From fc73457bcacbff07c6f1e78119888c30d8ac19ac Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 01:22:01 +0200 Subject: [PATCH 01/33] feat(picker): match every profile against the repo, not just the 19 with rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suggestion engine ranked profiles from five hand-maintained sources — dependency rules, path conventions, combo history, recents, featured. Between them they cover 19 of 85 profiles. The other 66 could only ever surface if the user had launched them before, so a directory that genuinely wanted one had no way to say so, and cycling past the third suggestion ran out of answers. profile-match scores every profile's OWN vocabulary (name, description, skill ids, MCP ids) against what the directory reveals about itself (dependencies, languages, marker files, entry names). Coverage goes to 85/85 and the card's tail keeps landing on something the repo justifies. Wired in as a new `matched` origin scored 8-30, so curation still leads: it can pass `featured` from ~0.32 strength but never outranks a detection, a confirmed combo, or something launched in this very directory. Four properties earned their place by being wrong first: Absolute strength, not relative to the run's best hit. Normalizing against the top scorer manufactures confidence from noise — a directory with nothing to say still produced a 1.00 "match", because the weakest signal present is still the strongest signal present. cue and gitguardex now correctly match nothing. Corroboration: a filename alone never carries a match. Every repo here has a CLAUDE.md, so every repo matched `claude-api` — above a real ROS workspace backed by an actual robot.urdf. Chasing each such word with the stopword list was a losing game; requiring one dependency, language, or marker hit ends the class. IDF weighting plus size damping, so `gstack` (70 terms, mentions everything) cannot beat `rust` (18 terms, mentions Rust) on a Cargo.toml by surface area. Both sides normalize through the same `tokenize`. Skipping it on the evidence side meant a profile indexed "robotic" while EXT_LANGUAGE emitted "robotics" — they never met, silently. Same failure class as the bash/TS drift the hook guards against. gstack itself goes to _featured.yaml rather than being made detectable, because it structurally cannot be: it is a WORKFLOW profile, describing how you want to work, and repo evidence only ever describes what the project IS. A .urdf says robotics; nothing on disk says "role-routed engineering". It scored 0.27 at rank #6 across the test repos; always-available is the honest mechanism. Also: `-js` is no longer folded as a plural (medusajs -> medusaj, nextjs -> nextj), which affected skill matching too; the df cut is skipped below 10 profiles, where it discarded every term shared by two and matched nothing; and manifest metadata keys are filtered, so `requires-python` and `[urls] issues` stop reading as dependencies named "research" and "linear". Verified on real repos: agv_stack surfaces ros2 #1 (previously unreachable), api-tester surfaces python + backend-base, kolarortopedia surfaces postgres + supabase, cue and gitguardex correctly surface nothing. 2819 pass / 0 fail. Co-Authored-By: Claude Opus 5 (1M context) --- profiles/_featured.yaml | 8 + resources/skills | 2 +- src/lib/catalog-index.ts | 17 +- src/lib/picker/flow.ts | 27 + src/lib/profile-match.test.ts | 259 ++++++++++ src/lib/profile-match.ts | 585 ++++++++++++++++++++++ src/lib/smart-loader-suggest.hook.test.ts | 6 +- src/lib/stack-suggest.ts | 45 +- 8 files changed, 939 insertions(+), 10 deletions(-) create mode 100644 src/lib/profile-match.test.ts create mode 100644 src/lib/profile-match.ts diff --git a/profiles/_featured.yaml b/profiles/_featured.yaml index bdfa0846..1d9c980c 100644 --- a/profiles/_featured.yaml +++ b/profiles/_featured.yaml @@ -16,6 +16,14 @@ featured: # --- focused workflow profiles --- - improver # 📈 plan → rank by ROI → goal-with-a-check → improve existing repos - headroom # 🪶 context compression — 60–95% fewer tokens (MCP tools + full Claude-traffic wrap) + - gstack # 🏭 58-role virtual engineering team: ship/deploy, design & real-browser QA, planning, safety rails + # Featured is the right home for gstack specifically because it is a + # WORKFLOW profile, not a domain one. The generic repo→profile matcher + # (lib/profile-match) now covers all 85 profiles, but it can only match what + # a directory reveals about itself — a `.urdf` says "robotics", a + # `Cargo.toml` says "rust". Nothing on disk says "I want role-routed + # engineering", so gstack scored 0.27 at rank #6 across the test repos. + # Always-available is the honest mechanism for a profile the repo can't ask for. # --- consolidation target: the 8 (appear once created) --- - commerce - growth diff --git a/resources/skills b/resources/skills index 673ceca8..e9e6657c 160000 --- a/resources/skills +++ b/resources/skills @@ -1 +1 @@ -Subproject commit 673ceca8cc1bb30995e987b58de79354a7041978 +Subproject commit e9e6657c3dbe9ee7ab16c3a5cb565a79c6559559 diff --git a/src/lib/catalog-index.ts b/src/lib/catalog-index.ts index a702caed..00aa423a 100644 --- a/src/lib/catalog-index.ts +++ b/src/lib/catalog-index.ts @@ -106,8 +106,15 @@ export interface IndexEntry { export interface SkillIndex { schema_version: string; - generated_at: string; - /** mtime (epoch ms) of the catalog this was derived from — staleness check. */ + /** + * mtime (epoch ms) of the catalog this was derived from. + * + * There is deliberately no `generated_at`: the index is a checked-in derived + * artifact, and a wall-clock stamp made every rebuild produce a one-line diff + * on a 432K file that nothing read. Staleness is decided by comparing file + * mtimes, so the content can — and should — be byte-identical for identical + * input. + */ catalog_mtime: number; counts: { skills: number; @@ -191,14 +198,15 @@ export function idFromSource(source: string, root: string): string | null { * left alone — and since they're indexed and queried through this same * function, leaving them alone is consistent on both sides. * - * Other guards: "ss" (class), "us" (status), "is" (analysis), and anything that + * Other guards: "ss" (class), "us" (status), "is" (analysis), "js" (nextjs, + * medusajs — a technology suffix, never a plural marker), and anything that * would fall under the minimum term length. */ function foldPlural(t: string): string { if (t.length <= MIN_TERM_LENGTH) return t; if (!/^[a-z0-9-]+$/.test(t)) return t; if (!t.endsWith("s")) return t; - if (/(ss|us|is)$/.test(t)) return t; + if (/(ss|us|is|js)$/.test(t)) return t; return t.slice(0, -1); } @@ -282,7 +290,6 @@ export function buildIndex(opts: { catalog?: string; root?: string } = {}): Skil return { schema_version: INDEX_SCHEMA_VERSION, - generated_at: new Date().toISOString(), catalog_mtime: catalogMtime, counts: { skills: skills.length, diff --git a/src/lib/picker/flow.ts b/src/lib/picker/flow.ts index cd2190fe..a6ffa925 100644 --- a/src/lib/picker/flow.ts +++ b/src/lib/picker/flow.ts @@ -11,11 +11,13 @@ import * as p from "@clack/prompts"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { recordCombo } from "../combo-history"; +import { matchProfilesForCwd } from "../profile-match"; import { mergeSignals, pathSignals, suggestStacks, type StackSuggestion, + type SuggestMatch, type SuggestSignal, } from "../stack-suggest"; import { CardPrompt, type CardSuggestion } from "./card"; @@ -29,6 +31,16 @@ import { } from "./tally"; import type { PickerInput, PickerOption, PickerOutput } from "./types"; +/** + * How many suggestions the card can cycle through. + * + * The engine's own default is 3, which was right when every suggestion came + * from a hand-curated source and the fourth would have been padding. With + * generic matching behind them there is a real ranked tail, so pressing Tab + * keeps landing on something the directory actually justifies. + */ +const SUGGESTION_LIMIT = 8; + /** * Whether the suggestion-first picker is active. On by default; `CUE_PICKER= * classic` (also `v1` / `legacy`) restores the two-screen flow while v2 beds in. @@ -78,6 +90,19 @@ export async function runPickerV2(input: PickerInput): Promise { } detected = detected.filter((d) => known.has(d.name)); + // Generic repo→profile matches. The curated sources cover 19 of 85 profiles; + // this scores every profile's own vocabulary against the directory so + // cycling through suggestions keeps finding relevant stacks instead of + // running out. Best-effort — no matches just means a shorter list. + let matched: SuggestMatch[] = []; + try { + matched = matchProfilesForCwd(input.cwd, { limit: SUGGESTION_LIMIT }) + .filter((m) => known.has(m.name)) + .map((m) => ({ name: m.name, strength: m.strength, reason: m.reason })); + } catch { + /* a directory we can't read yields no matches, not an error */ + } + const suggestions = suggestStacks({ profiles, detected, @@ -87,7 +112,9 @@ export async function runPickerV2(input: PickerInput): Promise { combos: input.combos, pairSuggestions: input.pairSuggestions, featured: input.featured, + matched, defaultSelector: input.defaultSelector, + limit: SUGGESTION_LIMIT, }); // Belt and braces: the engine only returns nothing when it was handed // nothing, but the card must always have something to show. diff --git a/src/lib/profile-match.test.ts b/src/lib/profile-match.test.ts new file mode 100644 index 00000000..8c8edd1f --- /dev/null +++ b/src/lib/profile-match.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, test } from "bun:test"; + +import { + MATCH_MIN_STRENGTH, + STRONG_MATCH_SCORE, + type MatchProbe, + loadProfileDocs, + matchProfiles, + matchProfilesForCwd, + repoEvidence, +} from "./profile-match"; + +/** + * A fake filesystem. Keys are absolute-ish paths; directory listings are + * derived from the keys so tests only describe files. + */ +function probeFor(files: Record): MatchProbe { + return { + exists: (p) => p in files || Object.keys(files).some((f) => f.startsWith(`${p}/`)), + read: (p) => files[p] ?? null, + list: (p) => { + const prefix = `${p}/`; + const out = new Set(); + for (const f of Object.keys(files)) { + if (!f.startsWith(prefix)) continue; + out.add(f.slice(prefix.length).split("/")[0]!); + } + return [...out]; + }, + }; +} + +const PROFILES = "/p"; + +/** A small profile library covering the shapes that matter. */ +const profileFiles: Record = { + [`${PROFILES}/rust/profile.yaml`]: ` +name: rust +description: All-in-one Rust profile — async, web, CLI, embedded +skills: + local: + - rust/async-tokio + - rust/serde + - rust/axum-api +`, + [`${PROFILES}/ros2/profile.yaml`]: ` +name: ros2 +description: "ROS 2 robot control via rosbridge — robotics tooling over WebSocket" +skills: + local: + - robotics/urdf-tools +`, + [`${PROFILES}/medusa-dev/profile.yaml`]: ` +name: medusa-dev +description: Medusa commerce backend and storefront development +skills: + local: + - medusa/medusa-reference +mcps: + - medusadocs +`, + // The giant. Mentions nearly every term the others do — the size-damping case. + [`${PROFILES}/everything/profile.yaml`]: ` +name: everything +description: Rust robot medusa commerce async serde axum tokio urdf robotics storefront backend +skills: + local: + - rust/async-tokio + - rust/serde + - rust/axum-api + - robotics/urdf-tools + - medusa/medusa-reference + - extra/one + - extra/two + - extra/three +`, + // Ignored: leading underscore. + [`${PROFILES}/_cache/profile.yaml`]: `name: _cache\ndescription: not a real profile`, + // Ignored: unparseable. + [`${PROFILES}/broken/profile.yaml`]: `name: [unclosed`, +}; + +const probe = probeFor(profileFiles); +const docs = loadProfileDocs(PROFILES, probe); + +describe("loadProfileDocs", () => { + test("reads every real profile and skips underscore dirs", () => { + expect(docs.map((d) => d.name).sort()).toEqual(["everything", "medusa-dev", "ros2", "rust"]); + }); + + test("a broken profile.yaml is skipped, not fatal", () => { + expect(docs.some((d) => d.name === "broken")).toBe(false); + expect(docs.length).toBeGreaterThan(0); + }); + + test("vocabulary comes from name, description and skill ids", () => { + const rust = docs.find((d) => d.name === "rust")!; + expect(rust.terms.has("rust")).toBe(true); + expect(rust.terms.has("tokio")).toBe(true); // from skill id + expect(rust.terms.has("embedded")).toBe(true); // from description + }); + + test("the profile's own name outweighs a description word", () => { + const rust = docs.find((d) => d.name === "rust")!; + expect(rust.terms.get("rust")!).toBeGreaterThan(rust.terms.get("embedded")!); + }); +}); + +describe("repoEvidence", () => { + test("reads dependencies from package.json, scope included", () => { + const ev = repoEvidence( + "/r", + probeFor({ "/r/package.json": JSON.stringify({ dependencies: { "@medusajs/medusa": "1" } }) }), + ); + expect(ev.terms.has("medusa")).toBe(true); + expect(ev.terms.has("medusajs")).toBe(true); + expect(ev.sources.get("medusa")).toBe("dependency"); + }); + + test("a malformed package.json contributes nothing and doesn't throw", () => { + const ev = repoEvidence("/r", probeFor({ "/r/package.json": "{{{ not json" })); + expect(ev.terms.has("medusa")).toBe(false); + }); + + test("one distinctive file settles a language; a common one needs two", () => { + const one = repoEvidence("/r", probeFor({ "/r/robot.urdf": "" })); + expect(one.terms.has("robot")).toBe(true); + + const strayPy = repoEvidence("/r", probeFor({ "/r/hack.py": "", "/r/main.ts": "" })); + expect(strayPy.terms.has("python")).toBe(false); + + const realPy = repoEvidence("/r", probeFor({ "/r/a.py": "", "/r/b.py": "" })); + expect(realPy.terms.has("python")).toBe(true); + }); + + test("universal scaffolding never becomes evidence", () => { + const ev = repoEvidence( + "/home/me/Documents/thing", + probeFor({ + "/home/me/Documents/thing/CLAUDE.md": "", + "/home/me/Documents/thing/README.md": "", + "/home/me/Documents/thing/src/x.ts": "", + "/home/me/Documents/thing/docs/y.md": "", + }), + ); + for (const noise of ["claude", "readme", "src", "docs", "document"]) { + expect(ev.terms.has(noise)).toBe(false); + } + }); + + test("the containing path is not evidence", () => { + // `~/Documents/` used to make every project match docs-writer. + const ev = repoEvidence("/home/me/Documents/rust-thing", probeFor({ "/home/me/Documents/rust-thing/a.md": "" })); + expect(ev.terms.has("rust")).toBe(false); + }); + + test("finds a nested manifest only when the root declares none", () => { + const nested = repoEvidence( + "/r", + probeFor({ "/r/app/package.json": JSON.stringify({ dependencies: { "@medusajs/medusa": "1" } }) }), + ); + expect(nested.terms.has("medusa")).toBe(true); + + const rootDeclares = repoEvidence( + "/r", + probeFor({ + "/r/package.json": JSON.stringify({ dependencies: { tokio: "1" } }), + "/r/app/package.json": JSON.stringify({ dependencies: { "@medusajs/medusa": "1" } }), + }), + ); + expect(rootDeclares.terms.has("tokio")).toBe(true); + expect(rootDeclares.terms.has("medusa")).toBe(false); + }); +}); + +describe("matchProfiles", () => { + const match = (files: Record) => matchProfiles(repoEvidence("/r", probeFor(files)), docs); + + test("a Rust repo matches the rust profile", () => { + const m = match({ "/r/Cargo.toml": "[dependencies]\ntokio = \"1\"\nserde = \"1\"\n", "/r/main.rs": "" }); + expect(m[0]!.name).toBe("rust"); + }); + + test("a ROS workspace matches ros2 off a single urdf", () => { + const m = match({ "/r/robot.urdf": "" }); + expect(m.map((x) => x.name)).toContain("ros2"); + }); + + test("a shop with its manifest in app/ still matches medusa", () => { + const m = match({ "/r/app/package.json": JSON.stringify({ dependencies: { "@medusajs/medusa": "1" } }) }); + expect(m.map((x) => x.name)).toContain("medusa-dev"); + }); + + // The whole point of size damping: `everything` shares every term with + // `rust`, so without it breadth alone would win each directory. + test("a broad profile does not outrank the specific one it contains", () => { + const m = match({ "/r/Cargo.toml": "[dependencies]\ntokio = \"1\"\n", "/r/main.rs": "" }); + const rust = m.findIndex((x) => x.name === "rust"); + const everything = m.findIndex((x) => x.name === "everything"); + expect(rust).toBeGreaterThanOrEqual(0); + if (everything >= 0) expect(rust).toBeLessThan(everything); + }); + + // The corroboration rule. Filenames alone used to make every repo in the + // workspace match whichever profile happened to share a scaffolding word. + test("filename evidence alone never carries a match", () => { + expect(match({ "/r/robot.md": "", "/r/notes.md": "" })).toEqual([]); + }); + + test("a directory with nothing to say matches nothing", () => { + // Not "matches its loudest noise at strength 1.0" — the bug that absolute + // scoring replaced relative-to-top-hit normalization to fix. + expect(match({ "/r/notes.md": "", "/r/todo.txt": "" })).toEqual([]); + }); + + test("strength is absolute, so a weak match reads as weak", () => { + const m = match({ "/r/robot.urdf": "" }); + const ros = m.find((x) => x.name === "ros2")!; + expect(ros.strength).toBeGreaterThanOrEqual(MATCH_MIN_STRENGTH); + expect(ros.strength).toBeLessThanOrEqual(1); + expect(ros.score / STRONG_MATCH_SCORE).toBeCloseTo(Math.min(1, ros.strength), 5); + }); + + test("results are sorted strongest-first and carry a reason", () => { + const m = match({ "/r/Cargo.toml": "[dependencies]\ntokio = \"1\"\n", "/r/main.rs": "" }); + expect(m.length).toBeGreaterThan(0); + expect([...m].sort((a, b) => b.score - a.score)).toEqual(m); + expect(m[0]!.reason.length).toBeGreaterThan(0); + expect(m[0]!.matchedTerms.length).toBeGreaterThan(0); + }); + + test("no profiles or no evidence yields no matches", () => { + expect(matchProfiles(repoEvidence("/r", probeFor({})), docs)).toEqual([]); + expect(matchProfiles(repoEvidence("/r", probeFor({ "/r/main.rs": "" })), [])).toEqual([]); + }); +}); + +describe("matchProfilesForCwd", () => { + test("honors the limit", () => { + const files = { "/r/Cargo.toml": "[dependencies]\ntokio = \"1\"\n", "/r/main.rs": "" }; + const all = matchProfilesForCwd("/r", { root: PROFILES, probe: probeFor({ ...profileFiles, ...files }) }); + const one = matchProfilesForCwd("/r", { + root: PROFILES, + probe: probeFor({ ...profileFiles, ...files }), + limit: 1, + }); + expect(one.length).toBeLessThanOrEqual(1); + expect(one.length).toBeLessThanOrEqual(all.length); + }); + + test("an unreadable tree returns no matches rather than throwing", () => { + const exploding: MatchProbe = { + exists: () => { throw new Error("nope"); }, + list: () => { throw new Error("nope"); }, + read: () => { throw new Error("nope"); }, + }; + expect(matchProfilesForCwd("/r", { root: PROFILES, probe: exploding })).toEqual([]); + }); +}); diff --git a/src/lib/profile-match.ts b/src/lib/profile-match.ts new file mode 100644 index 00000000..02340510 --- /dev/null +++ b/src/lib/profile-match.ts @@ -0,0 +1,585 @@ +/** + * Generic repo → profile matcher. + * + * `stack-suggest` ranks profiles from five hand-maintained sources: dependency + * detection rules, path conventions, combo history, recents, and the featured + * list. That covers 19 of 85 profiles. The other 66 — `gstack` among them — + * can only ever surface if the user launched them before, so a directory that + * genuinely wants one has no way to say so. + * + * The information is already there: every profile.yaml describes itself, and + * lists the skills and MCPs it carries. This module reads that text, reads what + * the directory looks like, and scores one against the other. Coverage goes + * from 19/85 to all of them, and the picker's suggestion list stops running out. + * + * Two properties matter more than raw overlap: + * + * **IDF weighting.** A term that appears in half the profiles ("skills", + * "deploy", "api") says nothing about which one to pick. Terms are weighted + * by how rare they are across the profile set, so a match on "cargo" counts + * for far more than a match on "build". + * + * **Size damping.** `gstack` bundles 58 roles and mentions nearly everything; + * `rust` mentions Rust. Without damping the big profile wins every directory + * by surface area alone. Scores are divided by the square root of the + * profile's vocabulary, so breadth stops being an advantage in itself. + * + * Inheritance is deliberately NOT resolved — the same reasoning as + * `catalog-index`'s MCP provider map. Nearly every profile inherits `core`, so + * resolved terms would make them all look alike. A profile is matched on what + * it declares about itself. + */ + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { parse as parseYaml } from "yaml"; + +import { tokenize } from "./catalog-index"; + +/** Per-source term weights for a PROFILE's own vocabulary. */ +const PROFILE_WEIGHTS = { + name: 4, + description: 2, + mcp: 2, + skill: 1.5, + other: 1, +} as const; + +/** Per-source term weights for what a DIRECTORY says about itself. */ +const EVIDENCE_WEIGHTS = { + /** A declared dependency is the strongest statement a repo makes. */ + dependency: 3, + /** A language inferred from file extensions present. */ + language: 3, + /** A marker file (Dockerfile, next.config.ts, ...). */ + marker: 2, + /** + * A top-level file or directory basename. Weak but genuinely generic: it is + * what lets `robot.urdf` reach the `ros2` profile, whose description says + * "ROS 2 robot control", with no rule written for it. + */ + entry: 1.2, +} as const; + +// Deliberately absent: the directory's own name and its parent's. Path evidence +// was tried and removed — `~/Documents/` made every project match +// `docs-writer` via "document", and `base-template` outranked the medusa +// profiles on "base"/"baseline". The one case it got right (a shop under +// `medusa-shops/`) is already handled explicitly, and better, by +// `pathSignals` in stack-suggest. + +/** + * Score a clear match reaches. Strength is measured against THIS, not against + * the run's best hit. + * + * Normalizing against the top scorer was the first implementation, and it was + * wrong in a way worth recording: it manufactures confidence out of noise. A + * directory with nothing to say still produced a 1.00-strength "match", because + * the weakest possible signal is still the strongest signal present. An + * absolute scale lets the honest answer — "this directory doesn't look like any + * profile in particular" — actually be expressed. + */ +export const STRONG_MATCH_SCORE = 10; + +/** Below this absolute strength a match isn't worth showing. */ +export const MATCH_MIN_STRENGTH = 0.25; + +/** + * A term present in more than this share of profiles carries no discriminating + * power, whatever its IDF works out to. "setup", "content" and "profile" appear + * across a fifth of the library; matching one says nothing about which profile + * to pick. + */ +export const MAX_DF_RATIO = 0.2; + +/** Below this many profiles the document-frequency cut is skipped entirely. */ +export const DF_CUT_MIN_PROFILES = 10; + +/** + * Terms that name universal project scaffolding or filesystem convention + * rather than anything about the project. + * + * `document` earns its place here the hard way: every project under + * `~/Documents/` contributed it via the path, so it matched `docs-writer` for + * the entire workspace. + */ +const EVIDENCE_STOPWORDS = new Set([ + // filesystem / scaffolding + "document", "documents", "home", "user", "users", "project", "projects", "repo", + "src", "lib", "libs", "bin", "dist", "build", "test", "tests", "spec", "specs", + "docs", "doc", "config", "configs", "script", "scripts", "asset", "assets", + "public", "static", "example", "examples", "sample", "samples", "template", + "templates", "node", "modules", "module", "vendor", "target", "coverage", + "packages", "tools", "util", "utils", "common", "shared", "temp", "tmp", + "cache", "logs", "content", "setup", "draft", "drafts", "action", "actions", + "lint", "eval", "evals", "main", "index", "types", "hooks", "components", + "pages", "styles", "license", "readme", "changelog", "makefile", + // package-internal words — every ecosystem has an @x/core and an @x/prompts, + // and matching on them ranked `core` first for a repo that merely uses clack + "core", "base", "baseline", "prompt", "prompts", "client", "server", "sdk", + "api", "cli", "runtime", "helper", "helpers", "plugin", "plugins", "profile", + "profiles", "development", "check", "fast", "awesome", "skill", "skills", + // agent tooling config — present in every repo in this workspace, so it says + // nothing about any of them + "claude", "agent", "agents", "cursor", "codex", "gemini", "copilot", "windsurf", +]); + +/** File extension → the terms that extension implies. */ +const EXT_LANGUAGE: Record = { + rs: ["rust", "cargo"], + py: ["python"], + go: ["golang"], + swift: ["swift", "ios"], + kt: ["kotlin", "android"], + java: ["java"], + rb: ["ruby", "rails"], + php: ["php"], + tsx: ["react", "typescript", "frontend"], + jsx: ["react", "javascript", "frontend"], + vue: ["vue", "frontend"], + svelte: ["svelte", "frontend"], + tf: ["terraform", "infrastructure"], + sol: ["solidity", "blockchain"], + ipynb: ["notebook", "research"], + urdf: ["robot", "robotics", "ros"], + xacro: ["robot", "robotics", "ros"], +}; + +/** + * Extensions distinctive enough that a single file settles the question. + * + * The rest need two, because one stray `.py` script in a TypeScript repo + * shouldn't make it a Python project. But a repo does not contain a lone + * `robot.urdf`, `main.tf`, or `Contract.sol` by accident — and requiring two + * made a real ROS workspace match nothing at all. + */ +const DISTINCTIVE_EXTS = new Set(["rs", "go", "swift", "kt", "rb", "tf", "sol", "urdf", "xacro", "ipynb", "vue", "svelte"]); + +/** Manifests worth looking for one directory down, for monorepo layouts. */ +const NESTED_MANIFESTS = ["package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod"]; + +/** + * Manifest METADATA keys, which the loose line-scanner would otherwise read as + * dependency names. + * + * These produced visibly wrong matches: `requires-python` and `authors` in a + * pyproject made a repo match the `research` profile on "author"/"keyword", + * and an `[project.urls] issues = ...` line made it match `linear` on "issue". + */ +const MANIFEST_METADATA_KEYS = new Set([ + "name", "version", "description", "license", "readme", "authors", "author", + "maintainers", "keywords", "classifiers", "homepage", "repository", "issues", + "documentation", "urls", "scripts", "entry", "edition", "workspace", "members", + "package", "project", "build", "requires", "toolchain", "module", "replace", + "exclude", "include", "features", "profile", "target", "dependencies", + "optional", "dev", "test", "default", "path", "branch", "true", "false", +]); + +/** Filenames that name a technology outright. */ +const MARKER_FILES: Record = { + "cargo.toml": ["rust", "cargo"], + "go.mod": ["golang"], + "pyproject.toml": ["python"], + "requirements.txt": ["python"], + "gemfile": ["ruby", "rails"], + "composer.json": ["php"], + dockerfile: ["docker", "container"], + "docker-compose.yml": ["docker", "compose"], + "docker-compose.yaml": ["docker", "compose"], + "package.xml": ["ros2", "robotics"], + "wp-config.php": ["wordpress"], + "medusa-config.ts": ["medusa", "commerce"], + "medusa-config.js": ["medusa", "commerce"], + "next.config.ts": ["nextjs", "react", "frontend"], + "next.config.js": ["nextjs", "react", "frontend"], + "next.config.mjs": ["nextjs", "react", "frontend"], + "vite.config.ts": ["vite", "frontend"], + "vite.config.js": ["vite", "frontend"], + "nuxt.config.ts": ["nuxt", "vue", "frontend"], + "svelte.config.js": ["svelte", "frontend"], + "astro.config.mjs": ["astro", "frontend"], + "tailwind.config.ts": ["tailwind", "frontend"], + "playwright.config.ts": ["playwright", "browser", "testing"], + "vercel.json": ["vercel", "deploy"], + "netlify.toml": ["netlify", "deploy"], + "serverless.yml": ["serverless", "aws"], + "terraform.tf": ["terraform", "infrastructure"], + "supabase": ["supabase", "postgres"], +}; + +/** One profile's self-declared vocabulary. */ +export interface ProfileDoc { + name: string; + description: string; + /** term → local weight, before IDF. */ + terms: Map; +} + +/** Where a piece of evidence came from. Drives the corroboration rule. */ +export type EvidenceSource = "dependency" | "language" | "marker" | "entry"; + +/** Sources that can stand on their own. See the corroboration rule below. */ +const STRONG_SOURCES: ReadonlySet = new Set(["dependency", "language", "marker"]); + +/** What a directory says about itself. */ +export interface RepoEvidence { + /** term → weight. */ + terms: Map; + /** term → short human reason, for the suggestion's "why". */ + reasons: Map; + /** term → which source produced it. */ + sources: Map; +} + +export interface ProfileMatch { + name: string; + /** 0..1, relative to the strongest match in this run. */ + strength: number; + /** Raw weighted score, before normalization. */ + score: number; + /** Up to three terms that drove the match, strongest first. */ + matchedTerms: string[]; + /** One-line explanation for the picker. */ + reason: string; +} + +/** Injectable filesystem access, mirroring `stack-suggest`'s PathProbe. */ +export interface MatchProbe { + exists: (p: string) => boolean; + list: (p: string) => string[]; + read: (p: string) => string | null; +} + +export const REAL_MATCH_PROBE: MatchProbe = { + exists: (p) => existsSync(p), + list: (p) => { + try { + return readdirSync(p); + } catch { + return []; + } + }, + read: (p) => { + try { + return readFileSync(p, "utf8"); + } catch { + return null; + } + }, +}; + +function repoRoot(): string { + return process.env.CUE_REPO_ROOT ?? process.env.SOUL_REPO_ROOT ?? join(homedir(), "Documents", "cue"); +} + +export function profilesRoot(): string { + return process.env.CUE_PROFILES_ROOT ?? join(repoRoot(), "profiles"); +} + +function addTerms(map: Map, terms: Iterable, weight: number): void { + for (const t of terms) { + // Highest-weighted source wins rather than accumulating, so repeating a + // word in a long description can't outweigh it being the profile's name. + map.set(t, Math.max(map.get(t) ?? 0, weight)); + } +} + +/** + * Read every profile.yaml and build its self-declared vocabulary. + * + * Unreadable or unparseable profiles are skipped, not fatal — one broken YAML + * shouldn't cost the picker its whole suggestion tail. + */ +export function loadProfileDocs(root: string = profilesRoot(), probe: MatchProbe = REAL_MATCH_PROBE): ProfileDoc[] { + const docs: ProfileDoc[] = []; + for (const entry of probe.list(root)) { + if (entry.startsWith("_") || entry.startsWith(".")) continue; + const file = join(root, entry, "profile.yaml"); + const raw = probe.read(file); + if (raw === null) continue; + + let doc: { + name?: string; + description?: string; + skills?: { local?: unknown[] }; + mcps?: unknown[]; + playbooks?: unknown[]; + commands?: unknown[]; + }; + try { + doc = parseYaml(raw) as typeof doc; + } catch { + continue; + } + if (!doc || typeof doc !== "object") continue; + + const name = typeof doc.name === "string" && doc.name ? doc.name : entry; + const description = typeof doc.description === "string" ? doc.description : ""; + const terms = new Map(); + + // The profile's own name, whole and split, is the strongest signal it has. + addTerms(terms, [name.toLowerCase(), ...tokenize(name.replace(/-/g, " "))], PROFILE_WEIGHTS.name); + addTerms(terms, tokenize(description), PROFILE_WEIGHTS.description); + + for (const item of doc.mcps ?? []) { + const id = typeof item === "string" ? item : (item as { id?: string } | null)?.id; + if (typeof id === "string" && id) addTerms(terms, [id.toLowerCase()], PROFILE_WEIGHTS.mcp); + } + + // Skill ids carry the domain vocabulary: "rust/sqlx-cli" → rust, sqlx, cli. + for (const item of doc.skills?.local ?? []) { + const id = typeof item === "string" ? item : (item as { id?: string } | null)?.id; + if (typeof id !== "string" || !id) continue; + addTerms(terms, tokenize(id.replace(/[/-]/g, " ")), PROFILE_WEIGHTS.skill); + } + + for (const list of [doc.playbooks, doc.commands]) { + for (const item of list ?? []) { + if (typeof item === "string") addTerms(terms, tokenize(item.replace(/-/g, " ")), PROFILE_WEIGHTS.other); + } + } + + docs.push({ name, description, terms }); + } + return docs; +} + +/** + * Dependency names declared in the usual manifests, lowercased. + * + * Searches the top level and one directory down. The nesting matters: a Medusa + * shop keeps its `package.json` in `app/`, so a top-level-only read found no + * dependencies at all and the shop matched nothing. + */ +function manifestDeps(cwd: string, probe: MatchProbe): string[] { + const out: string[] = []; + + const readPackageJson = (dir: string): void => { + const pkg = probe.read(join(dir, "package.json")); + if (pkg === null) return; + try { + const parsed = JSON.parse(pkg) as Record; + for (const field of ["dependencies", "devDependencies", "peerDependencies"]) { + const deps = parsed[field]; + if (deps && typeof deps === "object") out.push(...Object.keys(deps as Record)); + } + } catch { + /* a malformed package.json contributes nothing */ + } + }; + + const readLooseManifests = (dir: string): void => { + for (const file of ["pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod"]) { + const raw = probe.read(join(dir, file)); + if (raw === null) continue; + // Deliberately loose: we want identifier-shaped tokens, not a real parse + // of four different manifest grammars. Metadata keys are filtered because + // the scanner cannot tell `tokio = "1"` from `requires-python = ">=3.11"`. + for (const m of raw.matchAll(/^[\s"']*([a-zA-Z][a-zA-Z0-9._-]{2,})/gm)) { + const key = m[1]!.toLowerCase(); + if (MANIFEST_METADATA_KEYS.has(key)) continue; + // Hyphenated metadata (`build-backend`, `requires-python`) is metadata + // if either half is. + if (key.split(/[-_.]/).some((part) => MANIFEST_METADATA_KEYS.has(part))) continue; + out.push(m[1]!); + } + } + }; + + readPackageJson(cwd); + readLooseManifests(cwd); + + // Descend ONLY when the root declares nothing itself. + // + // A repo with its own manifest has already said what it is, and going deeper + // just collects unrelated sub-projects: scanning into cue's vendored skill + // tree made cue match `python` at 16.6, and gitguardex's template fixtures + // made it match `designer-medusa-next`. The nesting only needs handling for + // the case it was added for — a shop whose only package.json lives in `app/`. + if (out.length === 0) { + const SKIP = new Set(["node_modules", "dist", "build", "target", "vendor", "coverage", "resources", "templates", "fixtures"]); + const subdirs = probe + .list(cwd) + .filter((e) => !e.startsWith(".") && !e.includes(".") && !SKIP.has(e)) + .slice(0, 12); + for (const dir of subdirs) { + const full = join(cwd, dir); + if (NESTED_MANIFESTS.some((m) => probe.exists(join(full, m)))) { + readPackageJson(full); + readLooseManifests(full); + } + } + } + + return out.map((d) => d.toLowerCase()); +} + +/** + * Describe a directory as a weighted bag of terms. + * + * Best-effort throughout: an unreadable directory yields fewer terms, never an + * error. Only the top level is inspected — walking a whole tree would cost more + * than the suggestion is worth. + */ +export function repoEvidence(cwd: string, probe: MatchProbe = REAL_MATCH_PROBE): RepoEvidence { + const terms = new Map(); + const reasons = new Map(); + const sources = new Map(); + + /** + * Every evidence term goes through `tokenize` — the SAME normalizer the + * profile side uses. + * + * Skipping it is a silent-failure bug, not a shortcut: `tokenize` folds + * plurals, so a profile describing "robotics" indexes `robotic`, while a raw + * `EXT_LANGUAGE` value of "robotics" stays plural. The two never meet and the + * match simply doesn't happen, with nothing to show for it. Both sides + * normalize identically or neither does. + */ + const add = (list: Iterable, weight: number, reason: string, source: EvidenceSource) => { + for (const raw of list) { + for (const t of tokenize(raw.replace(/[@/_-]/g, " "))) { + if (EVIDENCE_STOPWORDS.has(t)) continue; + if ((terms.get(t) ?? 0) >= weight) continue; + terms.set(t, weight); + reasons.set(t, reason); + sources.set(t, source); + } + } + }; + + // Dependencies. A scoped name contributes both the scope and the package, + // so "@medusajs/medusa" reaches a profile that only says "medusa". + for (const dep of manifestDeps(cwd, probe)) { + const parts = dep.replace(/^@/, "").split(/[/@]/).filter(Boolean); + add([dep, ...parts, ...tokenize(dep.replace(/[@/_-]/g, " "))], EVIDENCE_WEIGHTS.dependency, `depends on ${dep}`, "dependency"); + } + + const entries = probe.list(cwd); + const files = entries.filter((e) => e.includes(".")); + + // Marker files. + for (const e of entries) { + const hit = MARKER_FILES[e.toLowerCase()]; + if (hit) add(hit, EVIDENCE_WEIGHTS.marker, e, "marker"); + } + + // Languages, by extension frequency. A single stray .py in a Rust repo + // shouldn't make it a Python project, so a language needs two files. + const extCount = new Map(); + for (const f of files) { + const ext = f.split(".").pop()?.toLowerCase() ?? ""; + if (ext) extCount.set(ext, (extCount.get(ext) ?? 0) + 1); + } + for (const [ext, count] of extCount) { + const langs = EXT_LANGUAGE[ext]; + if (!langs) continue; + if (count < (DISTINCTIVE_EXTS.has(ext) ? 1 : 2)) continue; + add(langs, EVIDENCE_WEIGHTS.language, `${count} .${ext} file${count === 1 ? "" : "s"}`, "language"); + } + + // Top-level entry basenames, files and directories alike. `robot.urdf` and + // `mrs1000_bringup` are the project describing itself in the only vocabulary + // it has; the stoplist keeps universal scaffolding out. + for (const e of entries) { + if (e.startsWith(".") || e.startsWith("_")) continue; + const stem = e.replace(/\.[^.]+$/, ""); + add(tokenize(stem.replace(/[-_]/g, " ")), EVIDENCE_WEIGHTS.entry, `${e} in this directory`, "entry"); + } + + return { terms, reasons, sources }; +} + +/** + * Score every profile against the directory's evidence. + * + * Returns matches sorted strongest-first, with `strength` normalized against + * the top scorer so callers can map it onto their own score band. + */ +export function matchProfiles(evidence: RepoEvidence, docs: ProfileDoc[]): ProfileMatch[] { + if (docs.length === 0 || evidence.terms.size === 0) return []; + + // Document frequency across profiles — how discriminating is each term? + const df = new Map(); + for (const doc of docs) { + for (const t of doc.terms.keys()) df.set(t, (df.get(t) ?? 0) + 1); + } + const n = docs.length; + // The document-frequency cut is a statistic, and a statistic over a handful + // of profiles is noise. Below `DF_CUT_MIN_PROFILES` it is skipped entirely, + // and it never drops below 2 — otherwise a small custom profile library + // discards every term shared by two profiles and matches nothing at all. + const maxDf = n < DF_CUT_MIN_PROFILES ? n : Math.max(2, Math.floor(n * MAX_DF_RATIO)); + const idf = (t: string): number => Math.log(1 + n / (df.get(t) ?? n)); + + const scored: ProfileMatch[] = []; + for (const doc of docs) { + // Breadth must not be an advantage in itself — see the module header. + const damp = Math.sqrt(Math.max(doc.terms.size, 1)); + let score = 0; + let corroborated = false; + const contributions: Array<{ term: string; value: number }> = []; + + for (const [term, evWeight] of evidence.terms) { + const profWeight = doc.terms.get(term); + if (profWeight === undefined) continue; + // Terms spread across a fifth of the library don't distinguish anything. + if ((df.get(term) ?? 0) > maxDf) continue; + const value = (evWeight * profWeight * idf(term)) / damp; + score += value; + contributions.push({ term, value }); + if (STRONG_SOURCES.has(evidence.sources.get(term) ?? "entry")) corroborated = true; + } + + if (score <= 0) continue; + + // Corroboration rule: a filename alone never carries a match. + // + // Without this, every repo in a workspace matched `claude-api` because they + // all have a CLAUDE.md, and `ros2` — backed by a real robot.urdf — ranked + // BELOW that noise. Growing the stopword list to chase each such word is a + // losing game; requiring one dependency, language, or marker hit ends the + // whole class. Filenames still sharpen a match that already stands up. + if (!corroborated) continue; + contributions.sort((a, b) => b.value - a.value); + const matchedTerms = contributions.slice(0, 3).map((c) => c.term); + const why = evidence.reasons.get(matchedTerms[0]!) ?? matchedTerms.join(", "); + scored.push({ + name: doc.name, + strength: 0, // filled in below + score, + matchedTerms, + reason: `matches ${matchedTerms.join(", ")} — ${why}`, + }); + } + + if (scored.length === 0) return []; + scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); + // Absolute, not relative to the best hit — a directory that matches nothing + // must be able to say so rather than crowning its loudest noise. + for (const s of scored) s.strength = Math.min(1, s.score / STRONG_MATCH_SCORE); + + return scored.filter((s) => s.strength >= MATCH_MIN_STRENGTH); +} + +/** + * Convenience wrapper: read the profiles, read the directory, score. + * + * The one I/O entry point, so `matchProfiles` itself stays pure and testable. + * Never throws — a failure here costs the suggestion tail, not the picker. + */ +export function matchProfilesForCwd( + cwd: string, + opts: { root?: string; probe?: MatchProbe; limit?: number } = {}, +): ProfileMatch[] { + try { + const probe = opts.probe ?? REAL_MATCH_PROBE; + const docs = loadProfileDocs(opts.root ?? profilesRoot(), probe); + const matches = matchProfiles(repoEvidence(cwd, probe), docs); + return opts.limit ? matches.slice(0, opts.limit) : matches; + } catch { + return []; + } +} diff --git a/src/lib/smart-loader-suggest.hook.test.ts b/src/lib/smart-loader-suggest.hook.test.ts index 44373e50..1e89200b 100644 --- a/src/lib/smart-loader-suggest.hook.test.ts +++ b/src/lib/smart-loader-suggest.hook.test.ts @@ -86,7 +86,11 @@ describe("smart-loader-suggest hook", () => { }); test("stays inside the 200ms budget", () => { - // Three runs; the slowest must still clear the contract. + // Warm up first: the very first invocation in a fresh process pays for + // page cache and shell startup that no real session pays twice, and it was + // enough to push a 100ms hook over the line intermittently. + runHook("warm up the caches with a throwaway prompt about coolify"); + // Three measured runs; the slowest must still clear the contract. const runs = [ runHook("deploy the backend to coolify and check the logs"), runHook("stripe checkout payment webhook signature"), diff --git a/src/lib/stack-suggest.ts b/src/lib/stack-suggest.ts index cbf871f2..f094d17e 100644 --- a/src/lib/stack-suggest.ts +++ b/src/lib/stack-suggest.ts @@ -69,13 +69,29 @@ export interface SuggestInput { /** Historical partners per primary, from session-log pair mining. */ pairSuggestions?: Map; featured?: string[]; + /** + * Generic repo→profile matches (see `profile-match`). The other sources are + * hand-maintained and between them cover 19 of 85 profiles; this one scores + * every profile's own vocabulary against the directory, so the suggestion + * list keeps going after curation runs out. + */ + matched?: SuggestMatch[]; /** Resolved Default selector (e.g. `"core"`), the last-resort suggestion. */ defaultSelector?: string; /** Max suggestions returned. Default 3. */ limit?: number; } -export type SuggestionOrigin = "detected" | "combo" | "recent" | "featured" | "default"; +/** A profile matched generically against the directory's own evidence. */ +export interface SuggestMatch { + name: string; + /** 0..1 absolute match strength. */ + strength: number; + /** One-line explanation, already human-readable. */ + reason: string; +} + +export type SuggestionOrigin = "detected" | "combo" | "recent" | "featured" | "matched" | "default"; export interface StackSuggestion { /** Conflict-free, deduped profile names. Always at least one. */ @@ -105,13 +121,26 @@ export const SCORE_RECENT_GLOBAL = 25; export const SCORE_FEATURED = 15; export const SCORE_DEFAULT = 5; +/** + * Band for generic matches, scaled by match strength. + * + * Placed so curation still leads but a strong match isn't buried: above the + * default always, past `SCORE_FEATURED` from ~0.32 strength, past a global + * recent from ~0.77. It can never outrank a real detection, a confirmed combo, + * or something the user launched in this very directory — those describe this + * project or this user, while a match only describes a resemblance. + */ +export const SCORE_MATCHED_MIN = 8; +export const SCORE_MATCHED_MAX = 30; + /** Origin ordering used as a deterministic tie-break when scores match. */ const ORIGIN_RANK: Record = { detected: 0, combo: 1, recent: 2, featured: 3, - default: 4, + matched: 4, + default: 5, }; /** @@ -227,7 +256,17 @@ export function suggestStacks(input: SuggestInput): StackSuggestion[] { record(parts, SCORE_FEATURED, "featured", ["featured pick"]); } - // 5. Default — the answer when the directory says nothing. Recorded last so + // 5. Generic matches — every profile scored against the directory's own + // evidence. Strongest first, so cycling past the curated answers keeps + // landing on something relevant instead of running out. + for (const m of [...(input.matched ?? [])].sort((a, b) => b.strength - a.strength)) { + if (!known.has(m.name)) continue; + const strength = Math.max(0, Math.min(1, m.strength)); + const score = Math.round(SCORE_MATCHED_MIN + strength * (SCORE_MATCHED_MAX - SCORE_MATCHED_MIN)); + record([m.name], score, "matched", [m.reason]); + } + + // 6. Default — the answer when the directory says nothing. Recorded last so // it only surfaces if it isn't already covered above. if (input.defaultSelector) { const parts = input.defaultSelector.split("+").filter((p) => known.has(p)); From cce1777b201376e543815df10a0a2c3b5bdda97b Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 01:59:11 +0200 Subject: [PATCH 02/33] fix(test): make cue handoff tests e2e, drop the global mock.module leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/commands/handoff.test.ts registered mock.module("../lib/handoff", ...). Bun's module mock lives in a PROCESS-GLOBAL registry that outlives the file that installs it, so whenever this file landed in the same worker before src/lib/handoff.test.ts, that file asserted against the stub instead of the real formatHandoffForAgent. The stub emitted only the header and the task summary, so exactly the four sections it omitted failed: Most useful skills / Also helpful / MCPs used / Notes Green locally, red in CI, purely on file ordering. Drive the command through spawnSync with XDG_CONFIG_HOME pointed at a temp dir instead. HANDOFFS_DIR is a module-level const baked from that env var at import time, so a fresh child picks up the temp dir and no global state is touched. Nothing is left to leak — this was the last mock.module in the repo. Also stronger: the router now runs against the real lib rather than a stub. 10 -> 18 tests, covering the --json branches, --skills level parsing, the --from default and the unknown-subcommand fallback. Verified: bun test --timeout 30000 (the CI command), 4 consecutive runs, 2819 pass / 1 skip / 0 fail. typecheck clean, lint unchanged at 6 warnings. --- src/commands/handoff.test.ts | 404 +++++++++++++++-------------------- 1 file changed, 167 insertions(+), 237 deletions(-) diff --git a/src/commands/handoff.test.ts b/src/commands/handoff.test.ts index b20bbcf9..c696f50e 100644 --- a/src/commands/handoff.test.ts +++ b/src/commands/handoff.test.ts @@ -1,284 +1,214 @@ /** * Tests for src/commands/handoff.ts * - * The command is a thin router over src/lib/handoff.ts. We mock the lib so - * tests never touch the real HANDOFFS_DIR (which is a module-level constant - * baked from XDG_CONFIG_HOME at import time and therefore cannot be redirected - * via beforeEach). + * Driven end-to-end via spawnSync with XDG_CONFIG_HOME pointed at a temp dir. * - * Coverage: - * - create: missing --task → usage error - * - create: valid args → calls createHandoff with parsed skills - * - latest: no handoff → "No handoffs yet" - * - latest: with handoff → calls formatHandoffForAgent - * - list: empty → "No handoffs" - * - list: with entries → prints each entry - * - show: not found → stderr error, returns 1 - * - inject: no handoff → stderr error, returns 1 + * Why e2e instead of mocking ../lib/handoff: `mock.module()` in Bun replaces the + * module in a PROCESS-GLOBAL registry that outlives the file registering it. An + * earlier version of this file mocked lib/handoff, which leaked the stub into + * src/lib/handoff.test.ts whenever the two landed in the same worker — that file + * then asserted against the stub's truncated output and failed in CI while + * passing locally, depending purely on file ordering. Spawning a child process + * gives real isolation: HANDOFFS_DIR is a module-level const baked from + * XDG_CONFIG_HOME at import time, so a fresh child picks up the temp dir and no + * global state is touched here. * - * Not tested: the actual filesystem I/O in lib/handoff.ts (covered separately - * by lib tests once written, or can be added with a dynamic-import pattern). + * Coverage: create (missing --task, valid, skill-level parsing), latest (empty, + * populated, --json), list (empty, populated, --json), show (found, not found), + * inject (empty, populated), and the default/unknown subcommand fallback. */ -import { mock } from "bun:test"; - -// ---- Mock lib/handoff before the command module imports it ---- -// Bun hoists mock.module calls, so this registers before the static import below. - -const mockHandoffStore: any[] = []; - -mock.module("../lib/handoff", () => ({ - createHandoff: (ctx: any) => { - const h = { id: "handoff-testid", ts: "2024-01-15T12:00:00.000Z", ...ctx }; - mockHandoffStore.push(h); - return h; - }, - getLatestHandoff: () => mockHandoffStore.at(-1) ?? null, - getHandoff: (id: string) => mockHandoffStore.find((h) => h.id === id) ?? null, - listHandoffs: (limit = 10) => mockHandoffStore.slice(-limit).reverse(), - formatHandoffForAgent: (h: any) => - `## Handoff from "${h.from_profile}" (${h.from_agent})\n> ${h.task_summary}\n`, -})); - import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { run } from "./handoff"; - -// ---- Output capture ---- - -let stdoutBuf: string[]; -let stderrBuf: string[]; -let origStdout: typeof process.stdout.write; -let origStderr: typeof process.stderr.write; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const CUE_BIN = join(import.meta.dir, "../index.ts"); + +// Skip when a child `bun` can't be spawned (some sandboxes / odd PATH setups). +const BUN_SPAWNABLE = spawnSync("bun", ["--version"], { encoding: "utf8" }).status === 0; + +let xdg: string; + +function cue(args: string[]): { status: number; stdout: string; stderr: string } { + const env = { ...process.env, XDG_CONFIG_HOME: xdg }; + delete env.CUE_LAUNCHING; + const res = spawnSync("bun", ["run", CUE_BIN, "handoff", ...args], { + encoding: "utf8", + timeout: 20000, + env, + }); + return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" }; +} + +/** Seed one handoff so read-side subcommands have something to find. */ +function seed(task = "Implement the auth feature"): string { + const res = cue([ + "create", + "--from", + "core", + "--task", + task, + "--skills", + "meta/careful:high,tools/context7:medium", + "--notes", + "check the env vars", + ]); + expect(res.status).toBe(0); + return res.stdout.match(/handoff-[a-z0-9]+/)![0]; +} beforeEach(() => { - stdoutBuf = []; - stderrBuf = []; - mockHandoffStore.length = 0; // reset the mock store - origStdout = process.stdout.write.bind(process.stdout); - origStderr = process.stderr.write.bind(process.stderr); - (process.stdout as any).write = (c: string | Uint8Array) => { - stdoutBuf.push(typeof c === "string" ? c : Buffer.from(c).toString()); - return true; - }; - (process.stderr as any).write = (c: string | Uint8Array) => { - stderrBuf.push(typeof c === "string" ? c : Buffer.from(c).toString()); - return true; - }; + xdg = mkdtempSync(join(tmpdir(), "cue-handoff-cmd-")); }); afterEach(() => { - process.stdout.write = origStdout; - process.stderr.write = origStderr; + rmSync(xdg, { recursive: true, force: true }); }); -// ---- create subcommand ---- - -describe("cue handoff create", () => { - test("missing --task → usage error on stderr, returns 1", async () => { - const code = await run(["create", "--from", "core"]); - expect(code).toBe(1); - expect(stderrBuf.join("")).toContain("Usage"); +describe.skipIf(!BUN_SPAWNABLE)("cue handoff create", () => { + test("missing --task prints usage to stderr and returns 1", () => { + const res = cue(["create", "--from", "core"]); + expect(res.status).toBe(1); + expect(res.stderr).toContain("Usage: cue handoff create"); + expect(res.stdout).not.toContain("Handoff created"); }); - test("missing --task with no other flags → usage error, returns 1", async () => { - const code = await run(["create"]); - expect(code).toBe(1); - expect(stderrBuf.join("")).toContain("cue handoff create"); + test("valid args create a handoff and print its id", () => { + const res = cue(["create", "--from", "core", "--task", "Fix the payment flow"]); + expect(res.status).toBe(0); + expect(res.stdout).toContain("Handoff created"); + expect(res.stdout).toMatch(/handoff-[a-z0-9]+/); + expect(res.stdout).toContain("cue handoff inject"); }); - test("valid create → calls createHandoff, confirms id in stdout", async () => { - const code = await run(["create", "--from", "core", "--task", "Build feature X"]); - expect(code).toBe(0); - const out = stdoutBuf.join(""); - expect(out).toContain("handoff-testid"); + test("--skills levels are parsed and routed to the right section", () => { + seed(); + const { stdout } = cue(["inject"]); + expect(stdout).toContain("**Most useful skills:** meta/careful"); + expect(stdout).toContain("**Also helpful:** tools/context7"); }); - test("--skills parsed into high/medium/low objects", async () => { - await run([ - "create", - "--from", "core", - "--task", "Testing skills parsing", - "--skills", "meta/analyze:high,meta/caveman:low,meta/help", - ]); - // The mock createHandoff captures the ctx.skills_used - const h = mockHandoffStore[0]; - expect(h).toBeDefined(); - expect(h.skills_used).toEqual([ - { id: "meta/analyze", usefulness: "high" }, - { id: "meta/caveman", usefulness: "low" }, - // No level → defaults to "medium" - { id: "meta/help", usefulness: "medium" }, - ]); - }); - - test("--from defaults to 'unknown' when omitted", async () => { - await run(["create", "--task", "Task without from"]); - const h = mockHandoffStore[0]; - expect(h.from_profile).toBe("unknown"); + test("a skill without an explicit level defaults to medium", () => { + cue(["create", "--from", "core", "--task", "t", "--skills", "plan/autoplan"]); + const { stdout } = cue(["inject"]); + expect(stdout).toContain("**Also helpful:** plan/autoplan"); + expect(stdout).not.toContain("Most useful"); }); - test("--notes passed through to createHandoff", async () => { - await run([ - "create", - "--from", "core", - "--task", "Task with notes", - "--notes", "Remember to run tests", - ]); - const h = mockHandoffStore[0]; - expect(h.notes).toBe("Remember to run tests"); + test("--from defaults to 'unknown' when omitted", () => { + cue(["create", "--task", "no from flag"]); + const { stdout } = cue(["inject"]); + expect(stdout).toContain('## Handoff from "unknown"'); }); }); -// ---- latest subcommand ---- - -describe("cue handoff latest", () => { - test("no handoffs → prints 'No handoffs yet'", async () => { - const code = await run(["latest"]); - expect(code).toBe(0); - expect(stdoutBuf.join("")).toContain("No handoffs yet"); +describe.skipIf(!BUN_SPAWNABLE)("cue handoff latest", () => { + test("with no handoffs prints 'No handoffs yet.' and returns 0", () => { + const res = cue(["latest"]); + expect(res.status).toBe(0); + expect(res.stdout).toContain("No handoffs yet."); + }); + + test("with a handoff prints the formatted context", () => { + seed("Implement the auth feature"); + const res = cue(["latest"]); + expect(res.status).toBe(0); + expect(res.stdout).toContain('## Handoff from "core" (claude-code)'); + expect(res.stdout).toContain("> Implement the auth feature"); + expect(res.stdout).toContain("**Notes:** check the env vars"); + }); + + test("--json emits the raw handoff object", () => { + seed("json me"); + const res = cue(["latest", "--json"]); + expect(res.status).toBe(0); + const parsed = JSON.parse(res.stdout); + expect(parsed.from_profile).toBe("core"); + expect(parsed.task_summary).toBe("json me"); + expect(parsed.skills_used).toEqual([ + { id: "meta/careful", usefulness: "high" }, + { id: "tools/context7", usefulness: "medium" }, + ]); }); - test("with a handoff → calls formatHandoffForAgent output", async () => { - // Create a handoff first - mockHandoffStore.push({ - id: "handoff-abc", - ts: "2024-01-15T10:00:00.000Z", - from_profile: "backend", - from_agent: "claude-code", - task_summary: "Implement pagination", - skills_used: [], - mcps_used: [], - notes: "", - }); - - const code = await run(["latest"]); - expect(code).toBe(0); - const out = stdoutBuf.join(""); - expect(out).toContain("backend"); - expect(out).toContain("Implement pagination"); + test("is the default subcommand when none is given", () => { + const res = cue([]); + expect(res.status).toBe(0); + expect(res.stdout).toContain("No handoffs yet."); }); - test("--json with a handoff → valid JSON output", async () => { - mockHandoffStore.push({ - id: "handoff-json", - ts: "2024-01-15T10:00:00.000Z", - from_profile: "core", - from_agent: "claude-code", - task_summary: "JSON test", - skills_used: [], - mcps_used: [], - notes: "", - }); - - const code = await run(["latest", "--json"]); - expect(code).toBe(0); - const data = JSON.parse(stdoutBuf.join("")); - expect(data.id).toBe("handoff-json"); - expect(data.from_profile).toBe("core"); - }); - - test("default subcommand (no args) behaves like latest", async () => { - const code = await run([]); - expect(code).toBe(0); - // No handoffs → "No handoffs yet" - expect(stdoutBuf.join("")).toContain("No handoffs yet"); + test("an unknown subcommand falls back to latest", () => { + const res = cue(["not-a-subcommand"]); + expect(res.status).toBe(0); + expect(res.stdout).toContain("No handoffs yet."); }); }); -// ---- list subcommand ---- - -describe("cue handoff list", () => { - test("empty list → prints 'No handoffs'", async () => { - const code = await run(["list"]); - expect(code).toBe(0); - expect(stdoutBuf.join("")).toContain("No handoffs"); +describe.skipIf(!BUN_SPAWNABLE)("cue handoff list", () => { + test("with no handoffs prints 'No handoffs.' and returns 0", () => { + const res = cue(["list"]); + expect(res.status).toBe(0); + expect(res.stdout).toContain("No handoffs."); }); - test("with entries → prints count and summary", async () => { - mockHandoffStore.push( - { - id: "handoff-1", - ts: "2024-01-15T10:00:00.000Z", - from_profile: "core", - to_profile: "backend", - from_agent: "claude-code", - task_summary: "First task", - skills_used: [], - mcps_used: [], - notes: "", - }, - { - id: "handoff-2", - ts: "2024-01-16T11:00:00.000Z", - from_profile: "backend", - from_agent: "codex", - task_summary: "Second task", - skills_used: [], - mcps_used: [], - notes: "", - }, - ); + test("lists each stored handoff with its id and task summary", () => { + seed("first task"); + const res = cue(["list"]); + expect(res.status).toBe(0); + expect(res.stdout).toContain("Recent handoffs (1):"); + expect(res.stdout).toMatch(/handoff-[a-z0-9]+/); + expect(res.stdout).toContain("first task"); + }); - const code = await run(["list"]); - expect(code).toBe(0); - const out = stdoutBuf.join(""); - expect(out).toContain("handoff-2"); // listed (reversed) - expect(out).toContain("handoff-1"); + test("--json emits an array", () => { + seed(); + const res = cue(["list", "--json"]); + expect(res.status).toBe(0); + const parsed = JSON.parse(res.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(1); + expect(parsed[0].from_profile).toBe("core"); }); }); -// ---- show subcommand ---- - -describe("cue handoff show", () => { - test("unknown id → stderr error, returns 1", async () => { - const code = await run(["show", "nonexistent-id"]); - expect(code).toBe(1); - expect(stderrBuf.join("")).toContain("not found"); +describe.skipIf(!BUN_SPAWNABLE)("cue handoff show", () => { + test("an existing id prints the formatted handoff", () => { + const id = seed("show me"); + const res = cue(["show", id]); + expect(res.status).toBe(0); + expect(res.stdout).toContain("> show me"); }); - test("known id → formats the handoff", async () => { - mockHandoffStore.push({ - id: "handoff-known", - ts: "2024-01-15T09:00:00.000Z", - from_profile: "gstack", - from_agent: "claude-code", - task_summary: "Show this one", - skills_used: [], - mcps_used: [], - notes: "", - }); - - const code = await run(["show", "handoff-known"]); - expect(code).toBe(0); - expect(stdoutBuf.join("")).toContain("gstack"); + test("a missing id writes an error to stderr and returns 1", () => { + const res = cue(["show", "handoff-does-not-exist"]); + expect(res.status).toBe(1); + expect(res.stderr).toContain('Handoff "handoff-does-not-exist" not found.'); }); -}); -// ---- inject subcommand ---- - -describe("cue handoff inject", () => { - test("no handoffs → stderr error, returns 1", async () => { - const code = await run(["inject"]); - expect(code).toBe(1); - expect(stderrBuf.join("")).toContain("No handoffs to inject"); + test("show with no id argument returns 1", () => { + const res = cue(["show"]); + expect(res.status).toBe(1); + expect(res.stderr).toContain("not found"); }); +}); - test("with a handoff → outputs formatted text", async () => { - mockHandoffStore.push({ - id: "handoff-inject", - ts: "2024-01-15T08:00:00.000Z", - from_profile: "frontend", - from_agent: "claude-code", - task_summary: "Inject this context", - skills_used: [], - mcps_used: [], - notes: "", - }); - - const code = await run(["inject"]); - expect(code).toBe(0); - const out = stdoutBuf.join(""); - expect(out).toContain("frontend"); - expect(out).toContain("Inject this context"); +describe.skipIf(!BUN_SPAWNABLE)("cue handoff inject", () => { + test("with no handoffs writes an error to stderr and returns 1", () => { + const res = cue(["inject"]); + expect(res.status).toBe(1); + expect(res.stderr).toContain("No handoffs to inject."); + }); + + test("with a handoff emits the full formatted block on stdout", () => { + seed("inject me"); + const res = cue(["inject"]); + expect(res.status).toBe(0); + expect(res.stdout).toContain('## Handoff from "core" (claude-code)'); + expect(res.stdout).toContain("> inject me"); + expect(res.stdout).toContain("**Most useful skills:**"); + expect(res.stdout).toContain("**Notes:**"); + expect(res.stderr).not.toContain("No handoffs"); }); }); From 36e507561df06b75a2b9e003541267d04e9baf26 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 01:59:29 +0200 Subject: [PATCH 03/33] chore: delete 733 lines of verified dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every deletion was re-verified by hand against a repo-wide fixed-string grep (excluding node_modules/dist/vendor, but INCLUDING .sh, .yaml, .md and extensionless bin scripts) plus a within-file usage check. Dead code's tests go with it — a test for dead code is also dead. Whole files, referenced by nothing but their own test: src/lib/incremental-materialize.ts 81 (+112 test) src/lib/skill-compressor.ts 68 (+59 test) src/lib/webhooks.ts 58 (+175 test) scripts/_test-ecc-materialize.ts 20 (referenced by nothing at all) Functions with exactly one occurrence in the repo — their own definition: discover.ts buildGemBadgeSvg 196 skill-deps.ts topologicalSort 61 (+18 test) Stale docs: bin/README.md 32 describes a `soul` CLI and a bin/cli/lib/ layout, neither of which exists Deliberately NOT deleted, though a generated report flagged them — 28 of its 76 non-picker findings were false positives, and deleting them blind would have broken the build: analytics.ts recordSkillUsage called by resources/hooks/skill-fire-tracker.sh bin/cue-slug called by bin/cue-learnings (the report's own grep passed --include='*.sh', so the extensionless caller never matched) runtime-gc.ts imported by commands/gc.ts and launch.ts handoff.ts (all 4 exports) imported by commands/handoff.ts kitty-image probeKittyTerminal, clearKittyImagesSequence, skill-deps parseDependencies, skill-router Router* types used within their own file; only the `export` keyword is redundant, which is churn, not dead code cloud.ts:255 "dead" branch reachable: `cue cloud push x` gives argv[2]="cloud" and falls to the default arm Also left alone: the picker block (~1057 lines) while that migration is in flight, and launch.ts's token-budget re-export shim (all five symbols are used inside launch.ts, so it is an export->import rewrite worth zero lines). Verified: bun test --timeout 30000, 4 consecutive runs, 2819 pass / 1 skip / 0 fail across 219 files. typecheck exit 0. lint 0 errors, 6 warnings (baseline). --- bin/README.md | 32 ----- scripts/_test-ecc-materialize.ts | 20 --- src/commands/discover.ts | 50 ------- src/lib/incremental-materialize.test.ts | 112 --------------- src/lib/incremental-materialize.ts | 81 ----------- src/lib/skill-compressor.test.ts | 59 -------- src/lib/skill-compressor.ts | 68 --------- src/lib/skill-deps.test.ts | 18 +-- src/lib/skill-deps.ts | 61 --------- src/lib/webhooks.test.ts | 175 ------------------------ src/lib/webhooks.ts | 58 -------- 11 files changed, 1 insertion(+), 733 deletions(-) delete mode 100644 bin/README.md delete mode 100644 scripts/_test-ecc-materialize.ts delete mode 100644 src/lib/incremental-materialize.test.ts delete mode 100644 src/lib/incremental-materialize.ts delete mode 100644 src/lib/skill-compressor.test.ts delete mode 100644 src/lib/skill-compressor.ts delete mode 100644 src/lib/webhooks.test.ts delete mode 100644 src/lib/webhooks.ts diff --git a/bin/README.md b/bin/README.md deleted file mode 100644 index ae8d43ad..00000000 --- a/bin/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# bin/ - -The `soul` CLI lives here. Bun-based (consistent with the macOS/Linux setup -flow). Subcommand dispatch only — actual logic is in `bin/cli/lib/`. - -## Layout - -``` -bin/ -├── soul # bash launcher → `bun bin/cli/index.ts "$@"` -└── cli/ - ├── index.ts # entrypoint, dispatches to commands/ - ├── commands/ # one file per `soul ` - └── lib/ # profile loader, resolvers, materializers -``` - -## Subcommands (planned) - -| Command | Purpose | -|---------------------|------------------------------------------------------------------| -| `soul use ` | Materialize a profile into CWD (or `--global` into ~/.claude/) | -| `soul list` | List profiles with skill/MCP counts and active marker | -| `soul new ` | Create a profile; `--from-scan` buckets discovered skills | -| `soul scan` | Print a tree of installed skills/plugins grouped by domain | -| `soul doctor` | Diff declared profile vs actual disk state; `--fix` repairs | -| `soul validate` | Schema + lint checks for one profile (or `--all`) | -| `soul init-shell` | Generate `claude-` aliases for zsh/bash/pwsh | - -Exit codes: -- `0` — success -- `1` — user error (bad args, missing profile) -- `2` — internal error diff --git a/scripts/_test-ecc-materialize.ts b/scripts/_test-ecc-materialize.ts deleted file mode 100644 index 665e9882..00000000 --- a/scripts/_test-ecc-materialize.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { loadProfile } from "../src/lib/profile-loader"; -import { materializeRuntime } from "../src/lib/runtime-materializer"; -import { join, resolve } from "node:path"; -import { homedir } from "node:os"; - -const profile = await loadProfile("ecc"); -console.log("rules :", profile.rules); -console.log("commands :", profile.commands); -console.log("hooks :", profile.hooks); - -const out = await materializeRuntime({ - profile, - agent: "claude-code", - runtimeRoot: join(homedir(), ".config", "cue", "runtime"), - skillSourceLookup: async (id) => resolve("resources/skills/skills", id), - mcpRegistry: {}, - userClaudeMd: "", - credentialsSource: undefined, -}); -console.log("\nbuilt:", out); diff --git a/src/commands/discover.ts b/src/commands/discover.ts index 55b335ac..652af56d 100644 --- a/src/commands/discover.ts +++ b/src/commands/discover.ts @@ -1775,56 +1775,6 @@ export function buildGemHeroBadges(gem: GemRepo, profile: string): string {

`; } -/** - * Standalone SVG card mimicking tokscale — dark gradient bg, three stat cards - * (Score · Stars · Profile) with bold colored numbers. Self-contained, no - * external font deps. Embed via raw URL or inline. - */ -export function buildGemBadgeSvg(gem: GemRepo, profile: string): string { - const t = tierPalette(gem.gem_score); - const s = starsPalette(gem.stars); - const esc = (str: string) => str.replace(/&/g, "&").replace(//g, ">"); - const W = 720, H = 220; - const cardW = 220, cardH = 100, gap = 20, padX = 20, cardY = 95; - const cards = [ - { label: "Score", value: `${gem.gem_score}`, sub: t.tier, color: `#${t.primary}`, bg: `#${t.label}` }, - { label: "Stars", value: s.display, sub: "github", color: `#${s.primary}`, bg: "#1f3a1d" }, - { label: "Profile", value: profile, sub: gem.has_skill_md ? "SKILL.md" : "matched", color: "#c084fc", bg: "#3a1d3a" }, - ]; - const updated = new Date().toISOString().split("T")[0]; - return ` - - - - - - - - - - 💎 cue · hidden gem - @${esc(gem.full_name)} -${cards.map((c, i) => { - const x = padX + i * (cardW + gap); - return ` - - ${esc(c.label)} - ${esc(c.value)} - ${esc(c.sub)} - `; -}).join("\n")} - cue discovery engine · scored ${updated} - github.com/opencue/cuecards -`; -} - function notifyOwner(gem: GemRepo, profile: string, opts: { dryRun?: boolean; force?: boolean } = {}): void { const log = loadNotifyLog(); if (log.notified[gem.full_name] && !opts.force) { diff --git a/src/lib/incremental-materialize.test.ts b/src/lib/incremental-materialize.test.ts deleted file mode 100644 index 9fdf4b02..00000000 --- a/src/lib/incremental-materialize.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { computeSkillHash, loadManifest, saveManifest, findChangedSkills } from "./incremental-materialize"; - -let tmp: string; - -beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "cue-incr-")); -}); -afterEach(() => { - try { rmSync(tmp, { recursive: true, force: true }); } catch {} -}); - -describe("computeSkillHash", () => { - test("returns consistent hash for same content", () => { - const dir = join(tmp, "skill-a"); - mkdirSync(dir); - writeFileSync(join(dir, "SKILL.md"), "# Test skill"); - const h1 = computeSkillHash(dir); - const h2 = computeSkillHash(dir); - expect(h1).toBe(h2); - expect(h1).toHaveLength(64); // sha256 hex - }); - - test("different content produces different hash", () => { - const dir1 = join(tmp, "skill-1"); - const dir2 = join(tmp, "skill-2"); - mkdirSync(dir1); - mkdirSync(dir2); - writeFileSync(join(dir1, "SKILL.md"), "# Skill A"); - writeFileSync(join(dir2, "SKILL.md"), "# Skill B"); - expect(computeSkillHash(dir1)).not.toBe(computeSkillHash(dir2)); - }); - - test("includes nested files in hash", () => { - const dir = join(tmp, "skill-nested"); - mkdirSync(dir); - mkdirSync(join(dir, "sub")); - writeFileSync(join(dir, "SKILL.md"), "# Main"); - const h1 = computeSkillHash(dir); - writeFileSync(join(dir, "sub/extra.md"), "extra"); - const h2 = computeSkillHash(dir); - expect(h1).not.toBe(h2); - }); - - test("empty dir returns a hash", () => { - const dir = join(tmp, "empty"); - mkdirSync(dir); - const h = computeSkillHash(dir); - expect(h).toHaveLength(64); - }); -}); - -describe("loadManifest / saveManifest", () => { - test("missing manifest returns empty object", () => { - expect(loadManifest(tmp)).toEqual({}); - }); - - test("round-trips correctly", () => { - const manifest = { "review/code-review": "abc123", "meta/doctor": "def456" }; - saveManifest(tmp, manifest); - expect(loadManifest(tmp)).toEqual(manifest); - }); -}); - -describe("findChangedSkills", () => { - test("detects added skills", () => { - const result = findChangedSkills( - { "a": "h1", "b": "h2" }, - { "a": "h1" }, - ); - expect(result.added).toEqual(["b"]); - expect(result.removed).toEqual([]); - expect(result.changed).toEqual([]); - }); - - test("detects removed skills", () => { - const result = findChangedSkills( - { "a": "h1" }, - { "a": "h1", "b": "h2" }, - ); - expect(result.removed).toEqual(["b"]); - expect(result.added).toEqual([]); - }); - - test("detects changed skills", () => { - const result = findChangedSkills( - { "a": "h1-new" }, - { "a": "h1-old" }, - ); - expect(result.changed).toEqual(["a"]); - expect(result.added).toEqual([]); - expect(result.removed).toEqual([]); - }); - - test("handles empty manifests", () => { - expect(findChangedSkills({}, {})).toEqual({ added: [], removed: [], changed: [] }); - }); - - test("complex diff", () => { - const result = findChangedSkills( - { "a": "1", "b": "2-new", "c": "3" }, - { "a": "1", "b": "2-old", "d": "4" }, - ); - expect(result.added).toEqual(["c"]); - expect(result.removed).toEqual(["d"]); - expect(result.changed).toEqual(["b"]); - }); -}); diff --git a/src/lib/incremental-materialize.ts b/src/lib/incremental-materialize.ts deleted file mode 100644 index ba52f187..00000000 --- a/src/lib/incremental-materialize.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Incremental materialization — hash-based skill change detection. - */ - -import { createHash } from "node:crypto"; -import { readFileSync, readdirSync, statSync, existsSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -const MANIFEST_FILE = ".cue-manifest.json"; - -/** - * Compute a sha256 hash of all files in a skill directory. - */ -export function computeSkillHash(skillDir: string): string { - const hash = createHash("sha256"); - const files = collectFiles(skillDir).sort(); - for (const file of files) { - hash.update(file); // include relative path in hash - hash.update(readFileSync(join(skillDir, file))); - } - return hash.digest("hex"); -} - -/** Recursively collect relative file paths. */ -function collectFiles(dir: string, prefix = ""): string[] { - const out: string[] = []; - let entries: string[]; - try { entries = readdirSync(dir); } catch { return out; } - for (const entry of entries) { - const full = join(dir, entry); - const rel = prefix ? `${prefix}/${entry}` : entry; - try { - if (statSync(full).isDirectory()) { - out.push(...collectFiles(full, rel)); - } else { - out.push(rel); - } - } catch { /* skip unreadable */ } - } - return out; -} - -/** - * Load the manifest from a runtime directory. - */ -export function loadManifest(runtimeDir: string): Record { - const path = join(runtimeDir, MANIFEST_FILE); - if (!existsSync(path)) return {}; - try { - return JSON.parse(readFileSync(path, "utf8")); - } catch { return {}; } -} - -/** - * Save the manifest to a runtime directory. - */ -export function saveManifest(runtimeDir: string, manifest: Record): void { - writeFileSync(join(runtimeDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2)); -} - -/** - * Find which skills changed between two manifests. - */ -export function findChangedSkills( - current: Record, - previous: Record, -): { added: string[]; removed: string[]; changed: string[] } { - const added: string[] = []; - const removed: string[] = []; - const changed: string[] = []; - - for (const id of Object.keys(current)) { - if (!(id in previous)) added.push(id); - else if (current[id] !== previous[id]) changed.push(id); - } - for (const id of Object.keys(previous)) { - if (!(id in current)) removed.push(id); - } - - return { added, removed, changed }; -} diff --git a/src/lib/skill-compressor.test.ts b/src/lib/skill-compressor.test.ts deleted file mode 100644 index fa159d35..00000000 --- a/src/lib/skill-compressor.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import { generateSkillIndex } from "./skill-compressor"; - -describe("generateSkillIndex — threshold behaviour", () => { - test("returns null when skillIds is empty (default threshold 10)", () => { - expect(generateSkillIndex([])).toBeNull(); - }); - - test("returns null when skillIds.length equals the threshold", () => { - const ids = Array.from({ length: 10 }, (_, i) => `cat/skill-${i}`); - expect(generateSkillIndex(ids, 10)).toBeNull(); - }); - - test("returns null when skillIds.length is below the threshold", () => { - const ids = Array.from({ length: 5 }, (_, i) => `cat/skill-${i}`); - expect(generateSkillIndex(ids, 10)).toBeNull(); - }); - - test("returns a string when skillIds.length exceeds the threshold", () => { - const ids = Array.from({ length: 11 }, (_, i) => `nonexistent/fake-${i}`); - const result = generateSkillIndex(ids, 10); - expect(typeof result).toBe("string"); - expect(result).not.toBeNull(); - }); - - test("custom threshold=1 with 2 ids returns a string", () => { - const result = generateSkillIndex(["x/a", "x/b"], 1); - expect(result).not.toBeNull(); - }); - - test("custom threshold=2 with 2 ids returns null", () => { - expect(generateSkillIndex(["x/a", "x/b"], 2)).toBeNull(); - }); -}); - -describe("generateSkillIndex — output structure (with non-existent skill ids)", () => { - const MANY = Array.from({ length: 12 }, (_, i) => `noop/fake-skill-${i}`); - - test("output starts with a skill index header", () => { - const result = generateSkillIndex(MANY, 10)!; - expect(result).toMatch(/^# Skill Index/); - }); - - test("output contains a markdown table header row", () => { - const result = generateSkillIndex(MANY, 10)!; - expect(result).toContain("| Skill | Trigger / Description | Category |"); - }); - - test("output contains the auto-generated footer line", () => { - const result = generateSkillIndex(MANY, 10)!; - expect(result).toContain("Index auto-generated by cue"); - }); - - test("reports correct skill count in header (0 when all ids missing on disk)", () => { - const result = generateSkillIndex(MANY, 10)!; - // Non-existent paths → summarizeSkill returns null → 0 resolved summaries - expect(result).toContain("# Skill Index (0 skills loaded)"); - }); -}); diff --git a/src/lib/skill-compressor.ts b/src/lib/skill-compressor.ts deleted file mode 100644 index d71e0d69..00000000 --- a/src/lib/skill-compressor.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Skill compressor — generate a condensed skill index for heavy profiles. - */ - -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { repoRoot } from "./repo-root"; - -const SKILLS_ROOT = join(repoRoot(), "resources", "skills", "skills"); - -interface SkillSummary { - id: string; - description: string; - triggers: string[]; - category: string; -} - -function extractTriggers(content: string): string[] { - const triggers: string[] = []; - // Extract quoted phrases that look like trigger patterns - const quotes = content.match(/"([^"]{5,60})"/g); - if (quotes) triggers.push(...quotes.slice(0, 3).map(q => q.replace(/"/g, ""))); - // Extract slash commands - const slashes = content.match(/\/[a-z][-a-z]+/g); - if (slashes) triggers.push(...slashes.slice(0, 2)); - return triggers; -} - -function summarizeSkill(id: string): SkillSummary | null { - const path = join(SKILLS_ROOT, id, "SKILL.md"); - if (!existsSync(path)) return null; - - const content = readFileSync(path, "utf8"); - const category = id.split("/")[0] ?? "unknown"; - - let description = ""; - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); - if (fmMatch) { - const descMatch = fmMatch[1]!.match(/^description:\s*["']?(.+?)["']?\s*$/m); - if (descMatch) description = descMatch[1]!; - } - if (!description) { - const firstLine = content.split("\n").find(l => l.trim() && !l.startsWith("#") && !l.startsWith("---")); - description = firstLine?.trim().slice(0, 100) ?? id; - } - - return { id, description, triggers: extractTriggers(content), category }; -} - -export function generateSkillIndex(skillIds: string[], threshold = 10): string | null { - if (skillIds.length <= threshold) return null; - - const summaries = skillIds.map(summarizeSkill).filter(Boolean) as SkillSummary[]; - - let md = `# Skill Index (${summaries.length} skills loaded)\n\n`; - md += `> This profile has many skills. Use this index to find the right one.\n`; - md += `> For full details, read \`./skills///SKILL.md\`\n\n`; - md += `| Skill | Trigger / Description | Category |\n`; - md += `|---|---|---|\n`; - - for (const s of summaries) { - const trigger = s.triggers.length ? s.triggers.join(", ") : s.description.slice(0, 60); - md += `| ${s.id} | ${trigger} | ${s.category} |\n`; - } - - md += `\n---\n*Index auto-generated by cue. ${summaries.length} skills compressed into ~${Math.ceil(md.length / 4)} tokens.*\n`; - return md; -} diff --git a/src/lib/skill-deps.test.ts b/src/lib/skill-deps.test.ts index 5c3d97ac..e7ca79f3 100644 --- a/src/lib/skill-deps.test.ts +++ b/src/lib/skill-deps.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; -import { parseDependencies, buildDependencyGraph, topologicalSort, explainWhy } from "./skill-deps"; +import { parseDependencies, buildDependencyGraph, explainWhy } from "./skill-deps"; const TEST_ROOT = join(import.meta.dir, "..", "..", "__test_skills_deps__"); @@ -61,22 +61,6 @@ describe("buildDependencyGraph", () => { }); }); -describe("topologicalSort", () => { - test("returns valid load order (deps before dependents)", () => { - const graph = buildDependencyGraph(["a/skill-a"]); - const order = topologicalSort(graph); - expect(order.indexOf("c/skill-c")).toBeLessThan(order.indexOf("b/skill-b")); - expect(order.indexOf("b/skill-b")).toBeLessThan(order.indexOf("a/skill-a")); - }); - - test("throws on cycle", () => { - const cyclic = new Map(); - cyclic.set("x", ["y"]); - cyclic.set("y", ["x"]); - expect(() => topologicalSort(cyclic)).toThrow("Cycle detected"); - }); -}); - describe("explainWhy", () => { test("finds paths to a transitive dependency", () => { const graph = buildDependencyGraph(["a/skill-a"]); diff --git a/src/lib/skill-deps.ts b/src/lib/skill-deps.ts index 0311e961..f15a9453 100644 --- a/src/lib/skill-deps.ts +++ b/src/lib/skill-deps.ts @@ -50,67 +50,6 @@ export function buildDependencyGraph(skillIds: string[]): Map return graph; } -/** - * Topological sort (Kahn's algorithm). Throws on cycle. - */ -export function topologicalSort(graph: Map): string[] { - const inDegree = new Map(); - for (const [node] of graph) inDegree.set(node, 0); - for (const [, deps] of graph) { - for (const dep of deps) { - if (!inDegree.has(dep)) inDegree.set(dep, 0); - // dep must be loaded before the node that depends on it - } - } - // In our graph, edges go from node → its deps (node depends on dep). - // For topo sort, dep must come before node. So in-degree counts how many - // nodes depend on a given node (i.e. how many times it appears as a dep). - for (const [, deps] of graph) { - for (const dep of deps) { - inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1); - } - } - // Wait — we want load order: deps first. Reverse the edge direction for Kahn's. - // Actually: node depends on dep means dep must load first. - // Let's recompute: in-degree of a node = number of its own dependencies. - const inDeg = new Map(); - const allNodes = new Set(); - for (const [node, deps] of graph) { - allNodes.add(node); - for (const d of deps) allNodes.add(d); - } - for (const n of allNodes) inDeg.set(n, 0); - // Edge: node → dep means "node depends on dep", so for load order - // we reverse: dep → node. In-degree of node = number of deps it has. - for (const [node, deps] of graph) { - inDeg.set(node, deps.length); - } - - const queue: string[] = []; - for (const [n, d] of inDeg) { - if (d === 0) queue.push(n); - } - - const result: string[] = []; - while (queue.length > 0) { - const n = queue.shift()!; - result.push(n); - // For each node that depends on n, decrement its in-degree - for (const [node, deps] of graph) { - if (deps.includes(n)) { - const newDeg = inDeg.get(node)! - 1; - inDeg.set(node, newDeg); - if (newDeg === 0) queue.push(node); - } - } - } - - if (result.length < allNodes.size) { - throw new Error("Cycle detected in skill dependency graph"); - } - return result; -} - /** * Return all paths from any root skill to the given skillId. * Each path is an array of skill IDs from root to target. diff --git a/src/lib/webhooks.test.ts b/src/lib/webhooks.test.ts deleted file mode 100644 index 84051ef5..00000000 --- a/src/lib/webhooks.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Tests for webhooks.ts. - * - * CONFIG_PATH in webhooks.ts is a module-level constant resolved from - * XDG_CONFIG_HOME at import time. To keep tests hermetic (never touching - * ~/.config/cue), we: - * 1. Set XDG_CONFIG_HOME to a temp dir BEFORE dynamically importing the module. - * 2. Create / delete the config.yaml inside that temp dir between tests. - * - * Dynamic import must be used (not a top-level static import) because static - * imports are hoisted and execute before beforeAll. - */ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -type FireWebhook = ( - event: "profile.modified" | "profile.created" | "profile.locked" | "profile.unlocked", - payload: Record, -) => Promise; - -let tmpDir: string; -let fireWebhook: FireWebhook; -let priorXdg: string | undefined; - -beforeAll(async () => { - tmpDir = mkdtempSync(join(tmpdir(), "cue-webhooks-test-")); - priorXdg = process.env.XDG_CONFIG_HOME; - // Point XDG_CONFIG_HOME at our sandbox BEFORE loading the module so - // CONFIG_PATH bakes in the temp path, not the real ~/.config path. - process.env.XDG_CONFIG_HOME = tmpDir; - const mod = await import("./webhooks"); - fireWebhook = mod.fireWebhook; -}); - -afterAll(() => { - rmSync(tmpDir, { recursive: true, force: true }); - if (priorXdg === undefined) delete process.env.XDG_CONFIG_HOME; - else process.env.XDG_CONFIG_HOME = priorXdg; -}); - -describe("fireWebhook", () => { - test("is a no-op when the config file does not exist", async () => { - // The tmpDir/cue/config.yaml does not exist yet. - const cfgPath = join(tmpDir, "cue", "config.yaml"); - expect(existsSync(cfgPath)).toBe(false); - - let fetchCalled = false; - const origFetch = globalThis.fetch; - globalThis.fetch = async () => { - fetchCalled = true; - return new Response(); - }; - - try { - await fireWebhook("profile.modified", { profile: "core" }); - expect(fetchCalled).toBe(false); - } finally { - globalThis.fetch = origFetch; - } - }); - - test("is a no-op when config.yaml has no webhooks key", async () => { - const cfgDir = join(tmpDir, "cue"); - mkdirSync(cfgDir, { recursive: true }); - writeFileSync(join(cfgDir, "config.yaml"), "# empty config\n"); - - let fetchCalled = false; - const origFetch = globalThis.fetch; - globalThis.fetch = async () => { - fetchCalled = true; - return new Response(); - }; - - try { - await fireWebhook("profile.modified", { profile: "core" }); - expect(fetchCalled).toBe(false); - } finally { - globalThis.fetch = origFetch; - rmSync(join(cfgDir, "config.yaml")); - } - }); - - test("is a no-op when config has webhooks but none match the event", async () => { - const cfgDir = join(tmpDir, "cue"); - mkdirSync(cfgDir, { recursive: true }); - writeFileSync( - join(cfgDir, "config.yaml"), - [ - "webhooks:", - ' - url: "http://example.com/hook"', - " events:", - ' - "profile.created"', - ].join("\n") + "\n", - ); - - let fetchCalled = false; - const origFetch = globalThis.fetch; - globalThis.fetch = async () => { - fetchCalled = true; - return new Response(); - }; - - try { - // Fire "profile.modified" — config only lists "profile.created" - await fireWebhook("profile.modified", { profile: "core" }); - expect(fetchCalled).toBe(false); - } finally { - globalThis.fetch = origFetch; - rmSync(join(cfgDir, "config.yaml")); - } - }); - - test("calls fetch with correct URL and JSON body when event matches", async () => { - const cfgDir = join(tmpDir, "cue"); - mkdirSync(cfgDir, { recursive: true }); - writeFileSync( - join(cfgDir, "config.yaml"), - [ - "webhooks:", - ' - url: "http://hook.example/notify"', - " events:", - ' - "profile.modified"', - ].join("\n") + "\n", - ); - - const calls: { url: string; body: string }[] = []; - const origFetch = globalThis.fetch; - globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => { - calls.push({ url: String(url), body: (init?.body as string) ?? "" }); - return new Response(null, { status: 200 }); - }; - - try { - await fireWebhook("profile.modified", { profile: "test-profile" }); - expect(calls).toHaveLength(1); - expect(calls[0]!.url).toBe("http://hook.example/notify"); - const parsed = JSON.parse(calls[0]!.body); - expect(parsed.event).toBe("profile.modified"); - expect(parsed.profile).toBe("test-profile"); - expect(typeof parsed.ts).toBe("string"); - } finally { - globalThis.fetch = origFetch; - rmSync(join(cfgDir, "config.yaml")); - } - }); - - test("silently swallows fetch errors (best-effort semantics)", async () => { - const cfgDir = join(tmpDir, "cue"); - mkdirSync(cfgDir, { recursive: true }); - writeFileSync( - join(cfgDir, "config.yaml"), - [ - "webhooks:", - ' - url: "http://unreachable.example/hook"', - " events:", - ' - "profile.locked"', - ].join("\n") + "\n", - ); - - const origFetch = globalThis.fetch; - globalThis.fetch = async () => { - throw new Error("network error"); - }; - - try { - // Must not throw — webhooks are best-effort. - await expect(fireWebhook("profile.locked", {})).resolves.toBeUndefined(); - } finally { - globalThis.fetch = origFetch; - rmSync(join(cfgDir, "config.yaml")); - } - }); -}); diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts deleted file mode 100644 index e49bb238..00000000 --- a/src/lib/webhooks.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Webhooks — fire notifications on profile events. - * Config: ~/.config/cue/config.yaml - */ - -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; - -const CONFIG_PATH = join( - process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), - "cue", - "config.yaml", -); - -interface WebhookConfig { - url: string; - events: string[]; -} - -interface CueConfig { - webhooks?: WebhookConfig[]; -} - -function loadConfig(): CueConfig { - if (!existsSync(CONFIG_PATH)) return {}; - try { - const yaml = require("yaml"); - return yaml.parse(readFileSync(CONFIG_PATH, "utf8")) ?? {}; - } catch { - return {}; - } -} - -export type WebhookEvent = "profile.modified" | "profile.created" | "profile.locked" | "profile.unlocked"; - -export async function fireWebhook(event: WebhookEvent, payload: Record): Promise { - const config = loadConfig(); - if (!config.webhooks?.length) return; - - const matching = config.webhooks.filter(w => w.events.includes(event)); - if (!matching.length) return; - - const body = JSON.stringify({ event, ts: new Date().toISOString(), ...payload }); - - for (const hook of matching) { - try { - await fetch(hook.url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, - signal: AbortSignal.timeout(5000), - }); - } catch { - // Webhooks are best-effort — don't block the CLI - } - } -} From 7e337c7b2a7513ef842af76a3aed4828df062b67 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 01:59:53 +0200 Subject: [PATCH 04/33] docs(dead-code): mark the report superseded, record the false-positive rate 28 of 76 non-picker findings were wrong. Record which ones and why, so the next pass re-verifies instead of trusting the confidence column: a 'high' rating only means the agent's grep came back empty, and those greps missed shell hooks, extensionless bin scripts, dynamic imports, and string-keyed command dispatch. --- DEAD-CODE-REPORT.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/DEAD-CODE-REPORT.md b/DEAD-CODE-REPORT.md index e504346e..d2a1c017 100644 --- a/DEAD-CODE-REPORT.md +++ b/DEAD-CODE-REPORT.md @@ -1,7 +1,37 @@ -# Dead-code report (generated, report only — nothing deleted) +# Dead-code report (generated — SUPERSEDED, see status below) Total findings: 85 — estimated 2587 lines. +> **Status (2026-07-27): acted on. Do not delete from this report unverified.** +> +> The findings below are raw agent output. They were independently re-verified +> before anything was removed, and **28 of the 76 non-picker findings turned out +> to be false positives** — deleting them as written would have broken the build. +> +> - **Deleted (733 lines):** commit `36e50756`. See that message for the itemized +> list and the reasoning for each exclusion. +> - **Left in place:** everything else, including the whole picker block +> (~1057 lines) while that migration is in flight. +> +> ### Why the raw findings can't be trusted directly +> +> | Finding | Reality | +> |---|---| +> | `analytics.ts recordSkillUsage` | called by `resources/hooks/skill-fire-tracker.sh` | +> | `bin/cue-slug` | called by `bin/cue-learnings` — the report's own grep passed `--include='*.sh'`, so the extensionless caller never matched | +> | `runtime-gc.ts` | imported by `commands/gc.ts` and `launch.ts`; deleting it breaks `cue gc` | +> | `handoff.ts` (all 4 exports) | imported by `commands/handoff.ts` | +> | `kitty-image` probes, `skill-deps parseDependencies`, `skill-router` types | used within their own file — only the `export` keyword is redundant, which is churn, not dead code | +> | `cloud.ts:255` "dead branch" | reachable: `cue cloud push x` gives `argv[2]="cloud"` and falls to the default arm | +> +> The recurring failure mode is **indirection the grep didn't model**: shell +> hooks, extensionless scripts, dynamic `await import()`, and string-keyed +> command dispatch. A `high` confidence rating in this report means the agent's +> grep came back empty — not that the symbol is unused. +> +> If you resume this sweep, re-verify each finding against the *whole* repo +> (no `--include` filters) and check within-file usage before removing anything. + ## Area: commands dynamic-reference check: true From 7d34aeb0fb1d87ad93d197f8d30267f1ce0f7339 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 02:06:52 +0200 Subject: [PATCH 05/33] feat(brief): hand the agent verified facts about the directory it launches in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A profile teaches the agent a domain. It cannot know that this repo runs on bun rather than npm, that the tests are behind `just check`, or where the entry point lives — so the agent guesses, and burns turns finding out. `lib/project-brief` scans that off the filesystem and the launcher hands it over: package manager (from the lockfile), the real test/build/lint/typecheck commands (package.json scripts, Makefile, justfile, Cargo, pyproject), entry points, layout, workspaces, data layer, what CI actually runs, and the default branch. Verified only — nothing inferred, because a wrong fact costs more than a missing one. `.env` values are never read; the scan notes only that a committed `.env.example` exists. Delivery is per process, deliberately NOT through the materialized memory file: the runtime is keyed by profile and shared by every directory and every parallel session using it, so repo-specific text there would leak across projects and race between sessions. claude-code takes the brief inline via `--append-system-prompt`; codex, which has no such flag, gets a per-cwd file plus one *static* pointer line in AGENTS.md. `cue brief` shows exactly what the agent receives. `--write` turns it into `.cue/project.md`: a machine block that refreshes and a `## Notes` section that never does — for the conventions no scanner can infer. `CUE_BRIEF=0` opts out entirely. Two bugs the real-repo smoke test caught, both fixed with tests: the layout list sorted alphabetically and spent its budget on `action/ agentshield/…` while cutting `src/`; and `--write` folded the notes back into the machine block, duplicating them on every rewrite. Tests: 33 new, driving the scanner through a stub probe (bun/pnpm/cargo/ python/go/monorepo fixtures, CI harvesting, caps, truncation, no-manifest → null, and an assertion that no `.env` is ever read), plus the brief-file merge and the per-agent injection. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-07-27-project-brief-design.md | 97 +++ src/commands/_index.ts | 4 + src/commands/brief.ts | 93 +++ src/commands/launch.ts | 32 +- src/lib/project-brief.test.ts | 376 +++++++++++ src/lib/project-brief.ts | 591 ++++++++++++++++++ src/lib/runtime-materializer.ts | 10 + 7 files changed, 1202 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-07-27-project-brief-design.md create mode 100644 src/commands/brief.ts create mode 100644 src/lib/project-brief.test.ts create mode 100644 src/lib/project-brief.ts diff --git a/docs/superpowers/specs/2026-07-27-project-brief-design.md b/docs/superpowers/specs/2026-07-27-project-brief-design.md new file mode 100644 index 00000000..c7ffca3a --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-project-brief-design.md @@ -0,0 +1,97 @@ +# Project brief — verified repo facts for the agent + +Status: approved 2026-07-27. + +## Problem + +A cue profile tells the agent what Medusa/Rust/Next.js *are*. It says nothing +about the directory the session actually runs in: which package manager, what +the test command is, where the entry points live. The agent guesses `npm test` +in a `bun` repo and burns turns finding out. + +Measured on the existing catalogue (85 profiles): persona 71, playbooks 16, +rules 6, qualityGates 1, persona_routing 0. The mechanisms are there; what is +missing is per-repo truth, which no profile can carry. + +## Constraint that shapes the design + +The runtime is keyed by profile selector — `~/.config/cue/runtime//` +— and the materialization hash covers the resolved profile and agent, not the +cwd. Repo-specific text written into that shared `CLAUDE.md` would leak into +every other directory using the same profile, and parallel sessions (the fleet) +would overwrite each other. **The brief must therefore be delivered per +process, never through the shared runtime file.** + +## Design + +### 1. Scanner — `src/lib/project-brief.ts` + +`scanBrief(cwd, probe?) → ProjectBrief | null`, pure apart from an injectable +filesystem probe. Verified facts only: + +| Field | Source | +|---|---| +| package manager | lockfile (`bun.lock`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`, `Cargo.lock`, `uv.lock`, `poetry.lock`) | +| test / build / lint / typecheck / dev | `package.json` scripts, `Makefile` targets, `justfile` recipes, Cargo, `pyproject.toml` | +| entry points | `bin`/`main`, `src/index.*`, `src/main.rs`, `main.go`, `cmd/*/main.go`, `manage.py` | +| layout | top-level directories, noise filtered, capped | +| workspaces | `workspaces`, `pnpm-workspace.yaml`, `turbo.json`, Cargo `[workspace]` | +| data layer | Prisma, Drizzle, Alembic, Medusa, Supabase config files | +| what CI runs | `run:` lines in `.github/workflows/*.yml`, filtered to test/build/lint verbs | +| default branch | `.git/refs/remotes/origin/HEAD` | +| env | whether `.env.example` exists — **values are never read** | + +Returns `null` when the directory has neither a manifest nor a `.git` — no +brief is better than a brief about nothing. + +`renderBrief(brief, {maxChars})` produces the markdown block, capped at ~1.5 KB +with explicit truncation. + +### 2. Delivery + +- **claude-code**: `--append-system-prompt ""` prepended to the + passthrough args. Per process, race-free, guaranteed in context. +- **codex**: no equivalent flag. The brief is written to + `/briefs/.md`, exported as `CUE_PROJECT_BRIEF`, and the + materialized `AGENTS.md` carries one **static** line pointing at it. Static + text keeps the shared file identical across directories, so nothing leaks and + the materialization hash is unaffected. + +### 3. `.cue/project.md` — opt-in + +Never written automatically. `cue brief --write` creates it: + +``` + +…machine block… + + +## Notes +- anything the scanner cannot infer +``` + +Re-running rewrites only the generated block; the notes survive. When the file +exists, its notes are appended to the injected brief. + +### 4. Command + +`cue brief` prints what the agent would receive. `--write` persists the file, +`--json` emits the structured scan. `CUE_BRIEF=0` disables injection entirely. + +### 5. Failure and size + +Every scan step is individually `try/catch`-ed; any failure drops that field, +and a total failure drops the brief — the launch never blocks on it. The block +is capped so it cannot meaningfully move the memory-file budget. + +### 6. Testing + +`project-brief.test.ts` drives the scanner through a stub probe: bun / pnpm / +cargo / python / monorepo fixtures, command extraction from all four manifest +kinds, layout noise filtering, caps and truncation, `null` without a manifest, +and an assertion that `.env` is never read. Separate tests cover the brief-file +merge (notes preserved, idempotent rewrite, missing markers) and the launch +arg/env wiring per agent. + +Out of scope: inferred conventions, LLM-written summaries, writing into the +repo's own CLAUDE.md/AGENTS.md, and compacting the 44 KB persona pile. diff --git a/src/commands/_index.ts b/src/commands/_index.ts index 32a2619d..cc91f6e7 100644 --- a/src/commands/_index.ts +++ b/src/commands/_index.ts @@ -332,6 +332,10 @@ export const COMMANDS = { summary: "Measure profile efficiency: tokens, skill usage, cost", load: () => import("./benchmark"), }, + brief: { + summary: "Show this directory's verified facts as handed to the agent; --write persists them", + load: () => import("./brief"), + }, tree: { summary: "Visualize profile inheritance tree with resources", load: () => import("./tree"), diff --git a/src/commands/brief.ts b/src/commands/brief.ts new file mode 100644 index 00000000..117218c4 --- /dev/null +++ b/src/commands/brief.ts @@ -0,0 +1,93 @@ +/** + * `cue brief` — show (or persist) the verified facts cue hands the agent about + * the current directory. + * + * The same scan runs automatically on every launch; this command exists so you + * can see exactly what the agent is told, and so you can turn it into a + * `.cue/project.md` you can annotate and commit. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { + BRIEF_FILE, + mergeBriefFile, + renderBrief, + scanBrief, + type ProjectBrief, +} from "../lib/project-brief"; + +const HELP = `cue brief — verified facts about this directory, as handed to the agent + +Usage: + cue brief Print the brief for the current directory + cue brief --write Create/refresh .cue/project.md (your notes are kept) + cue brief --json Print the structured scan + cue brief --path Scan another directory + +Every launch injects this automatically. CUE_BRIEF=0 turns injection off. +`; + +export async function run(args: string[]): Promise { + if (args.includes("-h") || args.includes("--help")) { + process.stdout.write(HELP); + return 0; + } + const pathIdx = args.indexOf("--path"); + const cwd = pathIdx >= 0 ? args[pathIdx + 1] ?? process.cwd() : process.cwd(); + const asJson = args.includes("--json"); + const write = args.includes("--write"); + + let brief: ProjectBrief | null = null; + try { + brief = scanBrief(cwd); + } catch { + brief = null; + } + + if (!brief) { + if (asJson) { + process.stdout.write(`${JSON.stringify({ brief: null, reason: "no manifest or git repo" })}\n`); + return 0; + } + process.stderr.write( + `cue brief: ${cwd} has no manifest or git repo — nothing verified to report\n`, + ); + return 1; + } + + if (asJson) { + process.stdout.write(`${JSON.stringify(brief, null, 2)}\n`); + return 0; + } + + if (!write) { + process.stdout.write(`${renderBrief(brief)}\n`); + return 0; + } + // The file carries notes in its own section; keeping them out of the machine + // block is what makes a rewrite idempotent. + const rendered = renderBrief(brief, { includeNotes: false }); + + const target = join(cwd, BRIEF_FILE); + let existing: string | null = null; + try { + existing = await readFile(target, "utf8"); + } catch { + /* first write */ + } + const merged = mergeBriefFile(existing, rendered); + try { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, merged); + } catch (err) { + process.stderr.write(`cue brief: could not write ${target}: ${(err as Error).message}\n`); + return 1; + } + process.stdout.write( + `${existing ? "refreshed" : "created"} ${BRIEF_FILE}` + + `${existing ? " (your notes were kept)" : " — add notes under ## Notes"}\n`, + ); + return 0; +} diff --git a/src/commands/launch.ts b/src/commands/launch.ts index 2091d3d3..aabfc4ea 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -2467,7 +2467,37 @@ export async function run(args: string[]): Promise { health: healthBadge, }); - const exitCode = await execAgent(realBin, parsed.passthrough, childEnv); + // Project brief — verified facts about THIS directory (package manager, the + // real test/build commands, layout). Delivered per process, never through the + // materialized memory file: that file is keyed by profile and shared by every + // directory and parallel session using it, so repo-specific text there would + // leak across projects. Best-effort in every step — a launch never fails + // because a scan did. `CUE_BRIEF=0` opts out. + let briefArgs: string[] = []; + if (process.env.CUE_BRIEF !== "0") { + try { + const { scanBrief, renderBrief, buildBriefInjection } = await import("../lib/project-brief"); + const scanned = scanBrief(cwd); + const rendered = scanned ? renderBrief(scanned) : ""; + if (rendered) { + const injection = buildBriefInjection({ + agent: agentKind, + brief: rendered, + briefDir: join(configDir(), "briefs"), + cwd, + }); + if (injection.file) { + const { mkdir, writeFile } = await import("node:fs/promises"); + await mkdir(dirname(injection.file.path), { recursive: true }); + await writeFile(injection.file.path, injection.file.content); + } + Object.assign(childEnv, injection.env); + briefArgs = injection.args; + } + } catch (err) { debug("launch:brief", err); } + } + + const exitCode = await execAgent(realBin, [...briefArgs, ...parsed.passthrough], childEnv); // Persist any /login done inside the session to its account dir now — // don't leave the only live rotated token stranded in the per-account runtime. if (agentKind === "claude-code") await rescueRuntimeCredsToOwner(runtimeKey); diff --git a/src/lib/project-brief.test.ts b/src/lib/project-brief.test.ts new file mode 100644 index 00000000..53bb8fc0 --- /dev/null +++ b/src/lib/project-brief.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, test } from "bun:test"; + +import { + BRIEF_FILE, + buildBriefInjection, + briefFileKey, + GEN_END, + GEN_START, + MAX_LAYOUT, + extractNotes, + mergeBriefFile, + parseJustRecipes, + parseMakeTargets, + renderBrief, + scanBrief, + splitBriefFile, + type BriefProbe, +} from "./project-brief"; + +/** + * Stub probe over a virtual tree. Keys are paths relative to `/repo`; a value + * of `null` marks a directory, a string marks a file with that content. + */ +function probeOf(tree: Record): BriefProbe { + const norm = (p: string) => p.replace(/^\/repo\/?/, "").replace(/\/$/, ""); + const entriesUnder = (dir: string): string[] => { + const prefix = dir === "" ? "" : `${dir}/`; + const names = new Set(); + for (const key of Object.keys(tree)) { + if (!key.startsWith(prefix)) continue; + const rest = key.slice(prefix.length); + if (rest.length === 0) continue; + names.add(rest.split("/")[0]!); + } + return [...names]; + }; + const isDir = (rel: string) => + tree[rel] === null || Object.keys(tree).some((k) => k.startsWith(`${rel}/`)); + return { + exists: (p) => { + const rel = norm(p); + return rel in tree || isDir(rel); + }, + read: (p) => { + const v = tree[norm(p)]; + return typeof v === "string" ? v : null; + }, + list: (p) => entriesUnder(norm(p)), + listDirs: (p) => { + const base = norm(p); + const prefix = base === "" ? "" : `${base}/`; + return entriesUnder(base).filter((n) => isDir(`${prefix}${n}`)); + }, + }; +} + +const scan = (tree: Record) => scanBrief("/repo", probeOf(tree)); + +describe("scanBrief", () => { + test("returns null for a directory with no manifest and no git", () => { + expect(scan({ "notes.txt": "hi" })).toBeNull(); + }); + + test("reads the package manager from the lockfile, not the ecosystem", () => { + const bun = scan({ "package.json": "{}", "bun.lock": "" }); + expect(bun?.packageManager).toEqual({ name: "bun", via: "bun.lock" }); + + const pnpm = scan({ "package.json": "{}", "pnpm-lock.yaml": "" }); + expect(pnpm?.packageManager?.name).toBe("pnpm"); + + const bare = scan({ "package.json": "{}" }); + expect(bare?.packageManager).toEqual({ name: "npm", via: "package.json (no lockfile)" }); + }); + + test("extracts the project's own scripts, routed through its package manager", () => { + const brief = scan({ + "bun.lock": "", + "package.json": JSON.stringify({ + scripts: { test: "bun test", build: "bun build src", lint: "biome lint src", typecheck: "tsc --noEmit" }, + }), + }); + expect(brief?.commands).toEqual([ + { label: "test", command: "bun run test" }, + { label: "build", command: "bun run build" }, + { label: "lint", command: "bun run lint" }, + { label: "typecheck", command: "bun run typecheck" }, + ]); + }); + + test("falls back to Makefile and justfile targets", () => { + const make = scan({ Makefile: "test:\n\tgo test ./...\nbuild:\n\tgo build\n" }); + expect(make?.commands).toContainEqual({ label: "test", command: "make test" }); + + const just = scan({ justfile: "check:\n cargo clippy\nrun port:\n cargo run\n" }); + expect(just?.commands).toContainEqual({ label: "test", command: "just check" }); + expect(just?.commands).toContainEqual({ label: "dev", command: "just run" }); + }); + + test("knows the ecosystem defaults for cargo, python and go", () => { + const rust = scan({ "Cargo.toml": "[package]\nname='x'", "Cargo.lock": "" }); + expect(rust?.commands).toContainEqual({ label: "test", command: "cargo test" }); + expect(rust?.packageManager?.name).toBe("cargo"); + + const py = scan({ "pyproject.toml": "[tool.pytest.ini_options]\n[tool.ruff]\n" }); + expect(py?.commands).toContainEqual({ label: "test", command: "pytest" }); + expect(py?.commands).toContainEqual({ label: "lint", command: "ruff check ." }); + + const go = scan({ "go.mod": "module x" }); + expect(go?.commands).toContainEqual({ label: "test", command: "go test ./..." }); + }); + + test("the first source of a label wins — no duplicate rows", () => { + const brief = scan({ + "package.json": JSON.stringify({ scripts: { test: "vitest" } }), + Makefile: "test:\n\tmake-test\n", + }); + expect(brief?.commands.filter((c) => c.label === "test")).toHaveLength(1); + expect(brief?.commands[0]?.command).toBe("npm run test"); + }); + + test("collects entry points from the manifest and conventional paths", () => { + const brief = scan({ + "package.json": JSON.stringify({ bin: { cue: "bin/cue.mjs" }, main: "dist/index.js" }), + "src/index.ts": "", + "cmd/server/main.go": "", + }); + expect(brief?.entrypoints).toContain("bin/cue.mjs"); + expect(brief?.entrypoints).toContain("src/index.ts"); + expect(brief?.entrypoints.length).toBeLessThanOrEqual(4); + }); + + test("layout keeps real directories and drops build noise", () => { + const brief = scan({ + "package.json": "{}", + "src/a.ts": "", + "docs/a.md": "", + "node_modules/pkg/index.js": "", + "dist/out.js": "", + ".git/HEAD": "ref: refs/heads/main", + ".next/x": "", + }); + expect(brief?.layout).toEqual(["src", "docs"]); + }); + + test("caps the layout list and reports how many it hid", () => { + const tree: Record = { "package.json": "{}" }; + for (let i = 0; i < 20; i++) tree[`dir${String(i).padStart(2, "0")}/f.ts`] = ""; + const brief = scan(tree); + expect(brief?.layout).toHaveLength(MAX_LAYOUT); + expect(brief?.layoutMore).toBe(12); + expect(renderBrief(brief!)).toContain("(+12 more)"); + }); + + test("source directories outrank the alphabet in the layout budget", () => { + const tree: Record = { "package.json": "{}" }; + for (const d of ["action", "agentshield", "awesome", "content", "drafts", "evals", "web", "src", "profiles", "resources"]) { + tree[`${d}/f.ts`] = ""; + } + const layout = scan(tree)?.layout ?? []; + expect(layout.slice(0, 4)).toEqual(["src", "web", "profiles", "resources"]); + expect(layout).not.toContain("drafts"); + }); + + test("reports workspaces from every monorepo dialect", () => { + const npmWs = scan({ "package.json": JSON.stringify({ workspaces: ["apps/*", "packages/*"] }) }); + expect(npmWs?.workspaces).toEqual(["apps/*", "packages/*"]); + + const pnpmWs = scan({ + "package.json": "{}", + "pnpm-workspace.yaml": "packages:\n - 'apps/*'\n - packages/*\n", + "turbo.json": "{}", + }); + expect(pnpmWs?.workspaces).toEqual(["apps/*", "packages/*", "turborepo"]); + }); + + test("names the data layer when a schema is present", () => { + const brief = scan({ "package.json": "{}", "prisma/schema.prisma": "" }); + expect(brief?.data).toEqual(["prisma (prisma/schema.prisma)"]); + }); + + test("quotes what CI actually runs, filtered and capped", () => { + const workflow = [ + "jobs:", + " ci:", + " steps:", + " - run: bun install", + " - run: bun test", + " - run: bun run typecheck", + " - run: echo hello", + ].join("\n"); + const brief = scan({ "package.json": "{}", ".github/workflows/ci.yml": workflow }); + expect(brief?.ci).toEqual(["bun test", "bun run typecheck"]); + }); + + test("reads the default branch from the origin ref", () => { + const brief = scan({ + "package.json": "{}", + ".git/refs/remotes/origin/HEAD": "ref: refs/remotes/origin/main\n", + }); + expect(brief?.defaultBranch).toBe("main"); + }); + + test("notes .env.example without ever reading a .env", () => { + const reads: string[] = []; + const base = probeOf({ "package.json": "{}", ".env.example": "API_KEY=", ".env": "API_KEY=sk-real" }); + const spy: BriefProbe = { ...base, read: (p) => { reads.push(p); return base.read(p); } }; + const brief = scanBrief("/repo", spy); + expect(brief?.hasEnvExample).toBe(true); + expect(reads.some((p) => /\.env$/.test(p))).toBe(false); + expect(reads.some((p) => /\.env\.example$/.test(p))).toBe(false); + }); + + test("survives a manifest that is not valid JSON", () => { + const brief = scan({ "package.json": "{ this is not json", "bun.lock": "" }); + expect(brief).not.toBeNull(); + expect(brief?.commands).toEqual([]); + }); + + test("picks up notes from an existing .cue/project.md", () => { + const brief = scan({ + "package.json": "{}", + [BRIEF_FILE]: `${GEN_START}\nold\n${GEN_END}\n\n## Notes\n- run migrations first\n`, + }); + expect(brief?.notes).toEqual(["run migrations first"]); + }); +}); + +describe("renderBrief", () => { + const brief = scan({ + "bun.lock": "", + "package.json": JSON.stringify({ scripts: { test: "bun test" }, bin: { cue: "bin/cue.mjs" } }), + "src/index.ts": "", + "docs/x.md": "", + })!; + + test("renders aligned facts with a header the agent can act on", () => { + const text = renderBrief(brief); + expect(text).toContain("## This project"); + expect(text).toContain("Prefer these commands over guesses"); + expect(text).toContain("package manager bun (bun.lock)"); + expect(text).toContain("test bun run test"); + expect(text).toContain("layout src/ docs/"); + }); + + test("truncates visibly instead of blowing the budget", () => { + const text = renderBrief({ ...brief, notes: Array.from({ length: 200 }, (_, i) => `note ${i}`) }); + expect(text.length).toBeLessThanOrEqual(1500); + expect(text).toContain("(brief truncated)"); + }); + + test("an empty brief renders nothing at all", () => { + expect( + renderBrief({ + root: "/repo", + commands: [], + entrypoints: [], + layout: [], + layoutMore: 0, + workspaces: [], + data: [], + ci: [], + hasEnvExample: false, + notes: [], + }), + ).toBe(""); + }); +}); + +describe(".cue/project.md handling", () => { + test("splits the generated block from everything else", () => { + const file = `${GEN_START}\nmachine\n${GEN_END}\n\n## Notes\n- a\n`; + const { generated, rest } = splitBriefFile(file); + expect(generated).toBe("machine"); + expect(rest).toContain("## Notes"); + }); + + test("a file without markers is treated as all notes — never overwritten", () => { + const { generated, rest } = splitBriefFile("## Notes\n- hand written\n"); + expect(generated).toBe(""); + expect(rest).toContain("hand written"); + }); + + test("merging replaces only the machine block", () => { + const existing = `${GEN_START}\nOLD FACTS\n${GEN_END}\n\n## Notes\n- keep me\n`; + const merged = mergeBriefFile(existing, "NEW FACTS"); + expect(merged).toContain("NEW FACTS"); + expect(merged).not.toContain("OLD FACTS"); + expect(merged).toContain("- keep me"); + }); + + test("merging is idempotent", () => { + const once = mergeBriefFile(null, "FACTS"); + const twice = mergeBriefFile(once, "FACTS"); + expect(twice).toBe(mergeBriefFile(twice, "FACTS")); + expect(twice.match(new RegExp(GEN_START, "g"))).toHaveLength(1); + }); + + test("a new file ships with a notes template", () => { + const fresh = mergeBriefFile(null, "FACTS"); + expect(fresh).toContain("## Notes"); + expect(fresh).toContain("FACTS"); + }); + + test("extractNotes reads bullets under any Notes heading and ignores prose", () => { + const file = "## Notes\nsome prose\n- first\n* second\n\n## Other\n- ignored\n"; + expect(extractNotes(file)).toEqual(["first", "second"]); + }); +}); + +describe("manifest parsers", () => { + test("Makefile targets skip variables and recipe bodies", () => { + const targets = parseMakeTargets("VAR := 1\ntest:\n\techo hi\n.PHONY: test\nbuild: test\n\techo\n"); + expect(targets).toContain("test"); + expect(targets).toContain("build"); + expect(targets).not.toContain("VAR"); + }); + + test("justfile recipes tolerate parameters", () => { + expect(parseJustRecipes("check:\n x\nrun port='3000':\n y\n")).toEqual(["check", "run"]); + }); +}); + +describe("buildBriefInjection", () => { + const brief = "## This project\n\ntest bun run test"; + + test("claude-code gets the brief inline as a system-prompt append", () => { + const out = buildBriefInjection({ + agent: "claude-code", + brief, + briefDir: "/cfg/briefs", + cwd: "/repo", + }); + expect(out.args).toEqual(["--append-system-prompt", brief]); + expect(out.env).toEqual({}); + expect(out.file).toBeUndefined(); + }); + + test("codex gets a per-directory file plus a pointer env var", () => { + const out = buildBriefInjection({ agent: "codex", brief, briefDir: "/cfg/briefs", cwd: "/repo" }); + expect(out.args).toEqual([]); + expect(out.file?.path).toBe(`/cfg/briefs/${briefFileKey("/repo")}.md`); + expect(out.file?.content).toBe(`${brief}\n`); + expect(out.env.CUE_PROJECT_BRIEF).toBe(out.file?.path); + }); + + test("different directories never share a brief file", () => { + const a = buildBriefInjection({ agent: "codex", brief, briefDir: "/cfg/briefs", cwd: "/repo/a" }); + const b = buildBriefInjection({ agent: "codex", brief, briefDir: "/cfg/briefs", cwd: "/repo/b" }); + expect(a.file?.path).not.toBe(b.file?.path); + // …and the same directory always resolves to the same file. + expect(briefFileKey("/repo/a")).toBe(briefFileKey("/repo/a")); + }); + + test("an empty brief injects nothing at all", () => { + for (const agent of ["claude-code", "codex"]) { + const out = buildBriefInjection({ agent, brief: " ", briefDir: "/cfg/briefs", cwd: "/repo" }); + expect(out).toEqual({ args: [], env: {} }); + } + }); +}); + +describe("notes never leak into the generated block", () => { + test("renderBrief can omit notes, and a rewrite stays idempotent", () => { + const brief = scan({ "package.json": "{}" })!; + const withNotes = { ...brief, notes: ["migrations first"] }; + expect(renderBrief(withNotes)).toContain("migrations first"); + expect(renderBrief(withNotes, { includeNotes: false })).not.toContain("migrations first"); + + const machine = renderBrief(withNotes, { includeNotes: false }); + const once = mergeBriefFile(null, machine); + const withUserNote = `${once}\n- migrations first\n`; + const twice = mergeBriefFile(withUserNote, machine); + expect(splitBriefFile(twice).generated).not.toContain("migrations first"); + expect(twice).toContain("- migrations first"); + }); +}); diff --git a/src/lib/project-brief.ts b/src/lib/project-brief.ts new file mode 100644 index 00000000..ae04d29b --- /dev/null +++ b/src/lib/project-brief.ts @@ -0,0 +1,591 @@ +/** + * Project brief — verified facts about the directory the agent is launching in. + * + * A profile teaches the agent a domain; it can't know that *this* repo uses bun + * rather than npm, that the tests run through `just check`, or where the entry + * point lives. This module reads that off the filesystem — manifests, lockfiles, + * CI config — and renders a compact block the launcher hands to the agent. + * + * Rules of the house: + * - Verified only. Everything here comes from a file that says so; nothing is + * inferred from vibes. A wrong fact is worse than a missing one. + * - `.env` values are NEVER read. The scanner notes only whether a committed + * `.env.example` exists. + * - Nothing throws. Every probe is guarded; a failure drops one field. + * + * `scanBrief` takes an injectable probe so the whole scanner is unit-testable + * without touching a real filesystem. + */ + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** One runnable command the project actually declares. */ +export interface BriefCommand { + /** What it's for: "test", "build", "lint", "typecheck", "dev". */ + label: string; + /** The command to run, verbatim. */ + command: string; +} + +export interface ProjectBrief { + /** Absolute path the scan ran against. */ + root: string; + /** e.g. `{ name: "bun", via: "bun.lock" }`. */ + packageManager?: { name: string; via: string }; + commands: BriefCommand[]; + entrypoints: string[]; + layout: string[]; + /** How many further directories the layout cap hid. */ + layoutMore: number; + workspaces: string[]; + /** Data-layer facts: "prisma (prisma/schema.prisma)", "alembic", … */ + data: string[]; + /** Commands CI actually runs. */ + ci: string[]; + defaultBranch?: string; + /** True when a committed `.env.example`/`.env.sample` exists. Values unread. */ + hasEnvExample: boolean; + /** Free-text notes from `.cue/project.md`, if the user wrote any. */ + notes: string[]; +} + +/** Filesystem access, injectable so tests stay hermetic. */ +export interface BriefProbe { + exists: (path: string) => boolean; + /** File contents, or null when missing/unreadable. */ + read: (path: string) => string | null; + /** Directory entry names, or [] when unreadable. */ + list: (path: string) => string[]; + /** Directory entry names that are themselves directories. */ + listDirs: (path: string) => string[]; +} + +export const REAL_PROBE: BriefProbe = { + exists: (p) => { + try { + return existsSync(p); + } catch { + return false; + } + }, + read: (p) => { + try { + return readFileSync(p, "utf8"); + } catch { + return null; + } + }, + list: (p) => { + try { + return readdirSync(p); + } catch { + return []; + } + }, + listDirs: (p) => { + try { + return readdirSync(p, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + return []; + } + }, +}; + +/** Directories that say nothing about a project's shape. */ +const LAYOUT_NOISE = new Set([ + "node_modules", ".git", ".github", ".vscode", ".idea", "dist", "build", "out", + "target", ".next", ".nuxt", ".svelte-kit", ".venv", "venv", "__pycache__", + ".pytest_cache", "coverage", ".turbo", ".cache", ".parcel-cache", "tmp", + ".claude", ".codex", ".cue", ".DS_Store", "vendor", ".ruff_cache", ".mypy_cache", +]); + +/** Cap on layout entries so the block stays scannable. */ +export const MAX_LAYOUT = 8; + +/** + * Directories worth naming first. Alphabetical order would spend the layout + * budget on `action/ agentshield/ awesome-clis/…` and cut `src/` — the one + * directory the agent actually needs. Anything not listed sorts after these, + * alphabetically. + */ +const LAYOUT_PRIORITY = [ + "src", "lib", "app", "apps", "packages", "cmd", "internal", "pkg", + "server", "client", "backend", "frontend", "storefront", "web", "api", + "core", "profiles", "resources", "skills", "components", "modules", + "tests", "test", "migrations", "db", "scripts", "docs", "config", +]; +/** Cap on CI command lines. */ +export const MAX_CI = 4; +/** Cap on workspace globs. */ +export const MAX_WORKSPACES = 6; +/** Default render budget in characters. */ +export const MAX_BRIEF_CHARS = 1500; + +/** package.json script names worth surfacing, mapped to their brief label. */ +const SCRIPT_LABELS: ReadonlyArray<[label: string, names: string[]]> = [ + ["test", ["test", "tests", "test:unit"]], + ["build", ["build", "compile"]], + ["lint", ["lint", "lint:check"]], + ["typecheck", ["typecheck", "types", "check-types", "type-check"]], + ["dev", ["dev", "start", "serve"]], +]; + +/** Makefile / justfile targets worth surfacing, in label order. */ +const TARGET_LABELS: ReadonlyArray<[label: string, names: string[]]> = [ + ["test", ["test", "tests", "check"]], + ["build", ["build", "all"]], + ["lint", ["lint", "fmt", "format"]], + ["dev", ["dev", "run", "serve"]], +]; + +/** + * Scan a directory into a brief. Returns `null` when the directory carries + * neither a manifest nor a git repo — there is nothing verified to say, and an + * empty brief is just noise in the agent's context. + */ +export function scanBrief(cwd: string, probe: BriefProbe = REAL_PROBE): ProjectBrief | null { + const has = (rel: string) => probe.exists(join(cwd, rel)); + const read = (rel: string) => probe.read(join(cwd, rel)); + + const MANIFESTS = [ + "package.json", "Cargo.toml", "pyproject.toml", "go.mod", "Makefile", + "justfile", "Justfile", "requirements.txt", "composer.json", "Gemfile", + ]; + const hasManifest = MANIFESTS.some((m) => has(m)); + if (!hasManifest && !has(".git")) return null; + + const brief: ProjectBrief = { + root: cwd, + commands: [], + entrypoints: [], + layout: [], + layoutMore: 0, + workspaces: [], + data: [], + ci: [], + hasEnvExample: false, + notes: [], + }; + + const pkg = parseJson(read("package.json")); + brief.packageManager = detectPackageManager(has, pkg !== null); + const runner = brief.packageManager?.name; + + // ── commands ────────────────────────────────────────────────────────────── + const seenLabels = new Set(); + const addCommand = (label: string, command: string) => { + if (seenLabels.has(label)) return; + seenLabels.add(label); + brief.commands.push({ label, command }); + }; + + const scripts = (pkg?.scripts ?? {}) as Record; + const jsRunner = runner && runner !== "cargo" && runner !== "uv" && runner !== "poetry" && runner !== "pip" + ? runner + : "npm"; + for (const [label, names] of SCRIPT_LABELS) { + const hit = names.find((n) => typeof scripts[n] === "string"); + if (hit) addCommand(label, `${jsRunner} run ${hit}`); + } + + const makefile = read("Makefile") ?? read("makefile"); + if (makefile) { + const targets = parseMakeTargets(makefile); + for (const [label, names] of TARGET_LABELS) { + const hit = names.find((n) => targets.includes(n)); + if (hit) addCommand(label, `make ${hit}`); + } + } + + const justfile = read("justfile") ?? read("Justfile"); + if (justfile) { + const recipes = parseJustRecipes(justfile); + for (const [label, names] of TARGET_LABELS) { + const hit = names.find((n) => recipes.includes(n)); + if (hit) addCommand(label, `just ${hit}`); + } + } + + const cargo = read("Cargo.toml"); + if (cargo) { + addCommand("test", "cargo test"); + addCommand("build", "cargo build"); + if (has("clippy.toml") || has(".clippy.toml")) addCommand("lint", "cargo clippy"); + } + + const pyproject = read("pyproject.toml"); + if (pyproject) { + if (/\[tool\.pytest/.test(pyproject) || has("tests") || has("test")) { + addCommand("test", "pytest"); + } + if (/\bruff\b/.test(pyproject)) addCommand("lint", "ruff check ."); + if (/\bmypy\b/.test(pyproject)) addCommand("typecheck", "mypy ."); + } + + if (has("go.mod")) { + addCommand("test", "go test ./..."); + addCommand("build", "go build ./..."); + } + + // ── entry points ────────────────────────────────────────────────────────── + if (pkg) { + const bin = pkg.bin; + if (typeof bin === "string") brief.entrypoints.push(bin); + else if (bin && typeof bin === "object") { + for (const v of Object.values(bin as Record)) { + if (typeof v === "string") brief.entrypoints.push(v); + } + } + if (typeof pkg.main === "string") brief.entrypoints.push(pkg.main); + } + for (const candidate of [ + "src/index.ts", "src/index.js", "src/main.ts", "src/main.rs", "main.go", + "manage.py", "app/main.py", "src/app.py", + ]) { + if (has(candidate)) brief.entrypoints.push(candidate); + } + for (const dir of probe.listDirs(join(cwd, "cmd"))) { + if (probe.exists(join(cwd, "cmd", dir, "main.go"))) brief.entrypoints.push(`cmd/${dir}/main.go`); + } + brief.entrypoints = dedupe(brief.entrypoints).slice(0, 4); + + // ── layout ──────────────────────────────────────────────────────────────── + const allDirs = probe + .listDirs(cwd) + .filter((d) => !d.startsWith(".") && !LAYOUT_NOISE.has(d)) + .sort((a, b) => { + const rank = (d: string) => { + const i = LAYOUT_PRIORITY.indexOf(d); + return i < 0 ? LAYOUT_PRIORITY.length : i; + }; + return rank(a) - rank(b) || a.localeCompare(b); + }); + brief.layout = allDirs.slice(0, MAX_LAYOUT); + brief.layoutMore = Math.max(0, allDirs.length - brief.layout.length); + + // ── workspaces ──────────────────────────────────────────────────────────── + const wsField = pkg?.workspaces; + if (Array.isArray(wsField)) { + brief.workspaces.push(...wsField.filter((w): w is string => typeof w === "string")); + } else if (wsField && typeof wsField === "object" && Array.isArray((wsField as { packages?: unknown }).packages)) { + brief.workspaces.push( + ...((wsField as { packages: unknown[] }).packages.filter((w): w is string => typeof w === "string")), + ); + } + const pnpmWs = read("pnpm-workspace.yaml"); + if (pnpmWs) { + for (const line of pnpmWs.split("\n")) { + const m = /^\s*-\s+["']?([^"'\s]+)["']?\s*$/.exec(line); + if (m?.[1]) brief.workspaces.push(m[1]); + } + } + if (cargo && /\[workspace\]/.test(cargo)) brief.workspaces.push("cargo workspace"); + if (has("turbo.json")) brief.workspaces.push("turborepo"); + brief.workspaces = dedupe(brief.workspaces).slice(0, MAX_WORKSPACES); + + // ── data layer ──────────────────────────────────────────────────────────── + const dataMarkers: ReadonlyArray<[file: string, label: string]> = [ + ["prisma/schema.prisma", "prisma (prisma/schema.prisma)"], + ["drizzle.config.ts", "drizzle (drizzle.config.ts)"], + ["alembic.ini", "alembic migrations"], + ["medusa-config.ts", "medusa"], + ["medusa-config.js", "medusa"], + ["supabase/config.toml", "supabase"], + ]; + for (const [file, label] of dataMarkers) if (has(file)) brief.data.push(label); + brief.data = dedupe(brief.data); + + // ── what CI runs ────────────────────────────────────────────────────────── + brief.ci = scanCiCommands(cwd, probe); + + // ── git default branch ──────────────────────────────────────────────────── + const originHead = read(".git/refs/remotes/origin/HEAD"); + const branchMatch = originHead ? /refs\/remotes\/origin\/(\S+)/.exec(originHead) : null; + if (branchMatch?.[1]) brief.defaultBranch = branchMatch[1]; + + brief.hasEnvExample = has(".env.example") || has(".env.sample"); + + // ── notes from an opt-in .cue/project.md ────────────────────────────────── + const briefFile = read(BRIEF_FILE); + if (briefFile) brief.notes = extractNotes(briefFile); + + return brief; +} + +/** Lockfile → package manager. JS lockfiles win when a package.json exists. */ +function detectPackageManager( + has: (rel: string) => boolean, + hasPackageJson: boolean, +): { name: string; via: string } | undefined { + const js: ReadonlyArray<[file: string, name: string]> = [ + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["package-lock.json", "npm"], + ]; + for (const [file, name] of js) if (has(file)) return { name, via: file }; + const others: ReadonlyArray<[file: string, name: string]> = [ + ["Cargo.lock", "cargo"], + ["uv.lock", "uv"], + ["poetry.lock", "poetry"], + ]; + for (const [file, name] of others) if (has(file)) return { name, via: file }; + if (hasPackageJson) return { name: "npm", via: "package.json (no lockfile)" }; + return undefined; +} + +/** Target names from a Makefile (`name:` at column 0, skipping .PHONY etc.). */ +export function parseMakeTargets(content: string): string[] { + const out: string[] = []; + for (const line of content.split("\n")) { + const m = /^([a-zA-Z][\w.-]*)\s*:(?!=)/.exec(line); + if (m?.[1] && !out.includes(m[1])) out.push(m[1]); + } + return out; +} + +/** Recipe names from a justfile (`name:` or `name arg:` at column 0). */ +export function parseJustRecipes(content: string): string[] { + const out: string[] = []; + for (const line of content.split("\n")) { + const m = /^([a-zA-Z][\w-]*)(?:\s+[^:]*)?:(?!=)/.exec(line); + if (m?.[1] && !out.includes(m[1])) out.push(m[1]); + } + return out; +} + +/** Verbs that make a CI `run:` line worth quoting to the agent. */ +const CI_VERBS = /\b(test|build|lint|typecheck|type-check|check|fmt|format)\b/; + +/** + * Commands CI actually runs, harvested from `.github/workflows/*.yml`. Reads at + * most three workflow files and keeps the lines that name a known verb — the + * point is "here is what has to pass", not a full pipeline dump. + */ +export function scanCiCommands(cwd: string, probe: BriefProbe): string[] { + const dir = join(cwd, ".github", "workflows"); + const files = probe + .list(dir) + .filter((f) => f.endsWith(".yml") || f.endsWith(".yaml")) + .sort() + .slice(0, 3); + const out: string[] = []; + for (const file of files) { + const content = probe.read(join(dir, file)); + if (!content) continue; + for (const line of content.split("\n")) { + const m = /^\s*(?:-\s*)?run:\s*(.+?)\s*$/.exec(line); + const cmd = m?.[1]?.replace(/^["']|["']$/g, ""); + // Multi-line `run: |` blocks and shell noise aren't useful one-liners. + if (!cmd || cmd === "|" || cmd === ">" || cmd.length > 60) continue; + if (!CI_VERBS.test(cmd)) continue; + if (!out.includes(cmd)) out.push(cmd); + if (out.length >= MAX_CI) return out; + } + } + return out; +} + +function parseJson(raw: string | null): Record | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function dedupe(items: string[]): string[] { + return [...new Set(items.filter((s) => s.length > 0))]; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +/** Column the values line up at in the rendered block. */ +const LABEL_COL = 18; + +/** + * Render the brief as the block handed to the agent. Aligned key/value lines — + * dense, greppable, and cheap in tokens. Returns "" when the brief carries no + * facts worth stating (a bare git directory with no manifest). + */ +export function renderBrief( + brief: ProjectBrief, + opts: { maxChars?: number; includeNotes?: boolean } = {}, +): string { + // `.cue/project.md` keeps its notes *below* the generated block, so writing + // that file renders without them — otherwise every rewrite would fold the + // notes back into the machine block and duplicate them. + const includeNotes = opts.includeNotes !== false; + const rows: Array<[string, string]> = []; + if (brief.packageManager) { + rows.push(["package manager", `${brief.packageManager.name} (${brief.packageManager.via})`]); + } + for (const c of brief.commands) rows.push([c.label, c.command]); + if (brief.entrypoints.length > 0) rows.push(["entry", brief.entrypoints.join(", ")]); + if (brief.layout.length > 0) { + const more = brief.layoutMore > 0 ? ` (+${brief.layoutMore} more)` : ""; + rows.push(["layout", brief.layout.map((d) => `${d}/`).join(" ") + more]); + } + if (brief.workspaces.length > 0) rows.push(["workspaces", brief.workspaces.join(", ")]); + if (brief.data.length > 0) rows.push(["data", brief.data.join(", ")]); + if (brief.ci.length > 0) rows.push(["ci runs", brief.ci.join(" · ")]); + if (brief.defaultBranch) rows.push(["default branch", brief.defaultBranch]); + if (brief.hasEnvExample) rows.push([".env", ".env.example present (values not read)"]); + + const notes = includeNotes ? brief.notes : []; + if (rows.length === 0 && notes.length === 0) return ""; + + const lines: string[] = []; + lines.push("## This project"); + lines.push(""); + lines.push( + "Facts scanned from the working directory by cue. Prefer these commands over " + + "guesses — they come from the project's own manifests.", + ); + lines.push(""); + for (const [label, value] of rows) { + lines.push(`${label.padEnd(LABEL_COL)}${value}`); + } + if (notes.length > 0) { + lines.push(""); + lines.push("Notes from .cue/project.md:"); + for (const note of notes) lines.push(`- ${note}`); + } + + const text = lines.join("\n"); + const max = opts.maxChars ?? MAX_BRIEF_CHARS; + if (text.length <= max) return text; + return `${text.slice(0, max - 20).trimEnd()}\n… (brief truncated)`; +} + +// --------------------------------------------------------------------------- +// Delivery +// --------------------------------------------------------------------------- + +/** How a rendered brief reaches one agent process. */ +export interface BriefInjection { + /** Arguments to prepend to the agent's argv. */ + args: string[]; + /** Environment additions for the child process. */ + env: Record; + /** File the caller must write first (codex has no system-prompt flag). */ + file?: { path: string; content: string }; +} + +/** + * Route a brief to an agent **per process**. + * + * This never goes through the materialized memory file: the runtime directory + * is keyed by profile, shared by every directory using that profile and by + * every parallel session, so repo-specific text written there would leak + * across projects and race between sessions. + * + * - claude-code takes `--append-system-prompt`, which is exactly this. + * - codex has no equivalent flag, so the brief is written to a per-cwd file + * and pointed at with `CUE_PROJECT_BRIEF`; the materialized AGENTS.md + * carries one *static* line telling the agent to read it. + * + * An empty brief injects nothing. Pure — the caller owns the write. + */ +export function buildBriefInjection(opts: { + agent: "claude-code" | "codex" | string; + brief: string; + /** Directory for per-cwd brief files (codex path). */ + briefDir: string; + cwd: string; +}): BriefInjection { + const brief = opts.brief.trim(); + if (brief.length === 0) return { args: [], env: {} }; + if (opts.agent === "claude-code") { + return { args: ["--append-system-prompt", brief], env: {} }; + } + const path = join(opts.briefDir, `${briefFileKey(opts.cwd)}.md`); + return { args: [], env: { CUE_PROJECT_BRIEF: path }, file: { path, content: `${brief}\n` } }; +} + +/** Stable per-directory filename component. */ +export function briefFileKey(cwd: string): string { + return createHash("sha256").update(cwd).digest("hex").slice(0, 16); +} + +/** + * The one static line the codex memory file carries. Static on purpose: the + * runtime AGENTS.md is shared across directories, so it must not contain any + * directory-specific text — only a pointer to the per-process env var. + */ +export const CODEX_BRIEF_POINTER = + "When `CUE_PROJECT_BRIEF` is set in the environment, read that file before " + + "your first action — it lists this directory's verified commands and layout."; + +// --------------------------------------------------------------------------- +// .cue/project.md — the opt-in persisted brief +// --------------------------------------------------------------------------- + +export const BRIEF_FILE = ".cue/project.md"; +export const GEN_START = ""; +export const GEN_END = ""; + +/** + * Split a `.cue/project.md` into its machine block and everything else. A file + * without markers is treated as all-notes, so a hand-written file is never + * mistaken for generated content and overwritten. + */ +export function splitBriefFile(content: string): { generated: string; rest: string } { + const start = content.indexOf(GEN_START); + const end = content.indexOf(GEN_END); + if (start < 0 || end < 0 || end < start) return { generated: "", rest: content }; + const generated = content.slice(start + GEN_START.length, end).trim(); + const rest = (content.slice(0, start) + content.slice(end + GEN_END.length)).trim(); + return { generated, rest }; +} + +/** + * The free-text notes a user (or the agent) wrote under `## Notes`. Bullet + * lines only — prose paragraphs are skipped so the injected block stays tight. + */ +export function extractNotes(content: string): string[] { + const { rest } = splitBriefFile(content); + const out: string[] = []; + let inNotes = false; + for (const line of rest.split("\n")) { + if (/^#{1,6}\s/.test(line)) { + inNotes = /notes/i.test(line); + continue; + } + if (!inNotes) continue; + const m = /^\s*[-*]\s+(.*\S)\s*$/.exec(line); + if (m?.[1]) out.push(m[1]); + } + return out; +} + +/** The default body for a freshly created `.cue/project.md`. */ +const NOTES_TEMPLATE = `## Notes + +Anything cue can't scan — conventions, gotchas, "always do X before Y". +These lines are handed to the agent alongside the generated block above. + +- `; + +/** + * Produce the new contents of `.cue/project.md`: the generated block replaced, + * everything the user wrote preserved. A missing file gets the notes template. + * Pure — the caller owns the write. + */ +export function mergeBriefFile(existing: string | null, generated: string): string { + const block = `${GEN_START}\n${generated}\n${GEN_END}`; + if (existing === null || existing.trim().length === 0) { + return `${block}\n\n${NOTES_TEMPLATE}\n`; + } + const { rest } = splitBriefFile(existing); + return rest.length > 0 ? `${block}\n\n${rest}\n` : `${block}\n`; +} diff --git a/src/lib/runtime-materializer.ts b/src/lib/runtime-materializer.ts index f39c084b..9dd4ed3c 100644 --- a/src/lib/runtime-materializer.ts +++ b/src/lib/runtime-materializer.ts @@ -19,6 +19,7 @@ import { normalizeUvxGitServers } from "./uvx-installer"; import { evaluateCondition } from "./conditional-skills"; import { hasWorkspaces, getActiveWorkspace, computeOverrides } from "./workspaces"; import { parseSkillFromDir, renderRouter, type ParsedSkill } from "./skill-router"; +import { CODEX_BRIEF_POINTER } from "./project-brief"; const REPO_ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", ".."); const RESOURCES_RULES = join(REPO_ROOT, "resources", "rules"); @@ -721,6 +722,15 @@ export async function materializeRuntime(input: MaterializeInput): Promise Date: Mon, 27 Jul 2026 02:15:33 +0200 Subject: [PATCH 06/33] feat(picker): let the model rerank profile matches, without ever waiting on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every failure the lexical matcher has is the same shape: a word means one thing in a manifest and another in a profile description, and the fix is another stopword. CLAUDE.md, @clack/core, requires-python, base-template each cost a round of tuning, and the list is unbounded. A model reads the same evidence and doesn't make that class of mistake. So: lexical proposes, the model judges. This fits here in a way it did not fit the skill matcher. That hook runs on every prompt and the prompt is always different, so an LLM call is a per-message tax with a near-zero cache hit rate — which is why it ended up as an opt-in `--deep` escalation. A repo's shape is stable for weeks. Keyed on the evidence rather than the clock, the cache hits ~always and the model is consulted about once per project. Measured: 9.8s cold, 0.124s warm. The launch path still never waits. A warm entry is read in ~1ms; a cold one serves the lexical answer immediately and spawns a detached process to fill the cache for next time. Picker cost measured at 98ms warm, 45-74ms cold — the model's 10 seconds are never on anyone's critical path. claude-classifier extracts the spawn, the ephemeral CLAUDE_CONFIG_DIR isolation and the credential copy-back out of skill-subset (477 -> 331 lines). That machinery is subtle enough — the rotation race, the shared timeout budget across the binary fallback — that a second copy would drift rather than stay honest. `cue profile match [dir] --explain --deep` exists because the matcher's early versions stayed wrong for as long as they did purely because nothing showed which term caused a bad suggestion. It earned its keep within minutes: on a small Python CLI it revealed "dependencies" named environment, intended, operating, programming and topic — PyPI trove classifiers, read as packages by the loose manifest scanner. Handed that same garbage, the model confidently picked profiles about building cue itself. With the scanner fixed to skip `::` lines it picks python + backend-base, which is right. The scanner now also harvests inline dependency arrays (`dependencies = [...]`), which are often the only place a pyproject declares anything real. Verified live: agv_stack -> ros2 ("ROS 2 robot control, .urdf files, MRS platform"); kolarortopedia -> backend + postgres, with the model correctly dropping `vite` (matched on the vitest test runner) and `supabase` (matched on the substring "postgre"). 2889 pass / 0 fail, tsc and lint clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/profile-match.ts | 179 ++++++++++++++ src/commands/profile.ts | 6 + src/lib/claude-classifier.test.ts | 175 ++++++++++++++ src/lib/claude-classifier.ts | 239 +++++++++++++++++++ src/lib/picker/flow.ts | 25 +- src/lib/profile-match-llm.test.ts | 194 +++++++++++++++ src/lib/profile-match-llm.ts | 384 ++++++++++++++++++++++++++++++ src/lib/profile-match.test.ts | 39 +++ src/lib/profile-match.ts | 83 +++++-- src/lib/skill-subset.ts | 216 ++--------------- 10 files changed, 1320 insertions(+), 220 deletions(-) create mode 100644 src/commands/profile-match.ts create mode 100644 src/lib/claude-classifier.test.ts create mode 100644 src/lib/claude-classifier.ts create mode 100644 src/lib/profile-match-llm.test.ts create mode 100644 src/lib/profile-match-llm.ts diff --git a/src/commands/profile-match.ts b/src/commands/profile-match.ts new file mode 100644 index 00000000..09c1ec48 --- /dev/null +++ b/src/commands/profile-match.ts @@ -0,0 +1,179 @@ +/** + * `cue profile match [dir]` — why does this directory suggest these profiles? + * + * The picker shows an answer; this shows the reasoning. When a repo suggests + * something silly, the fix is almost always one line — a stopword, a metadata + * key, a marker file — but only if you can see which term did it and where the + * term came from. Guessing at that from the suggestion alone is how the + * matcher's early versions stayed wrong for as long as they did. + * + * Flags: + * --deep run the LLM reranking pass (cached per repo shape) + * --explain show the evidence behind each match, and the unmatched terms + * --json machine-readable + * --no-cache force a fresh model call rather than a cached answer + * --limit N cap results (default 8) + */ + +import { + MATCH_MIN_STRENGTH, + loadProfileDocs, + matchProfiles, + repoEvidence, +} from "../lib/profile-match"; +import { deepMatchDisabled, deepMatchProfiles } from "../lib/profile-match-llm"; + +interface Options { + cwd: string; + deep: boolean; + explain: boolean; + json: boolean; + noCache: boolean; + limit: number; +} + +function parseArgs(args: string[]): Options { + const opts: Options = { + cwd: process.cwd(), + deep: false, + explain: false, + json: false, + noCache: false, + limit: 8, + }; + const positional: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + switch (a) { + case "--deep": opts.deep = true; break; + case "--explain": opts.explain = true; break; + case "--json": opts.json = true; break; + case "--no-cache": opts.noCache = true; break; + case "--limit": opts.limit = Number(args[++i]) || opts.limit; break; + default: + if (!a.startsWith("-")) positional.push(a); + break; + } + } + if (positional[0]) opts.cwd = positional[0]; + return opts; +} + +function usage(): void { + process.stdout.write(`cue profile match [dir] — show why a directory matches the profiles it does + + --deep rerank with the model (cached per repo shape) + --explain show the evidence behind each match + --json machine-readable output + --no-cache force a fresh model call + --limit N cap results (default 8) +`); +} + +export async function run(args: string[]): Promise { + if (args.includes("-h") || args.includes("--help")) { + usage(); + return 0; + } + const opts = parseArgs(args); + + const docs = loadProfileDocs(); + if (docs.length === 0) { + process.stderr.write("No profiles found. Is CUE_REPO_ROOT set correctly?\n"); + return 1; + } + + const evidence = repoEvidence(opts.cwd); + const lexical = matchProfiles(evidence, docs); + + let matches = lexical; + let note = ""; + if (opts.deep) { + const deep = await deepMatchProfiles({ evidence, docs, lexical, noCache: opts.noCache }); + matches = deep.matches; + note = deep.classified + ? `model: ${deep.reason}${deep.cached ? " (cached)" : ""}` + : `lexical only — ${deep.reason}`; + } + const shown = matches.slice(0, opts.limit); + + if (opts.json) { + process.stdout.write( + `${JSON.stringify( + { + cwd: opts.cwd, + deep: opts.deep, + note, + evidence: [...evidence.terms.entries()].map(([term, weight]) => ({ + term, + weight, + source: evidence.sources.get(term), + reason: evidence.reasons.get(term), + })), + matches: shown, + }, + null, + 2, + )}\n`, + ); + return 0; + } + + process.stdout.write(`\nProfile match for ${opts.cwd}\n`); + if (note) process.stdout.write(` ${note}\n`); + if (!opts.deep && !deepMatchDisabled()) { + process.stdout.write(" (lexical only — add --deep to rerank with the model)\n"); + } + process.stdout.write("\n"); + + if (shown.length === 0) { + process.stdout.write(" No profile matches this directory.\n"); + process.stdout.write(" That is a real answer, not a failure: nothing here resembles a\n"); + process.stdout.write(" domain profile, so the picker falls back to featured + your default.\n\n"); + if (opts.explain) writeEvidence(evidence); + return 0; + } + + for (const m of shown) { + process.stdout.write(` ${m.strength.toFixed(2)} ${m.name}\n`); + process.stdout.write(` ${m.reason}\n`); + if (opts.explain && m.matchedTerms.length > 0) { + for (const t of m.matchedTerms) { + const src = evidence.sources.get(t) ?? "?"; + const why = evidence.reasons.get(t) ?? ""; + process.stdout.write(` · ${t} [${src}] ${why}\n`); + } + } + process.stdout.write("\n"); + } + + if (opts.explain) writeEvidence(evidence); + return 0; +} + +/** + * Print the full evidence bag. + * + * The terms that matched NOTHING matter as much as the ones that did: a term + * here that obviously shouldn't be evidence at all (a metadata key, a + * scaffolding word) is a one-line fix in EVIDENCE_STOPWORDS or + * MANIFEST_METADATA_KEYS, and this listing is how you find it. + */ +function writeEvidence(evidence: ReturnType): void { + process.stdout.write(" Evidence gathered from this directory:\n"); + const rows = [...evidence.terms.entries()].sort( + ([ta, wa], [tb, wb]) => wb - wa || ta.localeCompare(tb), + ); + if (rows.length === 0) { + process.stdout.write(" (none)\n\n"); + return; + } + for (const [term, weight] of rows) { + const src = evidence.sources.get(term) ?? "?"; + const why = evidence.reasons.get(term) ?? ""; + process.stdout.write(` ${String(weight).padStart(4)} ${term.padEnd(20)} [${src}] ${why}\n`); + } + process.stdout.write( + `\n A term here that shouldn't be evidence at all is a one-line fix in\n EVIDENCE_STOPWORDS or MANIFEST_METADATA_KEYS (src/lib/profile-match.ts).\n Matches below ${MATCH_MIN_STRENGTH} strength are dropped before display.\n\n`, + ); +} diff --git a/src/commands/profile.ts b/src/commands/profile.ts index 9c04b6a9..a0cfd2db 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -13,6 +13,7 @@ export async function run(args: string[]): Promise { Subcommands: suggest Audit profiles/ and propose regroupings (promote-to-core, merges, new clusters) + match Show why a directory matches the profiles it does (--explain, --deep) evolve Surface skill-usage signals from analytics logs (drop / stale / group candidates) draft-skill Draft new SKILL.md files from recurring session prompts @@ -21,6 +22,11 @@ Run \`cue profile --help\` for details. return sub ? 0 : 1; } + if (sub === "match") { + const { run: matchRun } = await import("./profile-match"); + return matchRun(args.slice(1)); + } + if (sub === "suggest") { const { run: suggestRun } = await import("./profile-suggest"); return suggestRun(args.slice(1)); diff --git a/src/lib/claude-classifier.test.ts b/src/lib/claude-classifier.test.ts new file mode 100644 index 00000000..879d03cb --- /dev/null +++ b/src/lib/claude-classifier.test.ts @@ -0,0 +1,175 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, test } from "bun:test"; + +import { + classifierSpawnArgs, + credExpiresAt, + setupClassifierHome, + shouldCopyBackCreds, + teardownClassifierHome, +} from "./claude-classifier"; + +const tmps: string[] = []; +function tmpDir(): string { + const d = mkdtempSync(join(tmpdir(), "cue-classifier-")); + tmps.push(d); + return d; +} +afterEach(() => { + for (const d of tmps.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe("classifierSpawnArgs", () => { + test("keeps the spawn lightweight", () => { + const args = classifierSpawnArgs("hello"); + // Without --strict-mcp-config the child boots every MCP server in the + // user's config just to answer one line. + expect(args).toContain("--strict-mcp-config"); + expect(args).toContain("--print"); + expect(args.at(-1)).toBe("hello"); + }); + + test("defaults to a fast model", () => { + const prev = process.env.CUE_SMART_SUBSET_MODEL; + delete process.env.CUE_SMART_SUBSET_MODEL; + try { + const args = classifierSpawnArgs("x"); + expect(args[args.indexOf("--model") + 1]).toBe("haiku"); + } finally { + if (prev !== undefined) process.env.CUE_SMART_SUBSET_MODEL = prev; + } + }); + + test("honours the model override", () => { + const prev = process.env.CUE_SMART_SUBSET_MODEL; + process.env.CUE_SMART_SUBSET_MODEL = "sonnet"; + try { + const args = classifierSpawnArgs("x"); + expect(args[args.indexOf("--model") + 1]).toBe("sonnet"); + } finally { + if (prev === undefined) delete process.env.CUE_SMART_SUBSET_MODEL; + else process.env.CUE_SMART_SUBSET_MODEL = prev; + } + }); + + // --bare skips credential loading and comes back "Not logged in". + test("never passes --bare", () => { + expect(classifierSpawnArgs("x")).not.toContain("--bare"); + }); +}); + +describe("shouldCopyBackCreds", () => { + // Anthropic rotates the refresh token on every refresh, so a stale copy must + // never clobber a live source token. + test("only carries back a strictly newer token", () => { + expect(shouldCopyBackCreds(200, 100)).toBe(true); + expect(shouldCopyBackCreds(100, 100)).toBe(false); + expect(shouldCopyBackCreds(50, 100)).toBe(false); + }); +}); + +describe("credExpiresAt", () => { + test("reads the OAuth expiry", () => { + const d = tmpDir(); + const f = join(d, ".credentials.json"); + writeFileSync(f, JSON.stringify({ claudeAiOauth: { expiresAt: 12345 } })); + expect(credExpiresAt(f)).toBe(12345); + }); + + test("returns 0 for missing, malformed, or shapeless files", () => { + const d = tmpDir(); + expect(credExpiresAt(join(d, "nope.json"))).toBe(0); + const bad = join(d, "bad.json"); + writeFileSync(bad, "{{{"); + expect(credExpiresAt(bad)).toBe(0); + const empty = join(d, "empty.json"); + writeFileSync(empty, JSON.stringify({ other: true })); + expect(credExpiresAt(empty)).toBe(0); + }); +}); + +describe("setupClassifierHome", () => { + test("returns null when there is nothing to isolate", () => { + const prev = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; + try { + expect(setupClassifierHome()).toBeNull(); + } finally { + if (prev !== undefined) process.env.CLAUDE_CONFIG_DIR = prev; + } + }); + + test("builds a minimal config that loads no plugins, hooks or MCP servers", () => { + const src = tmpDir(); + const cache = tmpDir(); + const prevCfg = process.env.CLAUDE_CONFIG_DIR; + const prevCache = process.env.XDG_CACHE_HOME; + process.env.CLAUDE_CONFIG_DIR = src; + process.env.XDG_CACHE_HOME = cache; + try { + const h = setupClassifierHome(); + expect(h).not.toBeNull(); + const settings = JSON.parse(readFileSync(join(h!.home, "settings.json"), "utf8")) as Record; + expect(settings).toEqual({}); + const claudeJson = JSON.parse(readFileSync(join(h!.home, ".claude.json"), "utf8")) as Record; + expect(claudeJson.hasCompletedOnboarding).toBe(true); + teardownClassifierHome(h!); + expect(existsSync(h!.home)).toBe(false); + } finally { + if (prevCfg === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevCfg; + if (prevCache === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = prevCache; + } + }); + + test("copies live credentials in so the call still authenticates", () => { + const src = tmpDir(); + const cache = tmpDir(); + writeFileSync(join(src, ".credentials.json"), JSON.stringify({ claudeAiOauth: { expiresAt: 999 } })); + const prevCfg = process.env.CLAUDE_CONFIG_DIR; + const prevCache = process.env.XDG_CACHE_HOME; + process.env.CLAUDE_CONFIG_DIR = src; + process.env.XDG_CACHE_HOME = cache; + try { + const h = setupClassifierHome()!; + expect(credExpiresAt(join(h.home, ".credentials.json"))).toBe(999); + expect(h.credSrc).toBe(join(src, ".credentials.json")); + + // Teardown must not clobber the source with an equal-or-older token. + teardownClassifierHome(h); + expect(credExpiresAt(join(src, ".credentials.json"))).toBe(999); + } finally { + if (prevCfg === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevCfg; + if (prevCache === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = prevCache; + } + }); + + test("carries a rotated token back to the source", () => { + const src = tmpDir(); + const cache = tmpDir(); + const srcCred = join(src, ".credentials.json"); + writeFileSync(srcCred, JSON.stringify({ claudeAiOauth: { expiresAt: 100 } })); + const prevCfg = process.env.CLAUDE_CONFIG_DIR; + const prevCache = process.env.XDG_CACHE_HOME; + process.env.CLAUDE_CONFIG_DIR = src; + process.env.XDG_CACHE_HOME = cache; + try { + const h = setupClassifierHome()!; + // Simulate the child refreshing the token inside the ephemeral home. + writeFileSync(join(h.home, ".credentials.json"), JSON.stringify({ claudeAiOauth: { expiresAt: 500 } })); + teardownClassifierHome(h); + expect(credExpiresAt(srcCred)).toBe(500); + } finally { + if (prevCfg === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevCfg; + if (prevCache === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = prevCache; + } + }); +}); diff --git a/src/lib/claude-classifier.ts b/src/lib/claude-classifier.ts new file mode 100644 index 00000000..045dab34 --- /dev/null +++ b/src/lib/claude-classifier.ts @@ -0,0 +1,239 @@ +/** + * Shared `claude --print` classifier — one lightweight, isolated, fail-open LLM + * call that any cue feature can make. + * + * This machinery grew inside `skill-subset.ts` for one caller. It is subtle in + * ways that are expensive to rediscover — the ephemeral config dir, the + * credential copy-back race, the shared timeout budget across the binary + * fallback — so the second caller (the profile matcher) reuses it rather than + * growing a parallel copy that drifts. + * + * Contract, in the order it matters: + * + * 1. **Never throws, never rejects.** Spawn failure, non-zero exit, timeout, + * missing binary — all resolve to `{ ok: false }`. Callers fail open to + * whatever they had without the LLM. A classifier is an optimization; it + * is never a gate. + * 2. **Lightweight spawn.** `--strict-mcp-config` or the child boots every + * MCP server in the user's config just to answer one line, and a fast + * model because the account default may be a heavyweight reasoner. + * 3. **Isolated.** An ephemeral `CLAUDE_CONFIG_DIR` keeps the call out of the + * user's plugins, hooks and session logs. + * 4. **Bounded.** The timeout kills the child and settles the promise even if + * the child ignores the signal; the binary fallback shares the original + * budget rather than stacking a second full timeout on top. + */ + +import { spawn } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { findRealClaudeBin } from "./claude-binary"; +import { cacheDir } from "./config-paths"; + +/** + * CLI args for the classifier spawn. The call must be LIGHTWEIGHT: + * - `--strict-mcp-config` — without it the spawned claude boots every MCP + * server in the user's config (a dozen npm/python daemons) just to answer + * one line. Observed to blow the 30s budget and freeze launches on configs + * with many global servers. + * - a fast model — the account default may be a heavyweight reasoning model; + * classification is a trivial pick-list task. Override with + * CUE_SMART_SUBSET_MODEL if the alias isn't available on the account. + * NOT `--bare`: it skips credential/settings loading and comes back + * "Not logged in". + */ +export function classifierSpawnArgs(prompt: string): string[] { + const model = process.env.CUE_SMART_SUBSET_MODEL?.trim() || "haiku"; + return ["--print", "--strict-mcp-config", "--model", model, "-p", prompt]; +} + +// --------------------------------------------------------------------------- +// Classifier isolation — an ephemeral CLAUDE_CONFIG_DIR for the spawn +// --------------------------------------------------------------------------- + +export interface ClassifierHome { + /** The ephemeral config dir to point CLAUDE_CONFIG_DIR at. */ + home: string; + /** The real `.credentials.json` we copied from, for the rotation copy-back. */ + credSrc: string | null; +} + +/** OAuth token expiry (epoch ms) from a `.credentials.json`, or 0 if unreadable. */ +export function credExpiresAt(p: string): number { + try { + const v = (JSON.parse(readFileSync(p, "utf8")) as { claudeAiOauth?: { expiresAt?: unknown } }) + ?.claudeAiOauth?.expiresAt; + return typeof v === "number" ? v : 0; + } catch { + return 0; + } +} + +/** + * Whether to carry the classifier home's credentials back to the source: only + * when the home token is strictly newer (higher expiresAt). Anthropic rotates + * the refresh token on every refresh, so a stale copy must never clobber a live + * source token. Pure so it can be unit-tested. Mirrors the launch.ts rescue guard. + */ +export function shouldCopyBackCreds(homeExpiresAt: number, srcExpiresAt: number): boolean { + return homeExpiresAt > srcExpiresAt; +} + +/** + * Build an ephemeral CLAUDE_CONFIG_DIR for the classifier spawn so it does NOT + * load the user's plugins (claude-mem spawns a worker daemon per call), fire + * their hooks, or append phantom sessions to their logs. Copies the live OAuth + * credentials from the launch's real config dir in so the call still auths. + * Returns null when there's nothing to isolate (no CLAUDE_CONFIG_DIR set) — the + * caller then inherits the parent env, the pre-isolation behavior. + */ +export function setupClassifierHome(): ClassifierHome | null { + const src = process.env.CLAUDE_CONFIG_DIR; + if (!src) return null; + try { + const base = join(cacheDir(), "classifier-home"); + mkdirSync(base, { recursive: true }); + const home = mkdtempSync(join(base, "run-")); + // Minimal config: no enabledPlugins, no hooks, no mcpServers. + writeFileSync(join(home, "settings.json"), "{}\n"); + writeFileSync(join(home, ".claude.json"), `${JSON.stringify({ hasCompletedOnboarding: true })}\n`); + const credSrcFile = join(src, ".credentials.json"); + let credSrc: string | null = null; + if (existsSync(credSrcFile)) { + copyFileSync(credSrcFile, join(home, ".credentials.json")); + credSrc = credSrcFile; + } + return { home, credSrc }; + } catch { + return null; + } +} + +/** Copy back a rotated token if newer, then remove the ephemeral home. Best-effort. */ +export function teardownClassifierHome(h: ClassifierHome): void { + try { + if (h.credSrc) { + const homeCred = join(h.home, ".credentials.json"); + // The source may be a SHARED account `.credentials.json` (authmux parallel + // accounts point CLAUDE_CONFIG_DIR there), read live by other sessions. So + // this must be atomic: copy into a sibling tmp, re-check freshness (a + // concurrent launch may have rotated the source since we forked), then + // rename — a same-dir rename is atomic, so a reader never sees a torn file. + // Mirrors credentials-sync.ts's writer. + if (existsSync(homeCred) && shouldCopyBackCreds(credExpiresAt(homeCred), credExpiresAt(h.credSrc))) { + const tmp = `${h.credSrc}.cue-classifier.${process.pid}.tmp`; + try { + copyFileSync(homeCred, tmp); + // Re-check under the freshest source state before committing the swap. + if (shouldCopyBackCreds(credExpiresAt(homeCred), credExpiresAt(h.credSrc))) { + renameSync(tmp, h.credSrc); + } else { + rmSync(tmp, { force: true }); + } + } catch { + try { rmSync(tmp, { force: true }); } catch { /* best-effort */ } + } + } + } + } catch { + /* best-effort */ + } + try { + rmSync(h.home, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +} + +/** + * Spawn `claude --print` ASYNC and resolve with its trimmed stdout. Never + * rejects: on spawn error, non-zero exit, or timeout it resolves + * `{ status: non-zero }` so the caller fail-opens. The timeout kills the child + * (SIGTERM, then SIGKILL backstop) and resolves immediately so the Promise + * always settles even if the child ignores the signal. `configDirOverride`, + * when set, points the child at an ephemeral CLAUDE_CONFIG_DIR. + */ +function spawnClaude(bin: string, prompt: string, timeoutMs: number, configDirOverride?: string): Promise<{ status: number; stdout: string }> { + return new Promise((resolve) => { + let stdout = ""; + let settled = false; + const finish = (status: number) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ status, stdout }); + }; + + const env: NodeJS.ProcessEnv = { ...process.env, CUE_BYPASS: "1" }; + if (configDirOverride) env.CLAUDE_CONFIG_DIR = configDirOverride; + + let child: ReturnType; + try { + child = spawn(bin, classifierSpawnArgs(prompt), { + env, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + resolve({ status: 1, stdout: "" }); + return; + } + + const timer = setTimeout(() => { + try { + child.kill("SIGTERM"); + } catch { + /* already gone */ + } + const killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* already gone */ + } + }, 500); + killTimer.unref?.(); + finish(124); // timed out → fail-open + }, timeoutMs); + timer.unref?.(); + + child.stdout?.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + child.on("error", () => finish(1)); + child.on("close", (code) => finish(code ?? 1)); + }); +} + +export interface ClassifierResult { + ok: boolean; + output: string; +} + +/** + * Run one classification. Resolves `{ ok: false }` on every failure path. + * + * Tries the `claude` on PATH first (usually cue's own shim, which `CUE_BYPASS` + * makes transparent), then the resolved real binary. The fallback shares what's + * left of the budget — a minimum of 2s — so a double timeout can't stack to + * roughly twice the stated tolerance and freeze an interactive command. + */ +export async function runClassifier(prompt: string, timeoutMs = 30_000): Promise { + const startedAt = Date.now(); + const home = setupClassifierHome(); + const configDir = home?.home; + try { + let res = await spawnClaude("claude", prompt, timeoutMs, configDir); + if (res.status !== 0 || !res.stdout.trim()) { + const fallback = findRealClaudeBin(); + if (fallback) { + const remaining = Math.max(2_000, timeoutMs - (Date.now() - startedAt)); + res = await spawnClaude(fallback, prompt, remaining, configDir); + } + } + if (res.status !== 0 || !res.stdout.trim()) return { ok: false, output: "" }; + return { ok: true, output: res.stdout.trim() }; + } finally { + if (home) teardownClassifierHome(home); + } +} diff --git a/src/lib/picker/flow.ts b/src/lib/picker/flow.ts index a6ffa925..e389b8f9 100644 --- a/src/lib/picker/flow.ts +++ b/src/lib/picker/flow.ts @@ -11,7 +11,8 @@ import * as p from "@clack/prompts"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { recordCombo } from "../combo-history"; -import { matchProfilesForCwd } from "../profile-match"; +import { resolveMatchContext } from "../profile-match"; +import { deepMatchProfiles, hasWarmDeepMatch, warmDeepMatchCache } from "../profile-match-llm"; import { mergeSignals, pathSignals, @@ -94,11 +95,27 @@ export async function runPickerV2(input: PickerInput): Promise { // this scores every profile's own vocabulary against the directory so // cycling through suggestions keeps finding relevant stacks instead of // running out. Best-effort — no matches just means a shorter list. + // + // The model reranks this, but ONLY from cache. A launch must never wait on a + // network round-trip: a warm entry (the usual case, since a repo's shape is + // stable for weeks) is read in ~1ms, and a cold one serves the lexical answer + // now while a detached process fills the cache for next time. let matched: SuggestMatch[] = []; try { - matched = matchProfilesForCwd(input.cwd, { limit: SUGGESTION_LIMIT }) - .filter((m) => known.has(m.name)) - .map((m) => ({ name: m.name, strength: m.strength, reason: m.reason })); + const ctx = resolveMatchContext(input.cwd); + if (ctx) { + let ranked = ctx.matches; + if (hasWarmDeepMatch(ctx.evidence, ctx.docs)) { + const deep = await deepMatchProfiles({ evidence: ctx.evidence, docs: ctx.docs, lexical: ctx.matches }); + if (deep.classified) ranked = deep.matches; + } else { + warmDeepMatchCache(input.cwd); + } + matched = ranked + .filter((m) => known.has(m.name)) + .slice(0, SUGGESTION_LIMIT) + .map((m) => ({ name: m.name, strength: m.strength, reason: m.reason })); + } } catch { /* a directory we can't read yields no matches, not an error */ } diff --git a/src/lib/profile-match-llm.test.ts b/src/lib/profile-match-llm.test.ts new file mode 100644 index 00000000..bb750a25 --- /dev/null +++ b/src/lib/profile-match-llm.test.ts @@ -0,0 +1,194 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, describe, expect, test } from "bun:test"; + +import type { ProfileDoc, RepoEvidence } from "./profile-match"; +import { + buildMatchPrompt, + deepCacheKey, + deepMatchDisabled, + deepMatchProfiles, + parsePicks, +} from "./profile-match-llm"; + +// The cache lives under XDG_CACHE_HOME; redirect it so tests never touch the +// user's real one. +const cacheHome = mkdtempSync(join(tmpdir(), "cue-deep-")); +const originalCache = process.env.XDG_CACHE_HOME; +process.env.XDG_CACHE_HOME = cacheHome; +afterAll(() => { + if (originalCache === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = originalCache; + rmSync(cacheHome, { recursive: true, force: true }); +}); + +function evidence(terms: Record, sources: Record = {}): RepoEvidence { + return { + terms: new Map(Object.entries(terms)), + reasons: new Map(Object.keys(terms).map((t) => [t, `because ${t}`])), + sources: new Map( + Object.keys(terms).map((t) => [t, (sources[t] ?? "dependency") as RepoEvidence["sources"] extends Map ? V : never]), + ), + }; +} + +const docs: ProfileDoc[] = [ + { name: "rust", description: "Rust development", terms: new Map([["rust", 4]]) }, + { name: "python", description: "Python development", terms: new Map([["python", 4]]) }, + { name: "ros2", description: "ROS 2 robot control", terms: new Map([["robot", 2]]) }, +]; + +describe("deepMatchDisabled", () => { + test("respects the off switches", () => { + for (const v of ["0", "off", "false", "OFF"]) { + expect(deepMatchDisabled({ CUE_PROFILE_MATCH_DEEP: v })).toBe(true); + } + }); + + test("defaults to enabled", () => { + expect(deepMatchDisabled({})).toBe(false); + expect(deepMatchDisabled({ CUE_PROFILE_MATCH_DEEP: "1" })).toBe(false); + }); +}); + +describe("deepCacheKey", () => { + test("is stable for the same evidence and profiles", () => { + const a = deepCacheKey(evidence({ rust: 3, cargo: 3 }), docs); + const b = deepCacheKey(evidence({ rust: 3, cargo: 3 }), docs); + expect(a).toBe(b); + }); + + test("does not depend on term insertion order", () => { + const a = deepCacheKey(evidence({ rust: 3, cargo: 3 }), docs); + const b = deepCacheKey(evidence({ cargo: 3, rust: 3 }), docs); + expect(a).toBe(b); + }); + + test("changes when the evidence changes", () => { + const a = deepCacheKey(evidence({ rust: 3 }), docs); + const b = deepCacheKey(evidence({ python: 3 }), docs); + expect(a).not.toBe(b); + }); + + test("changes when a profile description changes", () => { + const edited = docs.map((d) => (d.name === "rust" ? { ...d, description: "something else" } : d)); + expect(deepCacheKey(evidence({ rust: 3 }), docs)).not.toBe(deepCacheKey(evidence({ rust: 3 }), edited)); + }); + + // Keying on cwd would miss two checkouts of the same project, and a moved + // repo would re-pay for an answer that hasn't changed. + test("is independent of any path", () => { + const key = deepCacheKey(evidence({ rust: 3 }), docs); + expect(key).toMatch(/^[0-9a-f]{32}$/); + }); +}); + +describe("buildMatchPrompt", () => { + const prompt = buildMatchPrompt( + evidence({ rust: 3, robot: 3 }, { robot: "language" }), + docs, + [{ name: "rust", strength: 0.8, score: 8, matchedTerms: ["rust"], reason: "matches rust" }], + ); + + test("groups evidence by source", () => { + expect(prompt).toContain("dependency:"); + expect(prompt).toContain("language:"); + }); + + test("lists every profile with its description", () => { + for (const d of docs) expect(prompt).toContain(`- ${d.name}: ${d.description}`); + }); + + test("shows the lexical ranking as fallible input, not as the answer", () => { + expect(prompt).toContain("which may be wrong"); + expect(prompt).toContain("rust (0.80)"); + }); + + test("states the response format and the escape hatch", () => { + expect(prompt).toContain("PICK:"); + expect(prompt).toContain("PICK: none"); + }); + + test("tells the model to ignore the noise the lexical pass had to be taught", () => { + expect(prompt).toContain("CLAUDE.md"); + expect(prompt).toContain("metadata"); + }); +}); + +describe("parsePicks", () => { + const known = new Set(["rust", "python", "ros2"]); + + test("parses name and reason", () => { + const out = parsePicks("PICK: rust | Cargo.toml and .rs files\nPICK: python | scripts", known); + expect(out).toEqual([ + { name: "rust", reason: "Cargo.toml and .rs files" }, + { name: "python", reason: "scripts" }, + ]); + }); + + test("a bare name still parses, with a placeholder reason", () => { + expect(parsePicks("PICK: rust", known)).toEqual([{ name: "rust", reason: "model pick" }]); + }); + + test("PICK: none is an empty answer, not a failure", () => { + expect(parsePicks("PICK: none", known)).toEqual([]); + }); + + test("drops unknown names", () => { + expect(parsePicks("PICK: rust | ok\nPICK: imaginary | no", known)).toEqual([ + { name: "rust", reason: "ok" }, + ]); + }); + + test("dedupes a repeated pick", () => { + expect(parsePicks("PICK: rust | a\nPICK: rust | b", known)).toEqual([{ name: "rust", reason: "a" }]); + }); + + // Signalling failure matters: the caller keeps the lexical ranking instead of + // replacing it with nothing. + test("returns null when no PICK line is present", () => { + expect(parsePicks("Sure! Here are some profiles you might like.", known)).toBeNull(); + }); + + test("returns null when every name is unknown", () => { + expect(parsePicks("PICK: nope | x\nPICK: alsonope | y", known)).toBeNull(); + }); + + test("ignores prose around the picks", () => { + expect(parsePicks("Here you go:\n\nPICK: python | it is python\n\nHope that helps!", known)).toEqual([ + { name: "python", reason: "it is python" }, + ]); + }); +}); + +describe("deepMatchProfiles fail-open", () => { + const lexical = [{ name: "rust", strength: 0.8, score: 8, matchedTerms: ["rust"], reason: "matches rust" }]; + + test("returns the lexical ranking when disabled by env", async () => { + const prev = process.env.CUE_PROFILE_MATCH_DEEP; + process.env.CUE_PROFILE_MATCH_DEEP = "0"; + try { + const r = await deepMatchProfiles({ evidence: evidence({ rust: 3 }), docs, lexical }); + expect(r.classified).toBe(false); + expect(r.matches).toEqual(lexical); + expect(r.reason).toContain("disabled"); + } finally { + if (prev === undefined) delete process.env.CUE_PROFILE_MATCH_DEEP; + else process.env.CUE_PROFILE_MATCH_DEEP = prev; + } + }); + + test("returns the lexical ranking when there are no profiles", async () => { + const r = await deepMatchProfiles({ evidence: evidence({ rust: 3 }), docs: [], lexical }); + expect(r.classified).toBe(false); + expect(r.matches).toEqual(lexical); + }); + + test("returns the lexical ranking when the directory offers no evidence", async () => { + const r = await deepMatchProfiles({ evidence: evidence({}), docs, lexical }); + expect(r.classified).toBe(false); + expect(r.matches).toEqual(lexical); + }); +}); diff --git a/src/lib/profile-match-llm.ts b/src/lib/profile-match-llm.ts new file mode 100644 index 00000000..c972255a --- /dev/null +++ b/src/lib/profile-match-llm.ts @@ -0,0 +1,384 @@ +/** + * LLM reranking for the profile matcher. + * + * The lexical matcher in `profile-match` scores term overlap. That works, but + * every failure it has is the same shape: a word means one thing in a manifest + * and another in a profile description, and the fix is another stopword. The + * list of such words is unbounded — `CLAUDE.md`, `@clack/core`, `requires-python`, + * `base-template` each cost a round of tuning. + * + * A model reads the same evidence and simply doesn't make that class of + * mistake, because it knows `requires-python` is a metadata key and `robot.urdf` + * is a robot. So this tier reranks: lexical proposes, the model judges. + * + * **Why this fits here when it didn't fit the skill matcher.** The skill hook + * runs on every prompt, and the prompt is different every time — an LLM call + * there is a per-message tax with a near-zero cache hit rate, which is why it + * ended up as an opt-in `--deep` escalation. A repo's shape is stable for + * weeks. Keyed on the evidence rather than the clock, the cache hits ~always, + * and the model is consulted roughly once per project. + * + * The launch path is still never allowed to wait for it. See + * `warmDeepMatchCache`: a cold miss serves the lexical answer immediately and + * populates the cache in the background for next time. + */ + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runClassifier } from "./claude-classifier"; +import { cacheDir } from "./config-paths"; +import type { ProfileDoc, ProfileMatch, RepoEvidence } from "./profile-match"; + +/** Bump when the prompt or parse contract changes, to invalidate old entries. */ +const CACHE_VERSION = 1; + +/** + * Repo shape changes on the scale of weeks, and the evidence hash already + * invalidates on any real change, so this only bounds unbounded growth of + * entries for directories that no longer exist. + */ +const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +/** Never scan or remove more than this many files in one sweep. */ +const CACHE_SWEEP_CAP = 200; + +/** Model picks to ask for. More than this and the card stops reading as an answer. */ +const MAX_PICKS = 6; + +/** Evidence terms shown to the model, strongest first. */ +const MAX_EVIDENCE_LINES = 24; + +export interface DeepMatchInput { + evidence: RepoEvidence; + docs: ProfileDoc[]; + /** What the lexical pass produced, shown to the model as a starting point. */ + lexical: ProfileMatch[]; + timeoutMs?: number; + /** Skip the cache read (the write still happens). */ + noCache?: boolean; +} + +export interface DeepMatchResult { + matches: ProfileMatch[]; + /** True when the model actually ran or a cached model answer was used. */ + classified: boolean; + /** Why it fell back, when it did — surfaced by `cue profile match --deep`. */ + reason: string; + /** True when served from disk rather than a fresh call. */ + cached: boolean; +} + +/** Disabled entirely by env, mirroring the other LLM paths' opt-outs. */ +export function deepMatchDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + const v = env.CUE_PROFILE_MATCH_DEEP?.trim().toLowerCase(); + return v === "0" || v === "off" || v === "false"; +} + +function deepCacheDir(): string { + return join(cacheDir(), "profile-match"); +} + +/** + * Cache key: the evidence and the profile library, NOT the path. + * + * Keying on cwd would miss the common case — two checkouts of the same project, + * or a repo that moved — and would gain nothing, since identical evidence + * deserves an identical answer. + */ +export function deepCacheKey(evidence: RepoEvidence, docs: ProfileDoc[]): string { + const h = createHash("sha256"); + h.update(`v${CACHE_VERSION}\n`); + for (const [term, weight] of [...evidence.terms.entries()].sort(([a], [b]) => a.localeCompare(b))) { + h.update(`${term}=${weight}\n`); + } + h.update("--profiles--\n"); + for (const d of [...docs].sort((a, b) => a.name.localeCompare(b.name))) { + h.update(`${d.name}:${d.description}\n`); + } + return h.digest("hex").slice(0, 32); +} + +interface CacheEntry { + v: number; + ts: number; + picks: Array<{ name: string; reason: string }>; +} + +function readCache(key: string): CacheEntry | null { + try { + const file = join(deepCacheDir(), `${key}.json`); + if (!existsSync(file)) return null; + const entry = JSON.parse(readFileSync(file, "utf8")) as CacheEntry; + if (entry.v !== CACHE_VERSION) return null; + if (Date.now() - entry.ts > CACHE_TTL_MS) return null; + if (!Array.isArray(entry.picks)) return null; + return entry; + } catch { + return null; + } +} + +function writeCache(key: string, picks: Array<{ name: string; reason: string }>): void { + try { + const dir = deepCacheDir(); + mkdirSync(dir, { recursive: true }); + const file = join(dir, `${key}.json`); + const tmp = `${file}.tmp`; + writeFileSync(tmp, JSON.stringify({ v: CACHE_VERSION, ts: Date.now(), picks } satisfies CacheEntry)); + renameSync(tmp, file); + sweepExpired(); + } catch { + /* a cache we can't write is a cache we do without */ + } +} + +/** Drop expired entries. Bounded so a large cache can't stall a launch. */ +function sweepExpired(): void { + try { + const dir = deepCacheDir(); + const now = Date.now(); + for (const name of readdirSync(dir).slice(0, CACHE_SWEEP_CAP)) { + if (!name.endsWith(".json")) continue; + const file = join(dir, name); + try { + if (now - statSync(file).mtimeMs > CACHE_TTL_MS) rmSync(file, { force: true }); + } catch { + /* skip */ + } + } + } catch { + /* skip */ + } +} + +/** + * Describe the directory to the model in its own terms. + * + * Deliberately the evidence, not a file listing: the evidence is what the + * lexical pass saw, so when the model disagrees, the disagreement is about + * judgement rather than about two different views of the repo. + */ +export function buildMatchPrompt(evidence: RepoEvidence, docs: ProfileDoc[], lexical: ProfileMatch[]): string { + const bySource = new Map(); + for (const [term] of evidence.terms) { + const src = evidence.sources.get(term) ?? "entry"; + const reason = evidence.reasons.get(term) ?? ""; + const list = bySource.get(src) ?? []; + list.push(reason ? `${term} (${reason})` : term); + bySource.set(src, list); + } + + const evidenceLines: string[] = []; + for (const src of ["dependency", "language", "marker", "entry"]) { + const list = bySource.get(src); + if (!list?.length) continue; + evidenceLines.push(`${src}: ${list.slice(0, MAX_EVIDENCE_LINES).join(", ")}`); + } + + const profileLines = [...docs] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((d) => `- ${d.name}: ${d.description || "(no description)"}`); + + const lexicalLine = lexical.length + ? lexical.slice(0, 5).map((m) => `${m.name} (${m.strength.toFixed(2)})`).join(", ") + : "(none)"; + + return `You are choosing which cue profiles fit a software project. A profile bundles skills and MCP servers for a kind of work. + +What the project directory contains: +${evidenceLines.join("\n") || "(no strong signals)"} + +A keyword matcher ranked these, which may be wrong: ${lexicalLine} + +Available profiles: +${profileLines.join("\n")} + +Pick the profiles a developer opening THIS project would actually want. Respond in EXACTLY this format, one line per pick, best first, no other text: +PICK: | + +Rules: +- At most ${MAX_PICKS} picks. Fewer is better than padding. +- Only names from the list above, exactly as written. +- Judge by what the project IS. Ignore agent config files (CLAUDE.md, AGENTS.md) and manifest metadata keys — every project has those. +- Do NOT pick a profile just because a word coincidentally matched. +- If nothing genuinely fits, respond with the single line: PICK: none`; +} + +/** Parse `PICK: | ` lines, keeping only known profile names. */ +export function parsePicks(output: string, known: Set): Array<{ name: string; reason: string }> | null { + const lines = output.split("\n").map((l) => l.trim()).filter((l) => /^PICK:/i.test(l)); + if (lines.length === 0) return null; + + if (lines.length === 1 && /^PICK:\s*none\s*$/i.test(lines[0]!)) return []; + + const picks: Array<{ name: string; reason: string }> = []; + const seen = new Set(); + for (const line of lines) { + const body = line.replace(/^PICK:\s*/i, ""); + const [rawName, ...rest] = body.split("|"); + const name = (rawName ?? "").trim(); + if (!name || !known.has(name) || seen.has(name)) continue; + seen.add(name); + picks.push({ name, reason: rest.join("|").trim() || "model pick" }); + } + // Every name unknown means the model answered about something else — signal a + // parse failure so the caller keeps the lexical ranking rather than emptying it. + return picks.length > 0 ? picks : null; +} + +/** + * Turn model picks into `ProfileMatch`es. + * + * Strength descends across the ranking rather than being invented per item: the + * model gives an order, not a calibrated score, and dressing its output in + * false precision would make it look more certain than it is. A lexical score + * is reused when the model and the matcher agree on a profile. + */ +function picksToMatches(picks: Array<{ name: string; reason: string }>, lexical: ProfileMatch[]): ProfileMatch[] { + const byName = new Map(lexical.map((m) => [m.name, m])); + const n = Math.max(picks.length, 1); + return picks.map((p, i) => { + const prior = byName.get(p.name); + const strength = 1 - (i / n) * 0.6; // 1.0 → 0.4 across the ranking + return { + name: p.name, + strength, + score: prior?.score ?? strength * 10, + matchedTerms: prior?.matchedTerms ?? [], + reason: p.reason, + }; + }); +} + +/** + * Rerank profiles with the model. Never throws; falls back to `lexical`. + */ +export async function deepMatchProfiles(input: DeepMatchInput): Promise { + const fallback = (reason: string): DeepMatchResult => ({ + matches: input.lexical, + classified: false, + reason, + cached: false, + }); + + if (deepMatchDisabled()) return fallback("deep matching disabled by CUE_PROFILE_MATCH_DEEP"); + if (input.docs.length === 0) return fallback("no profiles to choose from"); + if (input.evidence.terms.size === 0) return fallback("directory offers no evidence to judge"); + + const known = new Set(input.docs.map((d) => d.name)); + const key = deepCacheKey(input.evidence, input.docs); + + if (!input.noCache) { + const hit = readCache(key); + if (hit) { + const picks = hit.picks.filter((p) => known.has(p.name)); + return { + matches: picks.length > 0 ? picksToMatches(picks, input.lexical) : input.lexical, + classified: true, + reason: picks.length > 0 ? `${picks.length} profiles picked by the model` : "model found nothing that fits", + cached: true, + }; + } + } + + const prompt = buildMatchPrompt(input.evidence, input.docs, input.lexical); + const { ok, output } = await runClassifier(prompt, input.timeoutMs ?? 30_000); + if (!ok) return fallback("claude --print unavailable"); + + const picks = parsePicks(output, known); + if (picks === null) return fallback("could not parse the model's answer"); + + writeCache(key, picks); + if (picks.length === 0) { + return { matches: [], classified: true, reason: "model found nothing that fits", cached: false }; + } + return { + matches: picksToMatches(picks, input.lexical), + classified: true, + reason: `${picks.length} profiles picked by the model`, + cached: false, + }; +} + +/** True when a cached model answer exists for this evidence. */ +export function hasWarmDeepMatch(evidence: RepoEvidence, docs: ProfileDoc[]): boolean { + if (deepMatchDisabled()) return false; + return readCache(deepCacheKey(evidence, docs)) !== null; +} + +/** + * Populate the cache in the background, then return immediately. + * + * This is what keeps the launch path honest. `cue launch` must never be slower + * than it is today because a model call is available, so a cold miss shows the + * lexical answer now and the model's answer arrives for the NEXT launch in this + * directory. Detached and fully ignored: nothing here can block, print, or fail + * the parent. + */ +export function warmDeepMatchCache(cwd: string): void { + if (deepMatchDisabled()) return; + // The child re-enters this code path; without the guard it would spawn its + // own warm-up, and so on. + if (process.env.CUE_PROFILE_MATCH_WARM === "0") return; + const entry = resolveCueEntry(); + if (!entry) return; + try { + const child = spawn(entry.cmd, [...entry.args, "profile", "match", cwd, "--deep", "--json"], { + detached: true, + stdio: "ignore", + env: { ...process.env, CUE_PROFILE_MATCH_WARM: "0" }, + }); + child.unref(); + } catch { + /* a warm-up we can't start is a warm-up we do without */ + } +} + +/** + * How to invoke cue again as a child. + * + * `process.argv[1]` is NOT it: that is whatever script the current process was + * started with, which is only cue's entry point when cue was invoked directly. + * Called from a library it points at the caller, and the "warm-up" silently + * re-runs that instead — a no-op that leaves the cache permanently cold and + * looks like the model simply never being consulted. + * + * So: the packaged bin next to this source, else cue on PATH, else nothing. + */ +function resolveCueEntry(): { cmd: string; args: string[] } | null { + if (process.env.CUE_BIN && existsSync(process.env.CUE_BIN)) { + return { cmd: process.env.CUE_BIN, args: [] }; + } + + // Source first, packaged launcher second. `bin/cue.mjs` runs the prebuilt + // `dist/cue.js`, which in a working checkout is whatever was last built — it + // silently ran a cue with no `profile match` subcommand, so the warm-up + // "succeeded" every time and never wrote a cache entry. In a published + // package there is no `src/`, and the bundle there is current by construction. + try { + const here = dirname(fileURLToPath(import.meta.url)); + for (const candidate of [ + join(here, "..", "index.ts"), + join(here, "..", "..", "bin", "cue.mjs"), + ]) { + if (existsSync(candidate)) return { cmd: process.execPath, args: [candidate] }; + } + } catch { + /* fall through to PATH */ + } + + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + if (!dir) continue; + const p = join(dir, "cue"); + try { + if (existsSync(p)) return { cmd: p, args: [] }; + } catch { + /* skip */ + } + } + return null; +} diff --git a/src/lib/profile-match.test.ts b/src/lib/profile-match.test.ts index 8c8edd1f..90fbfc79 100644 --- a/src/lib/profile-match.test.ts +++ b/src/lib/profile-match.test.ts @@ -154,6 +154,45 @@ describe("repoEvidence", () => { expect(ev.terms.has("rust")).toBe(false); }); + // PyPI trove classifiers are prose, and reading them made a small Python CLI + // look like it depended on "environment", "intended", "operating", + // "programming" and "topic" — garbage that misled the LLM tier too. + test("trove classifiers are not dependencies", () => { + const ev = repoEvidence( + "/r", + probeFor({ + "/r/pyproject.toml": `[project] +name = "thing" +classifiers = [ + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Topic :: Utilities", +] +dependencies = ["httpx>=0.27", "click"] +`, + }), + ); + for (const noise of ["environment", "intended", "operating", "programming", "topic", "console"]) { + expect(ev.terms.has(noise)).toBe(false); + } + }); + + test("an inline dependency array still yields its packages", () => { + const ev = repoEvidence( + "/r", + probeFor({ "/r/pyproject.toml": `dependencies = ["httpx>=0.27", "click"]\n` }), + ); + expect(ev.terms.has("httpx")).toBe(true); + expect(ev.terms.has("click")).toBe(true); + }); + + test("section headers are not dependencies", () => { + const ev = repoEvidence("/r", probeFor({ "/r/Cargo.toml": `[dependencies]\ntokio = "1"\n` })); + expect(ev.terms.has("tokio")).toBe(true); + }); + test("finds a nested manifest only when the root declares none", () => { const nested = repoEvidence( "/r", diff --git a/src/lib/profile-match.ts b/src/lib/profile-match.ts index 02340510..0b987a95 100644 --- a/src/lib/profile-match.ts +++ b/src/lib/profile-match.ts @@ -177,6 +177,14 @@ const MANIFEST_METADATA_KEYS = new Set([ "optional", "dev", "test", "default", "path", "branch", "true", "false", ]); +/** + * Metadata keys whose VALUE is a dependency list. + * + * They are metadata by name but carry the real package names inline, which for + * a pyproject is often the only place dependencies appear at all. + */ +const DEPENDENCY_LIST_KEYS = new Set(["dependencies", "requires", "install_requires", "devdependencies"]); + /** Filenames that name a technology outright. */ const MARKER_FILES: Record = { "cargo.toml": ["rust", "cargo"], @@ -375,14 +383,32 @@ function manifestDeps(cwd: string, probe: MatchProbe): string[] { const raw = probe.read(join(dir, file)); if (raw === null) continue; // Deliberately loose: we want identifier-shaped tokens, not a real parse - // of four different manifest grammars. Metadata keys are filtered because - // the scanner cannot tell `tokio = "1"` from `requires-python = ">=3.11"`. - for (const m of raw.matchAll(/^[\s"']*([a-zA-Z][a-zA-Z0-9._-]{2,})/gm)) { + // of four different manifest grammars. Two filters keep that honest. + for (const line of raw.split("\n")) { + // 1. Trove classifiers. `"Intended Audience :: Developers"` and friends + // are prose. Reading them made a small Python CLI look like it + // depended on "environment", "intended", "operating", "programming" + // and "topic" — and the LLM tier, handed that same evidence, then + // confidently picked profiles about building cue itself. + if (line.includes("::")) continue; + if (line.trimStart().startsWith("[")) continue; // section header + + const m = line.match(/^[\s"']*([a-zA-Z][a-zA-Z0-9._-]{2,})/); + if (!m) continue; const key = m[1]!.toLowerCase(); - if (MANIFEST_METADATA_KEYS.has(key)) continue; - // Hyphenated metadata (`build-backend`, `requires-python`) is metadata - // if either half is. - if (key.split(/[-_.]/).some((part) => MANIFEST_METADATA_KEYS.has(part))) continue; + + // 2. Metadata keys, because the scanner cannot tell `tokio = "1"` from + // `requires-python = ">=3.11"`. Hyphenated metadata (`build-backend`) + // counts if either half does. + if (MANIFEST_METADATA_KEYS.has(key) || key.split(/[-_.]/).some((p) => MANIFEST_METADATA_KEYS.has(p))) { + // An inline dependency array still carries real names on this line — + // `dependencies = ["httpx>=0.27", "click"]` — so harvest those rather + // than discarding the only dependency declaration a pyproject has. + if (DEPENDENCY_LIST_KEYS.has(key)) { + for (const q of line.matchAll(/["']([a-zA-Z][a-zA-Z0-9._-]{2,})/g)) out.push(q[1]!); + } + continue; + } out.push(m[1]!); } } @@ -564,22 +590,47 @@ export function matchProfiles(evidence: RepoEvidence, docs: ProfileDoc[]): Profi return scored.filter((s) => s.strength >= MATCH_MIN_STRENGTH); } +/** Everything one directory's match needed, kept for callers that go further. */ +export interface MatchContext { + evidence: RepoEvidence; + docs: ProfileDoc[]; + matches: ProfileMatch[]; +} + /** - * Convenience wrapper: read the profiles, read the directory, score. + * Read the profiles, read the directory, score — and hand back the inputs too. * - * The one I/O entry point, so `matchProfiles` itself stays pure and testable. - * Never throws — a failure here costs the suggestion tail, not the picker. + * The LLM reranking tier needs the evidence and the profile docs, not just the + * ranking, both to build its prompt and to key its cache. Returning them here + * keeps `matchProfiles` pure and stops the caller from re-reading the whole + * profile tree to get at them. + * + * Never throws: a failure costs the suggestion tail, not the picker. */ -export function matchProfilesForCwd( +export function resolveMatchContext( cwd: string, - opts: { root?: string; probe?: MatchProbe; limit?: number } = {}, -): ProfileMatch[] { + opts: { root?: string; probe?: MatchProbe } = {}, +): MatchContext | null { try { const probe = opts.probe ?? REAL_MATCH_PROBE; const docs = loadProfileDocs(opts.root ?? profilesRoot(), probe); - const matches = matchProfiles(repoEvidence(cwd, probe), docs); - return opts.limit ? matches.slice(0, opts.limit) : matches; + const evidence = repoEvidence(cwd, probe); + return { evidence, docs, matches: matchProfiles(evidence, docs) }; } catch { - return []; + return null; } } + +/** + * Convenience wrapper for callers that only want the ranking. + * + * Never throws — a failure here costs the suggestion tail, not the picker. + */ +export function matchProfilesForCwd( + cwd: string, + opts: { root?: string; probe?: MatchProbe; limit?: number } = {}, +): ProfileMatch[] { + const ctx = resolveMatchContext(cwd, opts); + if (!ctx) return []; + return opts.limit ? ctx.matches.slice(0, opts.limit) : ctx.matches; +} diff --git a/src/lib/skill-subset.ts b/src/lib/skill-subset.ts index 75566f6b..253418e8 100644 --- a/src/lib/skill-subset.ts +++ b/src/lib/skill-subset.ts @@ -19,12 +19,21 @@ * change to the list never serves a stale subset. */ -import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { findRealClaudeBin } from "./claude-binary"; +// The classifier spawn, its ephemeral config dir and the credential copy-back +// live in claude-classifier now — the profile matcher needs the same call, and +// this machinery is far too subtle to keep two copies of. +import { + classifierSpawnArgs, + credExpiresAt, + runClassifier, + setupClassifierHome, + shouldCopyBackCreds, + teardownClassifierHome, +} from "./claude-classifier"; import { cacheDir } from "./config-paths"; import { resolveLocalSkill } from "./resolver-local"; import { parseMetadataFromContent } from "../commands/optimizer"; @@ -41,6 +50,9 @@ export const ALWAYS_KEEP = new Set([ "caveman/caveman-commit", ]); +// Re-exported so callers and tests that reached for these here keep working. +export { classifierSpawnArgs, shouldCopyBackCreds }; + /** Bump when buildPrompt / the parse contract changes, to invalidate old cache. */ const CACHE_VERSION = 1; const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days @@ -92,202 +104,6 @@ Rules: - If unsure, KEEP fewer skills. The user can always load more by retrying.`; } -/** - * CLI args for the classifier spawn. The call must be LIGHTWEIGHT: - * - `--strict-mcp-config` — without it the spawned claude boots every MCP - * server in the user's config (a dozen npm/python daemons) just to answer - * one KEEP: line. Observed to blow the 30s budget and freeze launches on - * configs with many global servers. - * - a fast model — the account default may be a heavyweight reasoning model; - * skill classification is a trivial pick-list task. Override with - * CUE_SMART_SUBSET_MODEL if the alias isn't available on the account. - * NOT `--bare`: it skips credential/settings loading and comes back - * "Not logged in". - */ -export function classifierSpawnArgs(prompt: string): string[] { - const model = process.env.CUE_SMART_SUBSET_MODEL?.trim() || "haiku"; - return ["--print", "--strict-mcp-config", "--model", model, "-p", prompt]; -} - -// --------------------------------------------------------------------------- -// Classifier isolation — an ephemeral CLAUDE_CONFIG_DIR for the spawn -// --------------------------------------------------------------------------- - -interface ClassifierHome { - /** The ephemeral config dir to point CLAUDE_CONFIG_DIR at. */ - home: string; - /** The real `.credentials.json` we copied from, for the rotation copy-back. */ - credSrc: string | null; -} - -/** OAuth token expiry (epoch ms) from a `.credentials.json`, or 0 if unreadable. */ -function credExpiresAt(p: string): number { - try { - const v = (JSON.parse(readFileSync(p, "utf8")) as { claudeAiOauth?: { expiresAt?: unknown } }) - ?.claudeAiOauth?.expiresAt; - return typeof v === "number" ? v : 0; - } catch { - return 0; - } -} - -/** - * Whether to carry the classifier home's credentials back to the source: only - * when the home token is strictly newer (higher expiresAt). Anthropic rotates - * the refresh token on every refresh, so a stale copy must never clobber a live - * source token. Pure so it can be unit-tested. Mirrors the launch.ts rescue guard. - */ -export function shouldCopyBackCreds(homeExpiresAt: number, srcExpiresAt: number): boolean { - return homeExpiresAt > srcExpiresAt; -} - -/** - * Build an ephemeral CLAUDE_CONFIG_DIR for the classifier spawn so it does NOT - * load the user's plugins (claude-mem spawns a worker daemon per call), fire - * their hooks, or append phantom sessions to their logs. Copies the live OAuth - * credentials from the launch's real config dir in so the call still auths. - * Returns null when there's nothing to isolate (no CLAUDE_CONFIG_DIR set) — the - * caller then inherits the parent env, the pre-isolation behavior. - */ -function setupClassifierHome(): ClassifierHome | null { - const src = process.env.CLAUDE_CONFIG_DIR; - if (!src) return null; - try { - const base = join(cacheDir(), "classifier-home"); - mkdirSync(base, { recursive: true }); - const home = mkdtempSync(join(base, "run-")); - // Minimal config: no enabledPlugins, no hooks, no mcpServers. - writeFileSync(join(home, "settings.json"), "{}\n"); - writeFileSync(join(home, ".claude.json"), `${JSON.stringify({ hasCompletedOnboarding: true })}\n`); - const credSrcFile = join(src, ".credentials.json"); - let credSrc: string | null = null; - if (existsSync(credSrcFile)) { - copyFileSync(credSrcFile, join(home, ".credentials.json")); - credSrc = credSrcFile; - } - return { home, credSrc }; - } catch { - return null; - } -} - -/** Copy back a rotated token if newer, then remove the ephemeral home. Best-effort. */ -function teardownClassifierHome(h: ClassifierHome): void { - try { - if (h.credSrc) { - const homeCred = join(h.home, ".credentials.json"); - // The source may be a SHARED account `.credentials.json` (authmux parallel - // accounts point CLAUDE_CONFIG_DIR there), read live by other sessions. So - // this must be atomic: copy into a sibling tmp, re-check freshness (a - // concurrent launch may have rotated the source since we forked), then - // rename — a same-dir rename is atomic, so a reader never sees a torn file. - // Mirrors credentials-sync.ts's writer. - if (existsSync(homeCred) && shouldCopyBackCreds(credExpiresAt(homeCred), credExpiresAt(h.credSrc))) { - const tmp = `${h.credSrc}.cue-classifier.${process.pid}.tmp`; - try { - copyFileSync(homeCred, tmp); - // Re-check under the freshest source state before committing the swap. - if (shouldCopyBackCreds(credExpiresAt(homeCred), credExpiresAt(h.credSrc))) { - renameSync(tmp, h.credSrc); - } else { - rmSync(tmp, { force: true }); - } - } catch { - try { rmSync(tmp, { force: true }); } catch { /* best-effort */ } - } - } - } - } catch { - /* best-effort */ - } - try { - rmSync(h.home, { recursive: true, force: true }); - } catch { - /* best-effort */ - } -} - -/** - * Spawn `claude --print` ASYNC and resolve with its trimmed stdout. Never - * rejects: on spawn error, non-zero exit, or timeout it resolves - * `{ status: non-zero }` so the caller fail-opens. The timeout kills the child - * (SIGTERM, then SIGKILL backstop) and resolves immediately so the Promise - * always settles even if the child ignores the signal. `configDirOverride`, - * when set, points the child at an ephemeral CLAUDE_CONFIG_DIR (see setupClassifierHome). - */ -function spawnClaude(bin: string, prompt: string, timeoutMs: number, configDirOverride?: string): Promise<{ status: number; stdout: string }> { - return new Promise((resolve) => { - let stdout = ""; - let settled = false; - const finish = (status: number) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve({ status, stdout }); - }; - - const env: NodeJS.ProcessEnv = { ...process.env, CUE_BYPASS: "1" }; - if (configDirOverride) env.CLAUDE_CONFIG_DIR = configDirOverride; - - let child: ReturnType; - try { - child = spawn(bin, classifierSpawnArgs(prompt), { - env, - stdio: ["ignore", "pipe", "ignore"], - }); - } catch { - resolve({ status: 1, stdout: "" }); - return; - } - - const timer = setTimeout(() => { - try { - child.kill("SIGTERM"); - } catch { - /* already gone */ - } - const killTimer = setTimeout(() => { - try { - child.kill("SIGKILL"); - } catch { - /* already gone */ - } - }, 500); - killTimer.unref?.(); - finish(124); // timed out → fail-open - }, timeoutMs); - timer.unref?.(); - - child.stdout?.on("data", (d: Buffer) => { - stdout += d.toString(); - }); - child.on("error", () => finish(1)); - child.on("close", (code) => finish(code ?? 1)); - }); -} - -async function callClaudeAsync(prompt: string, timeoutMs: number): Promise<{ ok: boolean; output: string }> { - const startedAt = Date.now(); - const home = setupClassifierHome(); - const configDir = home?.home; - try { - let res = await spawnClaude("claude", prompt, timeoutMs, configDir); - if (res.status !== 0 || !res.stdout.trim()) { - const fallback = findRealClaudeBin(); - // Share the budget: the fallback gets what's left of timeoutMs (min 2s), so a - // double-timeout can't stack to ~2x the stated tolerance and freeze the launch. - if (fallback) { - const remaining = Math.max(2_000, timeoutMs - (Date.now() - startedAt)); - res = await spawnClaude(fallback, prompt, remaining, configDir); - } - } - if (res.status !== 0 || !res.stdout.trim()) return { ok: false, output: "" }; - return { ok: true, output: res.stdout.trim() }; - } finally { - if (home) teardownClassifierHome(home); - } -} - function parseClaudeKeep(output: string, allSkillIds: string[]): string[] | null { const m = output.match(/KEEP:\s*(.+)/i); if (!m) return null; @@ -476,7 +292,7 @@ export async function selectRelevantSkills( } const claudePrompt = buildPrompt(trimmed, descriptors); - const { ok, output } = await callClaudeAsync(claudePrompt, timeoutMs); + const { ok, output } = await runClassifier(claudePrompt, timeoutMs); if (!ok) { return { selected: skillIds, classified: false, reason: "claude --print unavailable — kept all skills" }; } From 7c8494ad9608de5beff2db56b42cfd9846a2057a Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 09:27:04 +0200 Subject: [PATCH 07/33] feat(picker): scope remembered stacks to the repo you launch in The combine suggestion engine remembered every stack globally: combo-history rows carried no directory, so "you launched this stack 4x" counted launches from every project at once. At SCORE_COMBO (50-65 with the count bonus) that outranked the cwd-scoped recent (max 50), so a favourite stack from an unrelated repo led the picker in a repo that had never seen it. Rows now record the launch directory, and readCombos scopes them by repository root - a launch inside packages/core still sees the stack confirmed at the repo root above it. Stacks confirmed here score 60-75; stacks known only from other repos drop to 31-40, below anything cwd-scoped, and say so in their reason. Backward compatible on both ends: an unattributed row (written before cwd was recorded) never claims the current directory, and a caller that passes no scope keeps the exact score and wording it had before. --- src/commands/launch.ts | 4 +- src/lib/combo-history.test.ts | 110 +++++++++++++++++++++++++++++++++ src/lib/combo-history.ts | 112 +++++++++++++++++++++++++++++++--- src/lib/picker.ts | 2 +- src/lib/picker/flow.ts | 2 +- src/lib/stack-suggest.test.ts | 25 ++++++++ src/lib/stack-suggest.ts | 58 +++++++++++++++--- 7 files changed, 295 insertions(+), 18 deletions(-) diff --git a/src/commands/launch.ts b/src/commands/launch.ts index aabfc4ea..0eef3209 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -1650,7 +1650,9 @@ export async function run(args: string[]): Promise { let combos: ComboUsage[] = []; try { const { readCombos } = await import("../lib/combo-history"); - combos = readCombos(); + // Scoped to the launch directory: stacks confirmed in this repo lead, + // stacks from other repos drop to a hint. + combos = readCombos(undefined, { cwd }); } catch (err) { debug("launch:combo-history", err); } const picked = await runPicker({ cwd, diff --git a/src/lib/combo-history.test.ts b/src/lib/combo-history.test.ts index 22b5783f..5984d0e7 100644 --- a/src/lib/combo-history.test.ts +++ b/src/lib/combo-history.test.ts @@ -50,6 +50,18 @@ describe("recordCombo", () => { }); expect(wrote).toBe(false); }); + + test("records the launch directory so combos can be scoped per repo", () => { + const { lines, append } = capture(); + recordCombo(["a", "b"], "t", append, "/home/u/proj"); + expect((JSON.parse(lines[0]!) as ComboRecord).cwd).toBe("/home/u/proj"); + }); + + test("omits cwd when the caller has no directory to attribute", () => { + const { lines, append } = capture(); + recordCombo(["a", "b"], "t", append); + expect((JSON.parse(lines[0]!) as ComboRecord).cwd).toBeUndefined(); + }); }); describe("readCombos", () => { @@ -68,6 +80,10 @@ describe("readCombos", () => { } }; + /** A cwd-attributed history row, the shape `recordCombo` writes today. */ + const row = (ts: string, profile: string, cwd: string): string => + JSON.stringify({ ts, profile, primary: profile.split("+")[0], cwd }); + test("aggregates repeats into one row with a count and the newest timestamp", () => { withLog( [ @@ -102,4 +118,98 @@ describe("readCombos", () => { test("a missing log is empty, not an error", () => { expect(readCombos("/nonexistent/cue/combos.jsonl")).toEqual([]); }); + + /** Pretend every path under /home/u/ belongs to repo /home/u/. */ + const fakeRepoRootOf = (dir: string): string | undefined => { + const m = /^(\/home\/u\/[^/]+)(\/|$)/.exec(dir); + return m ? m[1] : undefined; + }; + + test("a launch inside the repo sees a stack confirmed at the repo root", () => { + withLog([row("2026-07-01T00:00:00Z", "a+b", "/home/u/api")], (path) => { + const out = readCombos(path, { + cwd: "/home/u/api/packages/core", + repoRootOf: fakeRepoRootOf, + }); + expect(out[0]?.here).toBe(1); + }); + }); + + test("a stack from a different repo is never `here`", () => { + withLog([row("2026-07-01T00:00:00Z", "a+b", "/home/u/other/src")], (path) => { + const out = readCombos(path, { cwd: "/home/u/api", repoRootOf: fakeRepoRootOf }); + expect(out[0]?.here).toBe(0); + }); + }); + + test("outside any repo, scoping falls back to the directory subtree", () => { + withLog( + [ + row("2026-07-01T00:00:00Z", "a+b", "/scratch/notes/sub"), + row("2026-07-02T00:00:00Z", "c+d", "/scratch/elsewhere"), + ], + (path) => { + const out = readCombos(path, { cwd: "/scratch/notes", repoRootOf: () => undefined }); + expect(out.find((c) => c.parts.join("+") === "a+b")?.here).toBe(1); + expect(out.find((c) => c.parts.join("+") === "c+d")?.here).toBe(0); + }, + ); + }); + + test("`here` counts only rows recorded at or under the scoping directory", () => { + withLog( + [ + row("2026-07-01T00:00:00Z", "rust+secops", "/home/u/api"), + row("2026-07-02T00:00:00Z", "rust+secops", "/home/u/api/crates/core"), + row("2026-07-03T00:00:00Z", "rust+secops", "/home/u/other"), + row("2026-07-04T00:00:00Z", "python+ops", "/home/u/other"), + ], + (path) => { + const out = readCombos(path, { cwd: "/home/u/api", repoRootOf: fakeRepoRootOf }); + const rust = out.find((c) => c.parts.join("+") === "rust+secops"); + const python = out.find((c) => c.parts.join("+") === "python+ops"); + expect(rust?.here).toBe(2); // the repo row + the row from a subdirectory + expect(rust?.count).toBe(3); // global total still counts the other repo + expect(python?.here).toBe(0); + }, + ); + }); + + test("a sibling directory sharing a name prefix is not `here`", () => { + withLog([row("2026-07-01T00:00:00Z", "a+b", "/home/u/api-legacy")], (path) => { + expect(readCombos(path, { cwd: "/home/u/api", repoRootOf: fakeRepoRootOf })[0]?.here).toBe(0); + }); + }); + + test("legacy rows written before cwd was recorded never count as `here`", () => { + withLog( + [JSON.stringify({ ts: "2026-07-01T00:00:00Z", profile: "a+b", primary: "a" })], + (path) => { + const out = readCombos(path, { cwd: "/home/u/api", repoRootOf: fakeRepoRootOf }); + expect(out[0]?.count).toBe(1); + expect(out[0]?.here).toBe(0); + }, + ); + }); + + test("`here` stays undefined when no scope is requested (unchanged shape)", () => { + withLog([row("2026-07-01T00:00:00Z", "a+b", "/home/u/api")], (path) => { + expect(readCombos(path)[0]?.here).toBeUndefined(); + }); + }); + + test("stacks used in this directory sort ahead of more-used foreign stacks", () => { + withLog( + [ + row("2026-07-01T00:00:00Z", "here+stack", "/home/u/api"), + row("2026-07-02T00:00:00Z", "far+stack", "/home/u/other"), + row("2026-07-03T00:00:00Z", "far+stack", "/home/u/other"), + row("2026-07-04T00:00:00Z", "far+stack", "/home/u/other"), + ], + (path) => { + const out = readCombos(path, { cwd: "/home/u/api", repoRootOf: fakeRepoRootOf }); + expect(out.map((c) => c.parts.join("+"))).toEqual(["here+stack", "far+stack"]); + }, + ); + }); }); diff --git a/src/lib/combo-history.ts b/src/lib/combo-history.ts index ccad1b11..8d077a24 100644 --- a/src/lib/combo-history.ts +++ b/src/lib/combo-history.ts @@ -31,6 +31,12 @@ export interface ComboRecord { profile: string; /** Convenience field — the first part, the profile the user picked first. */ primary: string; + /** + * Directory the combo was confirmed in. Optional: rows written before this + * field existed carry no attribution, and `readCombos` treats them as + * foreign rather than guessing which repo they came from. + */ + cwd?: string; } /** @@ -39,12 +45,15 @@ export interface ComboRecord { * parts (preserving order) so "a+a+b" records as "a+b". `now` is injected for * testability. Returns whether a line was written. * - * `append` is injectable so tests don't touch the real config dir. + * `append` is injectable so tests don't touch the real config dir. `cwd` is the + * launch directory, stored so `readCombos` can scope suggestions to the repo + * you're actually in; omitting it records an unattributed row. */ export function recordCombo( parts: string[], now: string, append: (line: string) => void = defaultAppend, + cwd?: string, ): boolean { const deduped: string[] = []; for (const raw of parts) { @@ -56,6 +65,7 @@ export function recordCombo( ts: now, profile: deduped.join("+"), primary: deduped[0]!, + ...(cwd ? { cwd } : {}), }; try { append(JSON.stringify(record) + "\n"); @@ -79,17 +89,88 @@ export function readComboHistoryLines(path: string = comboHistoryPath()): string /** One previously-confirmed stack, aggregated across the history log. */ export interface ComboUsage { parts: string[]; + /** Times this stack was confirmed anywhere. */ count: number; lastUsed?: string; + /** + * Of `count`, how many were confirmed in the scoping directory (see + * `ReadCombosOptions.cwd`). `undefined` when no scope was requested — the + * caller then has no per-repo signal and should treat the stack as it always + * did. `0` means the stack is real but foreign to this directory. + */ + here?: number; +} + +export interface ReadCombosOptions { + /** + * Scope counts to a directory. A row counts toward `here` when it was + * recorded in the same *repository* — so a launch deep inside `packages/api` + * still sees the stack you confirmed at the repo root, and vice versa. Rows + * recorded at or beneath `cwd` also count, which covers directories that + * aren't in a repo at all. + * + * A sibling that merely shares a name prefix (`api-legacy` vs `api`) never + * matches: comparison is by resolved root or `/`-delimited descent, not raw + * string prefix. + */ + cwd?: string; + /** + * Resolve a directory to its repository root, or `undefined` when it isn't in + * one. Injectable so tests stay off the real filesystem. + */ + repoRootOf?: (dir: string) => string | undefined; +} + +/** True when `candidate` is `base` or lives beneath it. */ +function isWithin(candidate: string, base: string): boolean { + return candidate === base || candidate.startsWith(`${base}/`); +} + +/** + * Nearest ancestor directory containing `.git` (a directory for a normal + * clone, a file for a worktree or submodule). `undefined` when the path isn't + * inside a repository — callers then fall back to plain directory scoping. + */ +export function findRepoRoot(dir: string): string | undefined { + let current = dir; + // `dirname("/") === "/"`, so this terminates at the filesystem root. + for (;;) { + if (existsSync(join(current, ".git"))) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +/** Memoize root lookups for one read — the log repeats the same directories. */ +function cachedRootResolver( + resolve: (dir: string) => string | undefined, +): (dir: string) => string | undefined { + const cache = new Map(); + return (dir) => { + if (!cache.has(dir)) cache.set(dir, resolve(dir)); + return cache.get(dir); + }; } /** * Aggregate the combo log into distinct stacks with a use count and the most - * recent timestamp, newest-and-most-used first. Feeds the v2 picker's - * suggestion engine ("you launched this stack 4×"). Malformed lines are - * skipped; a missing log yields []. Never throws. + * recent timestamp. Feeds the v2 picker's suggestion engine ("you launched this + * stack 4× here"). Malformed lines are skipped; a missing log yields []. Never + * throws. + * + * With `opts.cwd` set, stacks confirmed in that directory sort first regardless + * of how heavily a foreign stack was used — the whole point of scoping is that + * what you do in *this* repo outranks what you do everywhere else. */ -export function readCombos(path: string = comboHistoryPath()): ComboUsage[] { +export function readCombos( + path: string = comboHistoryPath(), + opts: ReadCombosOptions = {}, +): ComboUsage[] { + const scope = opts.cwd; + const rootOf = cachedRootResolver(opts.repoRootOf ?? findRepoRoot); + // Resolved once: every row is compared against this repo, not this directory. + const scopeRoot = scope === undefined ? undefined : rootOf(scope); const byProfile = new Map(); for (const line of readComboHistoryLines(path)) { const trimmed = line.trim(); @@ -105,15 +186,32 @@ export function readCombos(path: string = comboHistoryPath()): ComboUsage[] { if (parts.length < 2) continue; const existing = byProfile.get(selector); const ts = typeof record.ts === "string" ? record.ts : undefined; + // An unattributed (pre-cwd) row is never claimed for the current directory. + const rowCwd = typeof record.cwd === "string" ? record.cwd : undefined; + const isHere = + scope !== undefined && + rowCwd !== undefined && + // Same repository (covers sibling subdirectories in either direction), + // or — for paths outside any repo — recorded at or under the scope. + ((scopeRoot !== undefined && rootOf(rowCwd) === scopeRoot) || isWithin(rowCwd, scope)); if (existing) { existing.count += 1; + if (isHere) existing.here = (existing.here ?? 0) + 1; if (ts && (existing.lastUsed ?? "") < ts) existing.lastUsed = ts; } else { - byProfile.set(selector, { parts, count: 1, lastUsed: ts }); + byProfile.set(selector, { + parts, + count: 1, + lastUsed: ts, + ...(scope === undefined ? {} : { here: isHere ? 1 : 0 }), + }); } } return [...byProfile.values()].sort( - (a, b) => b.count - a.count || (b.lastUsed ?? "").localeCompare(a.lastUsed ?? ""), + (a, b) => + (b.here ?? 0) - (a.here ?? 0) || + b.count - a.count || + (b.lastUsed ?? "").localeCompare(a.lastUsed ?? ""), ); } diff --git a/src/lib/picker.ts b/src/lib/picker.ts index c2221919..e1ef5cd7 100644 --- a/src/lib/picker.ts +++ b/src/lib/picker.ts @@ -1148,7 +1148,7 @@ export async function runPickerClassic(input: PickerInput): Promise { const choice = parts.join("+"); try { - recordCombo(parts, new Date().toISOString()); + recordCombo(parts, new Date().toISOString(), undefined, input.cwd); } catch { /* logging must never block a launch */ } diff --git a/src/lib/stack-suggest.test.ts b/src/lib/stack-suggest.test.ts index 0424b792..483a09a0 100644 --- a/src/lib/stack-suggest.test.ts +++ b/src/lib/stack-suggest.test.ts @@ -113,6 +113,31 @@ describe("suggestStacks", () => { expect(out[0]?.reasons[0]).toBe("you launched this stack 4×"); }); + test("a stack confirmed in this directory outranks a more-used foreign one", () => { + const out = suggestStacks({ + profiles, + combos: [ + { parts: ["python", "secops"], count: 6, here: 0, lastUsed: "2026-07-25T00:00:00Z" }, + { parts: ["rust", "secops"], count: 1, here: 1, lastUsed: "2026-07-20T00:00:00Z" }, + ], + }); + expect(out[0]?.parts).toEqual(["rust", "secops"]); + expect(out[0]?.reasons[0]).toBe("you launched this stack 1× here"); + expect(out[1]?.reasons[0]).toBe("you launched this stack 6× in other directories"); + }); + + test("a foreign-only stack is named as such and ranks below a cwd recent", () => { + const out = suggestStacks({ + profiles, + combos: [{ parts: ["python", "secops"], count: 6, here: 0 }], + recents: [{ name: "rust", sessions: 1, lastUsed: "2026-07-25T00:00:00Z" }], + recentsAreCwdScoped: true, + }); + expect(out[0]?.parts[0]).toBe("rust"); + const foreign = out.find((s) => s.origin === "combo"); + expect(foreign?.reasons[0]).toBe("you launched this stack 6× in other directories"); + }); + test("ignores unknown profile names everywhere", () => { const out = suggestStacks({ profiles, diff --git a/src/lib/stack-suggest.ts b/src/lib/stack-suggest.ts index f094d17e..1e9f2f76 100644 --- a/src/lib/stack-suggest.ts +++ b/src/lib/stack-suggest.ts @@ -54,6 +54,13 @@ export interface SuggestCombo { parts: string[]; count: number; lastUsed?: string | null; + /** + * Of `count`, how many confirmations happened in *this* directory (see + * `combo-history.readCombos`). `undefined` means the caller had no per-repo + * attribution and the stack is scored the way it always was; `0` means the + * stack is genuinely foreign to this directory and is demoted accordingly. + */ + here?: number; } export interface SuggestInput { @@ -116,6 +123,18 @@ export const DETECT_MIN_CONFIDENCE = 0.5; // history comes next (it describes this user), curation last. export const SCORE_DETECTED = 100; export const SCORE_COMBO = 45; +/** + * A stack confirmed in *this* directory: the strongest history signal there is, + * because it describes both this user and this project. Ranks above any + * cwd-scoped recent (a single profile) but still below a confident detection. + */ +export const SCORE_COMBO_HERE = 55; +/** + * A stack the user only ever confirmed in *other* directories. Still a hint — + * they clearly like this pairing — but it says nothing about the repo they're + * standing in, so it drops below everything cwd-scoped. + */ +export const SCORE_COMBO_ELSEWHERE = 28; export const SCORE_RECENT_CWD = 40; export const SCORE_RECENT_GLOBAL = 25; export const SCORE_FEATURED = 15; @@ -228,13 +247,14 @@ export function suggestStacks(input: SuggestInput): StackSuggestion[] { ]); } - // 2. stacks the user confirmed here before (combo history). - for (const c of [...(input.combos ?? [])].sort(byCountThenRecency)) { + // 2. stacks the user confirmed before (combo history), repo-scoped when the + // caller supplied attribution: what you launched *here* leads, what you + // launched elsewhere stays available but stops crowding out this + // directory's own signals. + for (const c of [...(input.combos ?? [])].sort(byHereThenCountThenRecency)) { const parts = c.parts.filter((p) => known.has(p)); if (parts.length < 2) continue; - record(parts, SCORE_COMBO + Math.min(c.count, 4) * 5, "combo", [ - `you launched this stack ${c.count}×`, - ]); + record(parts, comboScore(c), "combo", [comboReason(c)]); } // 3. recents — most-recent first, so "what I did here last" wins over @@ -287,9 +307,31 @@ export function suggestStacks(input: SuggestInput): StackSuggestion[] { .slice(0, limit); } -/** Sort combos by how often they were used, then by recency. */ -function byCountThenRecency(a: SuggestCombo, b: SuggestCombo): number { - return b.count - a.count || (b.lastUsed ?? "").localeCompare(a.lastUsed ?? ""); +/** Sort combos by local use first, then total use, then recency. */ +function byHereThenCountThenRecency(a: SuggestCombo, b: SuggestCombo): number { + return ( + (b.here ?? 0) - (a.here ?? 0) || + b.count - a.count || + (b.lastUsed ?? "").localeCompare(a.lastUsed ?? "") + ); +} + +/** + * Score a remembered stack against the directory it's being suggested for. + * Unattributed history (`here === undefined`) keeps the original global score, + * so a caller that can't scope loses nothing. + */ +function comboScore(c: SuggestCombo): number { + if (c.here === undefined) return SCORE_COMBO + Math.min(c.count, 4) * 5; + if (c.here > 0) return SCORE_COMBO_HERE + Math.min(c.here, 4) * 5; + return SCORE_COMBO_ELSEWHERE + Math.min(c.count, 4) * 3; +} + +/** The one-line "why" shown under a remembered stack. */ +function comboReason(c: SuggestCombo): string { + if (c.here === undefined) return `you launched this stack ${c.count}×`; + if (c.here > 0) return `you launched this stack ${c.here}× here`; + return `you launched this stack ${c.count}× in other directories`; } /** Sort recents newest-first, falling back to session count. */ From b029942429c74acf5bcf682ac3bd3e9e912e3d0d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 09:39:57 +0200 Subject: [PATCH 08/33] feat(picker): scope pair affinity to the repo you launch in growStack grafts your top historical partner onto every suggested stack, and it does so with no reason line of its own - the profile just appears in the card. That partner came from a global affinity map mined across every project, so a pairing learned in one repo rode silently into the suggestions of an unrelated one: open an API repo and backend+medusa-dev showed up because that is how you work in a shop repo. computeAffinityMap now takes an optional repo scope, and launch builds the picker's partner map from the scoped one. Cross-repo habits still surface, but only through channels that say where they came from - universalSuggestions, and `cue suggest-pairs`, which now splits its table into pairings made here and pairings made in other projects. The scope predicate moves to lib/repo-scope, shared with combo-history so the two history readers cannot drift into different notions of "here". Unscoped callers - the universal-suggestion frequency pass, the dashboard - are unchanged, and rows with no recorded directory are excluded under a scope rather than credited to whichever repo happens to be open. --- src/commands/launch.ts | 8 ++- src/commands/suggest-pairs.test.ts | 85 +++++++++++++++++++++++- src/commands/suggest-pairs.ts | 101 ++++++++++++++++++++--------- src/lib/combo-history.ts | 71 +++----------------- src/lib/pair-suggestions.test.ts | 48 ++++++++++++++ src/lib/pair-suggestions.ts | 13 ++++ src/lib/repo-scope.ts | 81 +++++++++++++++++++++++ 7 files changed, 312 insertions(+), 95 deletions(-) create mode 100644 src/lib/repo-scope.ts diff --git a/src/commands/launch.ts b/src/commands/launch.ts index 0eef3209..3951cd6c 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -1555,11 +1555,17 @@ export async function run(args: string[]): Promise { try { const { computeAffinityMap, suggestionsByProfile } = await import("../lib/pair-suggestions"); affinity = computeAffinityMap(); + // Partners are scoped to this repository: `growStack` grafts one onto + // every suggested stack with no reason line of its own, so a pairing + // mined from an unrelated project would silently ride along. Cross-repo + // habits still surface — as `universalSuggestions` below, which say where + // they came from — and in `cue suggest-pairs`. + const localAffinity = computeAffinityMap(undefined, { cwd }); // Surface a partner after a *single* prior combo: these now render // unchecked + hinted ("you paired these before"), so a low bar is a gentle // recommendation, not an auto-pin. (The stricter defaults still apply to // `cue suggest-pairs`, which reports rather than pre-fills.) - const sug = suggestionsByProfile(affinity, { minCount: 1, minAffinity: 0, limit: 6 }); + const sug = suggestionsByProfile(localAffinity, { minCount: 1, minAffinity: 0, limit: 6 }); pairSuggestions = new Map(); for (const [name, partners] of sug) { pairSuggestions.set(name, partners.map((p) => p.name)); diff --git a/src/commands/suggest-pairs.test.ts b/src/commands/suggest-pairs.test.ts index 6ecc26ad..99dbba60 100644 --- a/src/commands/suggest-pairs.test.ts +++ b/src/commands/suggest-pairs.test.ts @@ -7,7 +7,90 @@ import { describe, test, expect, spyOn, afterEach } from "bun:test"; -import { parseArgs, run } from "./suggest-pairs"; +import { buildRows, renderTable, parseArgs, run } from "./suggest-pairs"; +import { computeAffinityMap } from "../lib/pair-suggestions"; + +// --------------------------------------------------------------------------- +// buildRows — the here/elsewhere split, pure +// --------------------------------------------------------------------------- + +describe("buildRows", () => { + const at = (profile: string, cwd: string): string => + JSON.stringify({ ts: "2026-07-01T00:00:00Z", profile, cwd }); + const fakeRepoRootOf = (dir: string): string | undefined => { + const m = /^(\/home\/u\/[^/]+)(\/|$)/.exec(dir); + return m ? m[1] : undefined; + }; + const maps = (...rows: string[]) => { + const read = () => rows; + return { + here: computeAffinityMap(read, { cwd: "/home/u/api", repoRootOf: fakeRepoRootOf }), + global: computeAffinityMap(read), + }; + }; + const opts = { minCount: 1, minAffinity: 0, limit: 5 }; + + // Affinity is symmetric (see pair-suggestions: "pairwise co-occurrence in + // both directions"), so one `a+b` pick reports under both `a` and `b`. + test("a pairing made in this repo lands under `here`, with nothing left over", () => { + const { here, global } = maps(at("a+b", "/home/u/api"), at("a+b", "/home/u/api")); + const rows = buildRows(here, global, opts, null); + expect(rows.map((r) => `${r.profile}:${r.scope}`)).toEqual(["a:here", "b:here"]); + expect(rows[0]?.partners.map((p) => p.name)).toEqual(["b"]); + }); + + test("a pairing made only in other repos is reported as `elsewhere`", () => { + const { here, global } = maps(at("a+c", "/home/u/other")); + const rows = buildRows(here, global, opts, null); + expect(rows.map((r) => `${r.profile}:${r.scope}`)).toEqual(["a:elsewhere", "c:elsewhere"]); + expect(rows[0]?.partners.map((p) => p.name)).toEqual(["c"]); + }); + + test("local rows sort ahead of foreign ones, and a partner is not double-listed", () => { + const { here, global } = maps( + at("a+b", "/home/u/api"), + at("a+c", "/home/u/other"), + at("z+y", "/home/u/other"), + ); + const rows = buildRows(here, global, opts, null); + expect(rows.map((r) => `${r.profile}:${r.scope}`)).toEqual([ + "a:here", + "b:here", + "a:elsewhere", + "c:elsewhere", + "y:elsewhere", + "z:elsewhere", + ]); + expect(rows[0]?.partners.map((p) => p.name)).toEqual(["b"]); + // `a` paired with b here and c elsewhere — only c survives the elsewhere row. + const foreignA = rows.find((r) => r.profile === "a" && r.scope === "elsewhere"); + expect(foreignA?.partners.map((p) => p.name)).toEqual(["c"]); + // `b`'s only partner is `a`, already shown under `here` → no elsewhere row. + expect(rows.some((r) => r.profile === "b" && r.scope === "elsewhere")).toBe(false); + }); + + test("--profile narrows both sections to that profile", () => { + const { here, global } = maps(at("a+b", "/home/u/api"), at("z+y", "/home/u/other")); + expect(buildRows(here, global, opts, "z").map((r) => r.profile)).toEqual(["z"]); + }); +}); + +describe("renderTable", () => { + const partners = [{ name: "b", count: 2, affinity: 1 }]; + + test("labels each section so a foreign pairing is never mistaken for a local one", () => { + const out = renderTable([ + { profile: "a", partners, scope: "here" }, + { profile: "z", partners, scope: "elsewhere" }, + ]); + expect(out).toContain("in this repository"); + expect(out).toContain("in your other projects"); + }); + + test("with no history at all, explains how the table fills in", () => { + expect(renderTable([])).toContain("No pair suggestions yet."); + }); +}); // --------------------------------------------------------------------------- // parseArgs — pure function, exhaustive coverage diff --git a/src/commands/suggest-pairs.ts b/src/commands/suggest-pairs.ts index 3a3404c2..2f8a31e3 100644 --- a/src/commands/suggest-pairs.ts +++ b/src/commands/suggest-pairs.ts @@ -16,8 +16,21 @@ import { suggestionsByProfile, suggestPartnersFor, type PartnerSuggestion, + type ProfileAffinity, + type SuggestPartnersOptions, } from "../lib/pair-suggestions"; +/** + * One rendered line-group: a profile, its partners, and whether that pairing + * was learned in the current repository or somewhere else. The picker only + * acts on `here` rows, so the split is what makes the table match behavior. + */ +export interface PairRow { + profile: string; + partners: PartnerSuggestion[]; + scope: "here" | "elsewhere"; +} + interface ParsedArgs { profile: string | null; minCount: number; @@ -54,9 +67,40 @@ function pct(x: number): string { return `${Math.round(x * 100)}%`; } -function renderTable( - rows: ReadonlyArray<{ profile: string; partners: PartnerSuggestion[] }>, -): string { +/** + * Split pair history into what was learned in this repository and what was + * learned elsewhere. + * + * A partner already shown under `here` is dropped from that profile's + * `elsewhere` list — repeating it would imply two separate pieces of evidence + * when there's one. Local rows sort first, since those are the only ones the + * picker acts on. + */ +export function buildRows( + here: Map, + global: Map, + opts: SuggestPartnersOptions, + profile: string | null, +): PairRow[] { + const only = (map: Map): Map => { + if (!profile) return suggestionsByProfile(map, opts); + const partners = suggestPartnersFor(profile, map, opts); + return partners.length > 0 ? new Map([[profile, partners]]) : new Map(); + }; + const localByProfile = only(here); + const rows: PairRow[] = []; + for (const [name, partners] of [...localByProfile].sort((a, b) => a[0].localeCompare(b[0]))) { + rows.push({ profile: name, partners, scope: "here" }); + } + for (const [name, partners] of [...only(global)].sort((a, b) => a[0].localeCompare(b[0]))) { + const known = new Set((localByProfile.get(name) ?? []).map((p) => p.name)); + const rest = partners.filter((p) => !known.has(p.name)); + if (rest.length > 0) rows.push({ profile: name, partners: rest, scope: "elsewhere" }); + } + return rows; +} + +export function renderTable(rows: ReadonlyArray): string { if (rows.length === 0) { return [ "No pair suggestions yet.", @@ -67,18 +111,24 @@ function renderTable( ].join("\n"); } const lines: string[] = []; - lines.push("Pair suggestions from your local session history:"); - lines.push(""); - for (const r of rows) { - lines.push(` ${r.profile}`); - for (const p of r.partners) { - const a = pct(p.affinity); - lines.push(` + ${p.name.padEnd(28)} ${a.padStart(4)} (${p.count}× together)`); - } + const section = (scope: PairRow["scope"], heading: string): void => { + const group = rows.filter((r) => r.scope === scope); + if (group.length === 0) return; + lines.push(heading); lines.push(""); - } - lines.push("Picker behavior: when you pick a profile in this list, its top"); - lines.push("partners are pre-checked in the combine multiselect."); + for (const r of group) { + lines.push(` ${r.profile}`); + for (const p of r.partners) { + const a = pct(p.affinity); + lines.push(` + ${p.name.padEnd(28)} ${a.padStart(4)} (${p.count}× together)`); + } + lines.push(""); + } + }; + section("here", "Pairs you made in this repository:"); + section("elsewhere", "Pairs you made in your other projects:"); + lines.push("Picker behavior: only the pairings from this repository are"); + lines.push("pre-checked in the combine multiselect."); return lines.join("\n"); } @@ -108,22 +158,13 @@ export async function run(argv: string[]): Promise { limit: args.limit, }; - const affinity = computeAffinityMap(); - - if (args.profile) { - const partners = suggestPartnersFor(args.profile, affinity, opts); - if (args.json) { - process.stdout.write(JSON.stringify({ profile: args.profile, partners }, null, 2) + "\n"); - return 0; - } - process.stdout.write(renderTable([{ profile: args.profile, partners }]) + "\n"); - return 0; - } - - const sug = suggestionsByProfile(affinity, opts); - const rows = [...sug.entries()] - .map(([profile, partners]) => ({ profile, partners })) - .sort((a, b) => a.profile.localeCompare(b.profile)); + // Same two views the picker sees: what this repo taught cue, and everything. + const rows = buildRows( + computeAffinityMap(undefined, { cwd: process.cwd() }), + computeAffinityMap(), + opts, + args.profile, + ); if (args.json) { process.stdout.write(JSON.stringify(rows, null, 2) + "\n"); diff --git a/src/lib/combo-history.ts b/src/lib/combo-history.ts index 8d077a24..ad3a6d6a 100644 --- a/src/lib/combo-history.ts +++ b/src/lib/combo-history.ts @@ -16,6 +16,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { repoScopeMatcher, type RepoScopeOptions } from "./repo-scope"; /** Resolved path mirrors `pair-suggestions.sessionLogPath` (same config dir). */ export function comboHistoryPath(): string { @@ -101,57 +102,8 @@ export interface ComboUsage { here?: number; } -export interface ReadCombosOptions { - /** - * Scope counts to a directory. A row counts toward `here` when it was - * recorded in the same *repository* — so a launch deep inside `packages/api` - * still sees the stack you confirmed at the repo root, and vice versa. Rows - * recorded at or beneath `cwd` also count, which covers directories that - * aren't in a repo at all. - * - * A sibling that merely shares a name prefix (`api-legacy` vs `api`) never - * matches: comparison is by resolved root or `/`-delimited descent, not raw - * string prefix. - */ - cwd?: string; - /** - * Resolve a directory to its repository root, or `undefined` when it isn't in - * one. Injectable so tests stay off the real filesystem. - */ - repoRootOf?: (dir: string) => string | undefined; -} - -/** True when `candidate` is `base` or lives beneath it. */ -function isWithin(candidate: string, base: string): boolean { - return candidate === base || candidate.startsWith(`${base}/`); -} - -/** - * Nearest ancestor directory containing `.git` (a directory for a normal - * clone, a file for a worktree or submodule). `undefined` when the path isn't - * inside a repository — callers then fall back to plain directory scoping. - */ -export function findRepoRoot(dir: string): string | undefined { - let current = dir; - // `dirname("/") === "/"`, so this terminates at the filesystem root. - for (;;) { - if (existsSync(join(current, ".git"))) return current; - const parent = dirname(current); - if (parent === current) return undefined; - current = parent; - } -} - -/** Memoize root lookups for one read — the log repeats the same directories. */ -function cachedRootResolver( - resolve: (dir: string) => string | undefined, -): (dir: string) => string | undefined { - const cache = new Map(); - return (dir) => { - if (!cache.has(dir)) cache.set(dir, resolve(dir)); - return cache.get(dir); - }; -} +/** Scope `here` counts to one repository. See `lib/repo-scope`. */ +export type ReadCombosOptions = RepoScopeOptions; /** * Aggregate the combo log into distinct stacks with a use count and the most @@ -167,10 +119,8 @@ export function readCombos( path: string = comboHistoryPath(), opts: ReadCombosOptions = {}, ): ComboUsage[] { - const scope = opts.cwd; - const rootOf = cachedRootResolver(opts.repoRootOf ?? findRepoRoot); - // Resolved once: every row is compared against this repo, not this directory. - const scopeRoot = scope === undefined ? undefined : rootOf(scope); + // undefined when the caller asked for no scoping — `here` then stays absent. + const isInScope = repoScopeMatcher(opts); const byProfile = new Map(); for (const line of readComboHistoryLines(path)) { const trimmed = line.trim(); @@ -186,14 +136,9 @@ export function readCombos( if (parts.length < 2) continue; const existing = byProfile.get(selector); const ts = typeof record.ts === "string" ? record.ts : undefined; - // An unattributed (pre-cwd) row is never claimed for the current directory. + // An unattributed (pre-cwd) row is never claimed for the current repo. const rowCwd = typeof record.cwd === "string" ? record.cwd : undefined; - const isHere = - scope !== undefined && - rowCwd !== undefined && - // Same repository (covers sibling subdirectories in either direction), - // or — for paths outside any repo — recorded at or under the scope. - ((scopeRoot !== undefined && rootOf(rowCwd) === scopeRoot) || isWithin(rowCwd, scope)); + const isHere = isInScope?.(rowCwd) ?? false; if (existing) { existing.count += 1; if (isHere) existing.here = (existing.here ?? 0) + 1; @@ -203,7 +148,7 @@ export function readCombos( parts, count: 1, lastUsed: ts, - ...(scope === undefined ? {} : { here: isHere ? 1 : 0 }), + ...(isInScope === undefined ? {} : { here: isHere ? 1 : 0 }), }); } } diff --git a/src/lib/pair-suggestions.test.ts b/src/lib/pair-suggestions.test.ts index b1ecfba3..73dd7a61 100644 --- a/src/lib/pair-suggestions.test.ts +++ b/src/lib/pair-suggestions.test.ts @@ -23,6 +23,54 @@ describe("parseComposite", () => { }); }); +describe("computeAffinityMap — per-repo scoping", () => { + /** A history row attributed to a directory, as both log sources write it. */ + const at = (profile: string, cwd: string, ts = "2026-05-28T00:00:00Z"): string => + JSON.stringify({ ts, profile, cwd, session_id: `${ts}-${profile}-${cwd}` }); + /** Pretend every path under /home/u/ belongs to repo /home/u/. */ + const fakeRepoRootOf = (dir: string): string | undefined => { + const m = /^(\/home\/u\/[^/]+)(\/|$)/.exec(dir); + return m ? m[1] : undefined; + }; + const scope = (cwd: string) => ({ cwd, repoRootOf: fakeRepoRootOf }); + + test("folds in only the rows recorded in the scoped repository", () => { + const reader = lines( + at("a+b", "/home/u/api"), + at("a+b", "/home/u/api/packages/core"), + at("a+c", "/home/u/other"), + ); + const m = computeAffinityMap(reader, scope("/home/u/api")); + expect(m.get("a")?.picks).toBe(2); + expect(m.get("a")?.partners.get("b")).toBe(2); + expect(m.get("c")).toBeUndefined(); + }); + + test("a partner paired only in another repo yields no suggestion here", () => { + const reader = lines(at("a+c", "/home/u/other"), at("a+c", "/home/u/other")); + const local = suggestionsByProfile(computeAffinityMap(reader, scope("/home/u/api")), { + minCount: 1, + minAffinity: 0, + }); + expect(local.size).toBe(0); + // …while the unscoped map still knows about the pairing. + const global = suggestionsByProfile(computeAffinityMap(reader), { minCount: 1, minAffinity: 0 }); + expect(global.get("a")?.map((p) => p.name)).toEqual(["c"]); + }); + + test("unattributed rows are excluded when scoping, not guessed at", () => { + const reader = lines(JSON.stringify({ ts: "t", profile: "a+b" }), at("a+b", "/home/u/api")); + expect(computeAffinityMap(reader, scope("/home/u/api")).get("a")?.picks).toBe(1); + }); + + test("without a scope every row counts, exactly as before", () => { + const reader = lines(at("a+b", "/home/u/api"), at("a+c", "/home/u/other")); + const m = computeAffinityMap(reader); + expect(m.get("a")?.picks).toBe(2); + expect(m.get("a")?.partners.get("c")).toBe(1); + }); +}); + describe("computeAffinityMap", () => { test("ignores rows without a profile field", () => { const reader = lines( diff --git a/src/lib/pair-suggestions.ts b/src/lib/pair-suggestions.ts index 79ab0b8e..08032ef7 100644 --- a/src/lib/pair-suggestions.ts +++ b/src/lib/pair-suggestions.ts @@ -14,6 +14,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { readComboHistoryLines } from "./combo-history"; +import { repoScopeMatcher, type RepoScopeOptions } from "./repo-scope"; /** Resolved path mirrors `lib/telemetry-consent` to avoid a circular import. */ function sessionLogPath(): string { @@ -25,6 +26,8 @@ function sessionLogPath(): string { interface SessionLogRow { ts?: string; profile?: string; + /** Directory the session/combo was recorded in. Absent on older rows. */ + cwd?: string; } /** @@ -84,11 +87,18 @@ export function parseComposite(selector: string): string[] { * pick contributes one increment to each constituent profile's `picks` and * one to every ordered pair's partner count. * + * With `scope.cwd` set, only rows recorded in that repository are folded in — + * so "you usually pair X with Y" means *here*, not "somewhere on this machine". + * A partner grafted onto a suggested stack has to have earned it in the project + * you're actually opening. Unscoped callers see every row, as before. + * * `readLines` is exposed for tests so we don't have to write a tempfile. */ export function computeAffinityMap( readLines: () => string[] = defaultReadLines, + scope: RepoScopeOptions = {}, ): Map { + const isInScope = repoScopeMatcher(scope); const map = new Map(); for (const line of readLines()) { if (!line.trim()) continue; @@ -99,6 +109,9 @@ export function computeAffinityMap( continue; // malformed JSONL line — skip } if (!row.profile) continue; + // Unattributed rows drop out under a scope rather than being credited to + // whichever repo happens to be open. + if (isInScope && !isInScope(row.cwd)) continue; const parts = parseComposite(row.profile); if (parts.length === 0) continue; // Increment own picks for every part. diff --git a/src/lib/repo-scope.ts b/src/lib/repo-scope.ts new file mode 100644 index 00000000..7adcf5df --- /dev/null +++ b/src/lib/repo-scope.ts @@ -0,0 +1,81 @@ +/** + * Repo scoping — "was this history row recorded in the repository I'm standing + * in?" + * + * Both suggestion sources store the raw cwd of a launch: combo history writes + * it per confirmed stack, the session log per session. Suggestions are + * per-repo, so both need the same answer to that question — keeping it in one + * place is what stops `combo-history` and `pair-suggestions` from drifting into + * two subtly different notions of "here". + * + * Not to be confused with `lib/repo-root`, which locates the *cue install*. + */ + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Resolve a directory to its repository root, or `undefined` if it's not in one. */ +export type RepoRootResolver = (dir: string) => string | undefined; + +/** Options accepted by every per-repo history reader. */ +export interface RepoScopeOptions { + /** + * Scope to this directory. Rows recorded anywhere in the same repository + * match — so a launch deep in `packages/api` still sees what was confirmed at + * the repo root, and vice versa. For paths outside any repository, rows + * recorded at or beneath `cwd` match instead. + */ + cwd?: string; + /** Root resolution, injectable so tests stay off the real filesystem. */ + repoRootOf?: RepoRootResolver; +} + +/** + * Nearest ancestor directory containing `.git` — a directory for a normal + * clone, a file for a worktree or submodule. `undefined` when the path isn't + * inside a repository. + */ +export function findRepoRoot(dir: string): string | undefined { + let current = dir; + // `dirname("/") === "/"`, so this terminates at the filesystem root. + for (;;) { + if (existsSync(join(current, ".git"))) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +/** True when `candidate` is `base` or lives beneath it. Never matches a sibling + * that merely shares a name prefix (`api-legacy` vs `api`). */ +export function isWithin(candidate: string, base: string): boolean { + return candidate === base || candidate.startsWith(`${base}/`); +} + +/** + * Build the "does this row belong here?" predicate for a scope, or `undefined` + * when no scope was requested (the caller should then count every row). + * + * A row with no recorded directory never matches: history written before cwd + * was tracked is genuinely unattributable, and guessing would hand one repo's + * habits to another. Root lookups are memoized for the life of the predicate, + * since a log repeats the same handful of directories. + */ +export function repoScopeMatcher( + opts: RepoScopeOptions, +): ((rowCwd: string | undefined) => boolean) | undefined { + const scope = opts.cwd; + if (scope === undefined) return undefined; + const resolve = opts.repoRootOf ?? findRepoRoot; + const cache = new Map(); + const rootOf = (dir: string): string | undefined => { + if (!cache.has(dir)) cache.set(dir, resolve(dir)); + return cache.get(dir); + }; + const scopeRoot = rootOf(scope); + return (rowCwd) => { + if (rowCwd === undefined) return false; + if (scopeRoot !== undefined && rootOf(rowCwd) === scopeRoot) return true; + return isWithin(rowCwd, scope); + }; +} From c157281ce3dff7274874b7780e2e2c22a6fc30b2 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 09:58:59 +0200 Subject: [PATCH 09/33] feat(picker): scope Recent by repository, not by path prefix Recent was the last suggestion source still scoping by raw downward path prefix, which the previous two rounds turned into an inconsistency: launch in packages/core and cue would show you the stacks and pairings confirmed at the repo root, but none of the sessions. The prefix rule cannot match a parent path, so scoped Recent came back empty and launch silently fell back to the global list - handing that subdirectory the profiles you use in other projects. computeStats now takes the same repo scope as the other two readers, so all three agree on what "here" means. The reason line becomes "last used in this repo", which is what it now measures. This path had no test coverage at all; it now has five, including the subdirectory case that was broken and the outside-a-repository fallback. --- src/commands/launch.ts | 2 +- src/lib/analytics.test.ts | 60 ++++++++++++++++++++++++++++++++++- src/lib/analytics.ts | 22 ++++++------- src/lib/stack-suggest.test.ts | 2 +- src/lib/stack-suggest.ts | 2 +- 5 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/commands/launch.ts b/src/commands/launch.ts index 3951cd6c..182083a1 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -1141,7 +1141,7 @@ async function listProfileOptions(pinnedProfile?: string): Promise { try { rmSync(scratch, { recursive: true, force: true }); } catch { /* ignore */ } }); +describe("computeStats — per-repo scoping", () => { + /** Pretend every path under /home/u/ belongs to repo /home/u/. */ + const fakeRepoRootOf = (dir: string): string | undefined => { + const m = /^(\/home\/u\/[^/]+)(\/|$)/.exec(dir); + return m ? m[1] : undefined; + }; + const start = (profile: string, cwd?: string) => ({ + ts: "2026-06-01T00:00:00.000Z", + event: "start", + profile, + ...(cwd === undefined ? {} : { cwd }), + }); + const sessions = (rows: ReturnType, profile: string): number => + rows.find((r) => r.profile === profile)?.sessions ?? 0; + + test("a session from the repo root counts when launching in a subdirectory", () => { + writeAnalytics([start("rust", "/home/u/api"), start("shop", "/home/u/other")]); + writeSessionLog([]); + const out = computeStats({ cwd: "/home/u/api/packages/core", repoRootOf: fakeRepoRootOf }); + expect(sessions(out, "rust")).toBe(1); + expect(sessions(out, "shop")).toBe(0); + }); + + test("the Stop-hook session log is scoped the same way", () => { + writeAnalytics([]); + writeSessionLog([ + { ts: "2026-06-01T00:00:00.000Z", cwd: "/home/u/api/src", profile: "rust", session_id: "A" }, + { ts: "2026-06-01T00:00:00.000Z", cwd: "/home/u/other", profile: "shop", session_id: "B" }, + ]); + const out = computeStats({ cwd: "/home/u/api", repoRootOf: fakeRepoRootOf }); + expect(sessions(out, "rust")).toBe(1); + expect(sessions(out, "shop")).toBe(0); + }); + + test("a session with no recorded directory is excluded under a scope", () => { + writeAnalytics([start("rust")]); + writeSessionLog([]); + const out = computeStats({ cwd: "/home/u/api", repoRootOf: fakeRepoRootOf }); + expect(sessions(out, "rust")).toBe(0); + }); + + test("without a scope every session counts", () => { + writeAnalytics([start("rust", "/home/u/api"), start("shop", "/home/u/other")]); + writeSessionLog([]); + const out = computeStats({}); + expect(sessions(out, "rust")).toBe(1); + expect(sessions(out, "shop")).toBe(1); + }); + + test("outside any repository, scoping falls back to the directory subtree", () => { + writeAnalytics([start("notes", "/scratch/nb/sub"), start("other", "/scratch/elsewhere")]); + writeSessionLog([]); + const out = computeStats({ cwd: "/scratch/nb", repoRootOf: () => undefined }); + expect(sessions(out, "notes")).toBe(1); + expect(sessions(out, "other")).toBe(0); + }); +}); + describe("computeDailyActivity", () => { test("returns exactly `days` contiguous UTC buckets ending today", () => { writeAnalytics([]); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index cb7dc68f..7e82c584 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -7,6 +7,7 @@ import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { repoScopeMatcher, type RepoScopeOptions } from "./repo-scope"; import { isEnabled as telemetryEnabled } from "./telemetry-consent"; /** @@ -194,15 +195,15 @@ export interface ProfileStats { last_used: string | null; } -export interface ComputeStatsOptions { +export interface ComputeStatsOptions extends RepoScopeOptions { since?: Date; /** - * When set, only count events whose `cwd` equals this path OR is a - * descendant of it. Lets the picker scope Recent to the current project - * subtree so launches from $HOME (auto-pinned profiles) don't squat in - * the Recent slots of unrelated project directories. + * `cwd` (inherited) scopes Recent to the repository being launched in, so + * launches from $HOME — or from an unrelated project — don't squat in the + * Recent slots here. Repository rather than raw subtree, matching how combo + * history and pair affinity scope: a launch in `packages/core` and one at the + * repo root are the same project, and Recent should say so. */ - cwdPrefix?: string; } /** @@ -214,12 +215,9 @@ export function computeStats(optsOrSince: Date | ComputeStatsOptions = {}): Prof const opts: ComputeStatsOptions = optsOrSince instanceof Date ? { since: optsOrSince } : optsOrSince; - const { since, cwdPrefix } = opts; - const matchesCwd = (cwd?: string): boolean => { - if (!cwdPrefix) return true; - if (!cwd) return false; - return cwd === cwdPrefix || cwd.startsWith(`${cwdPrefix}/`); - }; + const { since } = opts; + // undefined when unscoped — then every event counts, as before. + const matchesCwd = repoScopeMatcher(opts) ?? ((): boolean => true); const events = readEvents(since); const map = new Map }>(); diff --git a/src/lib/stack-suggest.test.ts b/src/lib/stack-suggest.test.ts index 483a09a0..dc551d33 100644 --- a/src/lib/stack-suggest.test.ts +++ b/src/lib/stack-suggest.test.ts @@ -188,7 +188,7 @@ describe("suggestStacks", () => { recents: [{ name: "python", sessions: 2, lastUsed: "2026-07-26T00:00:00Z" }], }); expect(cwdScoped[0]!.score).toBeGreaterThan(global[0]!.score); - expect(cwdScoped[0]?.reasons[0]).toContain("last used in this directory"); + expect(cwdScoped[0]?.reasons[0]).toContain("last used in this repo"); expect(global[0]?.reasons[0]).toContain("you use this often"); }); }); diff --git a/src/lib/stack-suggest.ts b/src/lib/stack-suggest.ts index 1e9f2f76..1d05776a 100644 --- a/src/lib/stack-suggest.ts +++ b/src/lib/stack-suggest.ts @@ -260,7 +260,7 @@ export function suggestStacks(input: SuggestInput): StackSuggestion[] { // 3. recents — most-recent first, so "what I did here last" wins over // "what I've done here most". const recentBase = input.recentsAreCwdScoped ? SCORE_RECENT_CWD : SCORE_RECENT_GLOBAL; - const recentWhy = input.recentsAreCwdScoped ? "last used in this directory" : "you use this often"; + const recentWhy = input.recentsAreCwdScoped ? "last used in this repo" : "you use this often"; for (const r of [...(input.recents ?? [])].sort(byRecency)) { const parts = r.name.split("+").filter((p) => known.has(p)); if (parts.length === 0) continue; From 5eb0ccdc1be81524c3dda2026713baecdb0f6753 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 12:14:00 +0200 Subject: [PATCH 10/33] fix(picker): rank suggested stacks by what you actually launch here Three defects made the card lead with a stack the user had never launched. 1. Saturation. The usage bonus was `min(sessions, 5) * 2`, so everything with five or more sessions scored exactly 50 - a stack launched 112 times in this repo tied with one launched five times, and the card's headline was decided by the alphabetical tie-break. Replaced with a logarithmic bonus: every doubling of use adds a fixed step, calibrated so the five-session case lands where it did before and everything above it keeps climbing. 2. Truncation. A recalled stack was grown and then cut to MAX_STACK_PARTS, so a four-part recent became a three-part stack that was never launched, captioned with the four-part stack's session count. Recollections are now shown as launched: conflicts still resolve, but no companions are bolted on and no parts are dropped. The cap still applies to stacks this module proposes. 3. First-write-wins dedup. Sources are scanned in a fixed order but are no longer ranked by it, so a foreign combo used 4x claimed the part-set and permanently suppressed the recent used 112x here. Dedup now keeps the best-scoring claim; ties keep the incumbent, preserving origin order where scores don't separate. Measured in this repo, top suggestion before and after: before: career+skill-writer+core (truncated from a 4-part stack, 5 sessions) after: core+skill-writer (112 sessions here) --- src/lib/stack-suggest.test.ts | 63 +++++++++++++++++++ src/lib/stack-suggest.ts | 112 ++++++++++++++++++++++++++-------- 2 files changed, 148 insertions(+), 27 deletions(-) diff --git a/src/lib/stack-suggest.test.ts b/src/lib/stack-suggest.test.ts index dc551d33..44c0907e 100644 --- a/src/lib/stack-suggest.test.ts +++ b/src/lib/stack-suggest.test.ts @@ -138,6 +138,69 @@ describe("suggestStacks", () => { expect(foreign?.reasons[0]).toBe("you launched this stack 6× in other directories"); }); + test("a stack launched far more often outranks a rarely-used one", () => { + const out = suggestStacks({ + profiles, + recentsAreCwdScoped: true, + recents: [ + // Alphabetically first and more recent — only usage should decide. + { name: "core+python", sessions: 5, lastUsed: "2026-07-26T00:00:00Z" }, + { name: "rust+secops", sessions: 112, lastUsed: "2026-07-01T00:00:00Z" }, + ], + }); + expect(out[0]?.parts).toEqual(["rust", "secops"]); + expect(out[0]!.score).toBeGreaterThan(out[1]!.score); + }); + + test("a recalled stack is shown whole, never truncated to the display cap", () => { + // Four parts the user actually launched together. Truncating to three would + // put a stack on screen that was never launched, under its session count. + const out = suggestStacks({ + profiles, + recentsAreCwdScoped: true, + recents: [{ name: "core+python+rust+secops", sessions: 5, lastUsed: "2026-07-26T00:00:00Z" }], + }); + expect(out[0]?.parts).toEqual(["core", "python", "rust", "secops"]); + }); + + test("a recalled stack gains no companions — it is a recollection, not a proposal", () => { + const out = suggestStacks({ + profiles, + recentsAreCwdScoped: true, + recents: [{ name: "rust", sessions: 9, lastUsed: "2026-07-26T00:00:00Z" }], + companions: [{ profile: "secops", reason: "audit rules", confidence: 0.95 }], + }); + const recalled = out.find((s) => s.origin === "recent"); + expect(recalled?.parts).toEqual(["rust"]); + }); + + test("combo use keeps ranking past the old saturation point", () => { + const out = suggestStacks({ + profiles, + combos: [ + { parts: ["core", "python"], count: 4, here: 4 }, + { parts: ["rust", "secops"], count: 40, here: 40 }, + ], + }); + expect(out[0]?.parts).toEqual(["rust", "secops"]); + expect(out[0]!.score).toBeGreaterThan(out[1]!.score); + }); + + test("a stronger later source wins the stack, not whichever ran first", () => { + // Same part-set from two sources: a weak foreign combo (scanned first) and + // the stack the user actually launches here. Order of scanning must not + // decide which one the card gets to show. + const out = suggestStacks({ + profiles, + combos: [{ parts: ["rust", "secops"], count: 4, here: 0 }], + recents: [{ name: "rust+secops", sessions: 112, lastUsed: "2026-07-26T00:00:00Z" }], + recentsAreCwdScoped: true, + }); + expect(out).toHaveLength(1); + expect(out[0]?.origin).toBe("recent"); + expect(out[0]?.reasons[0]).toContain("112× sessions"); + }); + test("ignores unknown profile names everywhere", () => { const out = suggestStacks({ profiles, diff --git a/src/lib/stack-suggest.ts b/src/lib/stack-suggest.ts index 1d05776a..2380beb5 100644 --- a/src/lib/stack-suggest.ts +++ b/src/lib/stack-suggest.ts @@ -109,10 +109,33 @@ export interface StackSuggestion { origin: SuggestionOrigin; } -/** Hard cap on how many profiles one suggested stack may contain. Past three - * the card stops reading as an answer and starts reading as a list. */ +/** + * Hard cap on how many profiles a *proposed* stack may contain — one this + * module assembled from a seed plus companions. Past three the card stops + * reading as an answer and starts reading as a list. + * + * Recalled stacks (a recent or a confirmed combo) are exempt: those are a + * literal record of what the user launched, and trimming one to fit would put a + * stack on screen that was never launched, captioned with its session count. + * Showing a four-part line beats showing a lie. + */ export const MAX_STACK_PARTS = 3; +/** + * Score bonus for how much something has been used. + * + * Logarithmic, so every doubling of use adds a fixed step: a stack launched + * 100× always outranks one launched 5×, while a heavy user's history still + * can't swamp what the directory itself says. The previous rule + * (`min(count, N) * k`) saturated almost immediately — at five sessions + * everything tied, and the card's headline was decided by alphabetical + * tie-break rather than by anything the user had done. + */ +export function usageBonus(count: number, step: number, max: number): number { + if (count <= 0) return 0; + return Math.min(max, Math.round(Math.log2(1 + count) * step)); +} + /** Confidence at/above which a detected companion joins a suggested stack. */ export const COMPANION_AUTO_CONFIDENCE = 0.7; @@ -136,6 +159,17 @@ export const SCORE_COMBO_HERE = 55; */ export const SCORE_COMBO_ELSEWHERE = 28; export const SCORE_RECENT_CWD = 40; +/** Usage-bonus shape per origin. Steps are calibrated so the five-session case + * lands where the old saturating rule did — continuity for existing users — + * and everything above it keeps climbing instead of flattening. */ +export const RECENT_CWD_STEP = 4; +export const RECENT_CWD_BONUS_MAX = 30; +export const RECENT_GLOBAL_STEP = 3; +export const RECENT_GLOBAL_BONUS_MAX = 20; +export const COMBO_HERE_STEP = 5; +export const COMBO_HERE_BONUS_MAX = 20; +export const COMBO_ELSEWHERE_STEP = 3; +export const COMBO_ELSEWHERE_BONUS_MAX = 12; export const SCORE_RECENT_GLOBAL = 25; export const SCORE_FEATURED = 15; export const SCORE_DEFAULT = 5; @@ -204,37 +238,50 @@ export function suggestStacks(input: SuggestInput): StackSuggestion[] { const companions = input.companions ?? []; const companionByName = new Map(companions.map((c) => [c.profile, c])); - const out: StackSuggestion[] = []; - const seenKeys = new Set(); - /** - * Grow a primary into a full stack + reasons and record it, unless an equal - * part-set was already recorded by a stronger source. + * One entry per distinct part-set, keeping the best-scoring claim on it. + * + * Keyed rather than appended because sources are scanned in a fixed order but + * are no longer ranked by that order: a foreign combo used 4× is scanned + * before recents, and would otherwise permanently claim the same stack the + * user launches 112× here — suppressing the strongest answer on the card. */ + const byKey = new Map(); + + /** Grow a primary into a full stack + reasons and record it, keeping whichever + * claim on that part-set scores highest. */ const record = ( primaryParts: string[], score: number, origin: SuggestionOrigin, reasons: string[], + /** A literal recollection (recent / combo): show it as launched — no + * companions bolted on, no truncation. Only conflicts are resolved, since + * a stack that can't launch is no use as an answer. */ + recalled = false, ): void => { const seeds = primaryParts.filter((n) => known.has(n)); if (seeds.length === 0) return; - const parts = growStack(seeds, { - known, - conflictMap, - companionByName, - pairSuggestions: input.pairSuggestions, - }); + const parts = recalled + ? resolveConflicts(seeds, conflictMap) + : growStack(seeds, { + known, + conflictMap, + companionByName, + pairSuggestions: input.pairSuggestions, + }); const key = [...parts].sort().join("+"); - if (seenKeys.has(key)) return; - seenKeys.add(key); + // Ties keep the incumbent, which came from the earlier — stronger-ranked — + // source, preserving the origin ordering where scores don't separate. + const prev = byKey.get(key); + if (prev !== undefined && prev.score >= score) return; const extra = parts.filter((p) => !seeds.includes(p)); const withCompanions = [...reasons]; for (const name of extra) { const why = companionByName.get(name)?.reason; withCompanions.push(why ? `+ ${name} (${why})` : `+ ${name}`); } - out.push({ parts, score, origin, reasons: withCompanions.slice(0, 3) }); + byKey.set(key, { parts, score, origin, reasons: withCompanions.slice(0, 3) }); }; // 1. cwd detection — the strongest statement about *this* directory. @@ -254,19 +301,26 @@ export function suggestStacks(input: SuggestInput): StackSuggestion[] { for (const c of [...(input.combos ?? [])].sort(byHereThenCountThenRecency)) { const parts = c.parts.filter((p) => known.has(p)); if (parts.length < 2) continue; - record(parts, comboScore(c), "combo", [comboReason(c)]); + record(parts, comboScore(c), "combo", [comboReason(c)], true); } - // 3. recents — most-recent first, so "what I did here last" wins over - // "what I've done here most". + // 3. recents — how much you use a stack here decides the order; recency only + // breaks ties between equally-used ones, and settles which entry claims a + // part-set when two collapse to the same one. const recentBase = input.recentsAreCwdScoped ? SCORE_RECENT_CWD : SCORE_RECENT_GLOBAL; const recentWhy = input.recentsAreCwdScoped ? "last used in this repo" : "you use this often"; + const recentStep = input.recentsAreCwdScoped ? RECENT_CWD_STEP : RECENT_GLOBAL_STEP; + const recentMax = input.recentsAreCwdScoped ? RECENT_CWD_BONUS_MAX : RECENT_GLOBAL_BONUS_MAX; for (const r of [...(input.recents ?? [])].sort(byRecency)) { const parts = r.name.split("+").filter((p) => known.has(p)); if (parts.length === 0) continue; - record(parts, recentBase + Math.min(r.sessions, 5) * 2, "recent", [ - `${recentWhy} · ${r.sessions}× session${r.sessions === 1 ? "" : "s"}`, - ]); + record( + parts, + recentBase + usageBonus(r.sessions, recentStep, recentMax), + "recent", + [`${recentWhy} · ${r.sessions}× session${r.sessions === 1 ? "" : "s"}`], + true, + ); } // 4. curated featured picks. @@ -292,12 +346,12 @@ export function suggestStacks(input: SuggestInput): StackSuggestion[] { const parts = input.defaultSelector.split("+").filter((p) => known.has(p)); if (parts.length > 0) { record(parts, SCORE_DEFAULT, "default", [ - out.length === 0 ? "no clear signal in this directory" : "your default profile", + byKey.size === 0 ? "no clear signal in this directory" : "your default profile", ]); } } - return out + return [...byKey.values()] .sort( (a, b) => b.score - a.score || @@ -322,9 +376,13 @@ function byHereThenCountThenRecency(a: SuggestCombo, b: SuggestCombo): number { * so a caller that can't scope loses nothing. */ function comboScore(c: SuggestCombo): number { - if (c.here === undefined) return SCORE_COMBO + Math.min(c.count, 4) * 5; - if (c.here > 0) return SCORE_COMBO_HERE + Math.min(c.here, 4) * 5; - return SCORE_COMBO_ELSEWHERE + Math.min(c.count, 4) * 3; + if (c.here === undefined) { + return SCORE_COMBO + usageBonus(c.count, COMBO_HERE_STEP, COMBO_HERE_BONUS_MAX); + } + if (c.here > 0) { + return SCORE_COMBO_HERE + usageBonus(c.here, COMBO_HERE_STEP, COMBO_HERE_BONUS_MAX); + } + return SCORE_COMBO_ELSEWHERE + usageBonus(c.count, COMBO_ELSEWHERE_STEP, COMBO_ELSEWHERE_BONUS_MAX); } /** The one-line "why" shown under a remembered stack. */ From fdd083bfd0f181b00dfb9637a9cc6e8b938e020a Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 13:04:09 +0200 Subject: [PATCH 11/33] fix(suggest): score skills on what the user actually said `cue suggest` recommended a wedding-invitations skill because "date" appeared 195 times, and rated everything at confidence 1.00. Four compounding faults. Counting the wrong text. It scanned raw transcript JSONL, so assistant prose, tool names, tool output and file contents all counted as things the user "mentioned" - "read" scored 798x because that is the Read tool. Only user text parts are read now; tool_result parts arrive under role "user" too and are excluded. Substring matching. `indexOf` found "ops" inside "operations" and "and" inside "command". Matching is now whole-word, via a single tokenizing pass that also replaces a per-keyword regex compiled over megabytes of text - the command went from seconds to ~0.2s end to end. No stopword filter. Every SKILL.md description opens with "Use this when the user asks...", so "use", "when" and "user" became keywords for the entire catalogue. Filtered now, along with transcript-structure words, and the remaining keywords are weighted by catalogue-wide rarity: a stopword list only knows what is common in English, not that "mcp" appears in hundreds of these skills and separates none of them. Meaningless confidence. `min(1, mentions / 50)` reached 1.00 for every skill, so the printed number carried no information and the ranking was arbitrary. Score is now the strongest few signals - summing every match rewarded a long description over a relevant one - on an asymptotic curve calibrated against a real 355-candidate run. Measured confidence spread over this repo's transcripts: p10 0.32, p50 0.54, max 0.72. The reason line is computed from the same frequency map as the score, so it can no longer name a keyword that never contributed - which is how "and" came to be cited 8044 times. --- src/commands/suggest.test.ts | 132 +++++++++++++++++++- src/commands/suggest.ts | 232 +++++++++++++++++++++++++++++++---- 2 files changed, 339 insertions(+), 25 deletions(-) diff --git a/src/commands/suggest.test.ts b/src/commands/suggest.test.ts index 7c2e1e8e..5b936187 100644 --- a/src/commands/suggest.test.ts +++ b/src/commands/suggest.test.ts @@ -13,7 +13,137 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { run } from "./suggest"; +import { + extractUserPrompts, + run, + scoreSkills, + tokenizeText, + type CatalogEntry, +} from "./suggest"; + +// --------------------------------------------------------------------------- +// tokenizeText / scoreSkills — the scoring core, pure +// --------------------------------------------------------------------------- + +describe("tokenizeText", () => { + test("drops function words, which every skill description is full of", () => { + // Every SKILL.md description opens with boilerplate like this; if it turns + // into keywords, the whole catalogue matches every transcript. + const out = tokenizeText("Use this when the user asks you to design a page"); + expect(out).toContain("design"); + expect(out).toContain("page"); + for (const noise of ["use", "this", "when", "the", "user", "you", "asks"]) { + expect(out).not.toContain(noise); + } + }); + + test("drops transcript structure words, which are not something anyone said", () => { + const out = tokenizeText("assistant tool_result content json stripe"); + expect(out).toEqual(["stripe"]); + }); +}); + +describe("extractUserPrompts", () => { + const line = (o: unknown) => JSON.stringify(o); + + test("keeps what the user typed and drops everything else in the transcript", () => { + const chunk = [ + line({ message: { role: "user", content: "please set up kubernetes" } }), + line({ message: { role: "assistant", content: [{ type: "text", text: "wedding invitations" }] } }), + line({ message: { role: "user", content: [{ type: "text", text: "and helm charts" }] } }), + ].join("\n"); + const out = extractUserPrompts(chunk); + expect(out).toContain("kubernetes"); + expect(out).toContain("helm charts"); + expect(out).not.toContain("wedding"); + }); + + test("tool output is not something the user said, despite its user role", () => { + const chunk = line({ + message: { + role: "user", + content: [{ type: "tool_result", content: "read read read date date" }], + }, + }); + expect(extractUserPrompts(chunk)).toBe(""); + }); + + test("a torn final line is skipped, not thrown on", () => { + const chunk = `${line({ message: { role: "user", content: "figma" } })}\n{"message":{"rol`; + expect(extractUserPrompts(chunk)).toBe("figma"); + }); +}); + +describe("scoreSkills", () => { + const entry = (id: string, keywords: string[]): CatalogEntry => ({ id, keywords }); + + test("a keyword only ever seen inside longer words scores nothing", () => { + // "ops" lives inside "operations"/"develops"; substring counting made that + // look like 40 mentions of an ops skill. + const out = scoreSkills( + [entry("ops/deploy", ["ops", "kubernetes"])], + ["operations develops operations develops operations develops"], + ); + expect(out).toEqual([]); + }); + + test("one repeated word is not enough — a skill must match on more than one", () => { + const out = scoreSkills( + [entry("some/skill", ["design", "figma", "typography"])], + [Array(80).fill("design").join(" ")], + ); + expect(out).toEqual([]); + }); + + test("suggests a skill whose vocabulary genuinely shows up", () => { + const out = scoreSkills( + [entry("design/figma", ["figma", "typography", "palette"])], + ["figma figma typography palette figma typography"], + ); + expect(out).toHaveLength(1); + expect(out[0]?.skillId).toBe("design/figma"); + expect(out[0]?.reason).toContain("figma"); + }); + + test("the named keyword is one that actually scored", () => { + // The reason used to be computed by a second, unfiltered pass, so it could + // name a word ("to", "and") that never contributed to the score. + const out = scoreSkills( + [entry("a/b", ["kubernetes", "helm", "and", "the"])], + ["kubernetes helm kubernetes and the and the and the and the and the"], + ); + expect(out[0]?.reason).toContain("kubernetes"); + expect(out[0]?.reason).not.toContain('"and"'); + expect(out[0]?.reason).not.toContain('"the"'); + }); + + test("confidence discriminates instead of pinning everything at 1.00", () => { + const broad = entry("broad/skill", ["figma", "typography", "palette", "kerning"]); + const narrow = entry("narrow/skill", ["figma", "typography", "palette", "kerning"]); + const out = scoreSkills( + [broad, narrow], + ["figma typography palette kerning figma typography palette kerning", "figma typography"], + ); + for (const s of out) expect(s.confidence).toBeLessThanOrEqual(1); + // A single dominant word can never reach certainty on its own. + const thin = scoreSkills( + [entry("thin/skill", ["figma", "typography"])], + [Array(500).fill("figma typography").join(" ")], + ); + expect(thin[0]!.confidence).toBeLessThan(1); + }); + + test("ranks the better-covered skill first", () => { + const out = scoreSkills( + [ + entry("weak/skill", ["figma", "sketch", "invision", "zeplin"]), + entry("strong/skill", ["kubernetes", "helm", "kubectl"]), + ], + ["figma figma sketch kubernetes helm kubectl kubernetes helm kubectl"], + ); + expect(out[0]?.skillId).toBe("strong/skill"); + }); +}); let tmpDir: string; let savedSessionsDir: string | undefined; diff --git a/src/commands/suggest.ts b/src/commands/suggest.ts index 7fa4f1db..403f5e66 100644 --- a/src/commands/suggest.ts +++ b/src/commands/suggest.ts @@ -24,6 +24,77 @@ interface Suggestion { confidence: number; } +/** + * Words that say nothing about what a session was *about*. + * + * Two families, both of which used to dominate every score. English function + * words arrive through skill descriptions — every SKILL.md opens with "Use this + * when the user asks…", so "use"/"when"/"user" became keywords for the entire + * catalogue. Transcript-structure words arrive through the raw JSONL, where + * "assistant", "content" and "tool_result" appear on every single line and are + * something the format said, not something anyone typed. + */ +const STOPWORDS = new Set([ + // function words (only ones longer than 2 chars can survive tokenizing) + "the", "and", "for", "are", "was", "were", "been", "being", "does", "did", + "have", "has", "had", "will", "would", "can", "could", "should", "may", + "might", "must", "this", "that", "these", "those", "its", "they", "them", + "their", "you", "your", "yours", "our", "ours", "not", "yes", "all", "any", + "some", "more", "most", "other", "others", "such", "only", "own", "same", + "than", "too", "very", "just", "now", "when", "where", "which", "who", + "whom", "what", "why", "how", "there", "here", "out", "into", "about", + "after", "before", "between", "during", "through", "each", "few", "both", + "one", "two", "with", "from", "also", "per", "via", "but", "off", "over", + "under", "again", "once", "then", "else", "want", "wants", "wanted", + "need", "needs", "needed", "like", "make", "makes", "made", "give", "gives", + "let", "lets", "ask", "asks", "asked", "say", "says", "said", + // "Use when…" boilerplate — present in essentially every skill description + "use", "uses", "used", "using", "usage", + // transcript structure — the format's own vocabulary, not the user's + "user", "users", "human", "assistant", "content", "message", "messages", "role", "tool", "tools", + "tool_use", "tool_result", "result", "results", "type", "text", "input", + "output", "json", "true", "false", "null", "uuid", "timestamp", "session", + "parent", "http", "https", "www", "com", "org", "net", +]); + +/** A keyword shorter than this is noise even when it isn't a stopword. */ +const MIN_KEYWORD_LENGTH = 3; +/** Total keyword occurrences below which a skill isn't worth surfacing. */ +const MIN_MENTIONS = 3; +/** + * Distinct keywords a skill must match. One repeated word is a coincidence — + * "design" appearing 80× says nothing about a Figma skill specifically. Two or + * more of a skill's own vocabulary is a signal. + */ +const MIN_DISTINCT_KEYWORDS = 2; +/** + * Only a skill's strongest few signals count toward its score. + * + * Summing every matched keyword rewards a long description over a relevant + * one: a skill whose blurb happens to contain forty ordinary words outscores a + * sharply-matching skill with a terse one. Scoring the best few makes the + * measure "how strong is the evidence", not "how much prose did the author + * write". + */ +const TOP_SIGNAL_KEYWORDS = 5; +/** + * Ceiling on the per-keyword frequency term (≈63 occurrences). Beyond that, + * repetition says the word is part of the furniture of these sessions, not that + * the need is sixty times stronger. + */ +const FREQUENCY_LOG_CAP = 6; +/** + * Weighted score at which confidence reaches ~63%. + * + * Calibrated against a real run over this repo's transcripts, where the score + * distribution across 355 candidates ran p10≈30, p50≈67, p90≈88 — so this puts + * a median candidate near 0.5 and the strongest near 0.65. Confidence + * approaches 1 asymptotically and never arrives, which is the honest shape for + * this measure: counting words in transcripts is a hint, and the old formula's + * flat 1.00 for every skill claimed a certainty it could not have. + */ +const CONFIDENCE_SCALE = 100; + export async function run(args: string[]): Promise { if (args.includes("-h") || args.includes("--help")) { process.stdout.write(`cue suggest — skill recommendations based on session analysis @@ -94,7 +165,7 @@ Options: return 0; } -interface CatalogEntry { +export interface CatalogEntry { id: string; keywords: string[]; } @@ -114,10 +185,14 @@ function buildCatalog(skillIds: string[]): CatalogEntry[] { const keywords: string[] = []; if (descMatch) keywords.push(...tokenizeText(descMatch[1]!)); - if (tagsMatch) keywords.push(...tagsMatch[1]!.split(",").map(t => t.trim().toLowerCase())); + // Tags and id segments go through the same tokenizer as prose, so a + // multi-word tag ("web design") becomes matchable words rather than a + // phrase nothing will ever equal, and id filler ("to" in image-to-code) + // is dropped instead of becoming a keyword. + if (tagsMatch) keywords.push(...tokenizeText(tagsMatch[1]!.replace(/,/g, " "))); if (nameMatch) keywords.push(...tokenizeText(nameMatch[1]!)); // Add the slug parts - keywords.push(...id.split("/").flatMap(p => p.split("-"))); + keywords.push(...tokenizeText(id.replace(/\//g, " "))); entries.push({ id, keywords: [...new Set(keywords)] }); } catch {} @@ -125,8 +200,35 @@ function buildCatalog(skillIds: string[]): CatalogEntry[] { return entries; } -function tokenizeText(text: string): string[] { - return text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter(t => t.length > 2); +/** + * Split prose into scoring words: lowercased, punctuation-stripped, short and + * meaningless words removed. Used for both sides of the comparison — skill + * vocabulary and session text — so the two can be matched as whole words. + */ +export function tokenizeText(text: string): string[] { + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .split(/[\s-]+/) + .filter((t) => t.length >= MIN_KEYWORD_LENGTH && !STOPWORDS.has(t)); +} + +/** + * Count whole-word occurrences across the scanned transcripts, once. + * + * The previous implementation scanned the joined text with `indexOf` per + * keyword, which both counted substrings — "ops" inside "operations", "and" + * inside "command" — and re-walked megabytes of text for every keyword of every + * catalogue entry. One tokenizing pass is correct *and* cheaper. + */ +function wordFrequencies(chunks: string[]): Map { + const freq = new Map(); + for (const chunk of chunks) { + for (const word of tokenizeText(chunk)) { + freq.set(word, (freq.get(word) ?? 0) + 1); + } + } + return freq; } // Bound the session scan so `cue suggest` stays fast regardless of how large @@ -140,6 +242,45 @@ const MAX_SESSION_FILES = 100; const MAX_SESSION_BYTES = 2_000_000; const PER_FILE_BYTES = 100_000; +/** + * Pull just the user's own words out of a transcript chunk. + * + * A `.jsonl` transcript is mostly *not* the user: assistant prose, tool names, + * tool output and file contents dwarf it. Counting the raw bytes is what made + * `cue suggest` recommend a wedding-invitations skill because "date" appeared + * 195×, and a robotics skill because "read" — the Read tool — appeared 798×. + * None of that is a topic anyone raised. + * + * Tool results arrive under `role: "user"` too, as `tool_result` parts; only + * `text` parts are the human talking. Unparseable lines are skipped: the reader + * takes a byte-bounded prefix of each file, so the last line is usually torn. + */ +export function extractUserPrompts(chunk: string): string { + const said: string[] = []; + for (const line of chunk.split("\n")) { + if (line.length === 0) continue; + let row: { message?: { role?: string; content?: unknown } }; + try { + row = JSON.parse(line) as typeof row; + } catch { + continue; // truncated or non-JSON line + } + const message = row.message; + if (message?.role !== "user") continue; + const content = message.content; + if (typeof content === "string") { + said.push(content); + continue; + } + if (!Array.isArray(content)) continue; + for (const part of content) { + const p = part as { type?: string; text?: unknown }; + if (p?.type === "text" && typeof p.text === "string") said.push(p.text); + } + } + return said.join("\n"); +} + function scanSessions(cutoffMs: number): string[] { const projectsDir = process.env.CUE_SUGGEST_SESSIONS_DIR ?? join(homedir(), ".claude", "projects"); if (!existsSync(projectsDir)) return []; @@ -177,41 +318,84 @@ function scanSessions(cutoffMs: number): string[] { const buf = Buffer.alloc(PER_FILE_BYTES); const n = fs.readSync(fd, buf, 0, PER_FILE_BYTES, 0); fs.closeSync(fd); - chunks.push(buf.toString("utf8", 0, n)); + const said = extractUserPrompts(buf.toString("utf8", 0, n)); + if (said.length > 0) chunks.push(said); total += n; } catch {} } return chunks; } -function scoreSkills(catalog: CatalogEntry[], sessionChunks: string[]): Suggestion[] { - const combined = sessionChunks.join(" ").toLowerCase(); +/** A skill's scoring vocabulary: deduped, stopword-free, long enough to mean + * something. Shared by the IDF pass and the scoring pass so both see the same + * words. */ +function scoringKeywords(entry: CatalogEntry): string[] { + return [...new Set(entry.keywords)].filter( + (kw) => kw.length >= MIN_KEYWORD_LENGTH && !STOPWORDS.has(kw), + ); +} + +/** + * How much each keyword distinguishes one skill from the rest of the catalogue. + * + * A stopword list only removes words that are common in *English*. It can't + * know that "mcp", "skill" or "profile" appear in hundreds of these particular + * skills and therefore separate none of them — while "kubectl" appears in two + * and separates them sharply. Weighting by catalogue-wide rarity is what stops + * the busiest word in the transcripts from deciding every recommendation. + */ +function inverseDocFrequency(catalog: CatalogEntry[]): Map { + const docs = new Map(); + for (const entry of catalog) { + for (const kw of scoringKeywords(entry)) docs.set(kw, (docs.get(kw) ?? 0) + 1); + } + const total = Math.max(1, catalog.length); + const idf = new Map(); + for (const [kw, n] of docs) idf.set(kw, Math.log(1 + total / (1 + n))); + return idf; +} + +/** + * Rank uninstalled skills by how strongly the scanned transcripts point at + * them. + * + * A keyword contributes `rarity × log(times seen)`: repetition counts, but with + * diminishing returns, and a word that half the catalogue shares counts for + * little however often it appears. The old measure — `min(1, mentions / 50)` + * over substring hits — reached 1.00 for essentially every skill, so the + * ranking carried no information and the printed confidence was decorative. + */ +export function scoreSkills(catalog: CatalogEntry[], sessionChunks: string[]): Suggestion[] { + const freq = wordFrequencies(sessionChunks); + const idf = inverseDocFrequency(catalog); const suggestions: Suggestion[] = []; for (const entry of catalog) { let mentions = 0; - for (const kw of entry.keywords) { - if (kw.length < 3) continue; - // Count occurrences in session content - let idx = 0; - while ((idx = combined.indexOf(kw, idx)) !== -1) { - mentions++; - idx += kw.length; - } + const hits: Array<{ kw: string; count: number; weight: number }> = []; + for (const kw of scoringKeywords(entry)) { + const count = freq.get(kw) ?? 0; + if (count === 0) continue; + const weight = + (idf.get(kw) ?? 0) * Math.min(Math.log2(1 + count), FREQUENCY_LOG_CAP); + mentions += count; + hits.push({ kw, count, weight }); } - if (mentions < 3) continue; + const matched = hits.length; + if (matched < MIN_DISTINCT_KEYWORDS || mentions < MIN_MENTIONS) continue; - const confidence = Math.min(1, mentions / 50); - const topKeyword = entry.keywords.reduce((best, kw) => { - const count = (combined.match(new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? []).length; - return count > best.count ? { kw, count } : best; - }, { kw: "", count: 0 }); + hits.sort((a, b) => b.weight - a.weight || b.count - a.count); + const signals = hits.slice(0, TOP_SIGNAL_KEYWORDS); + const score = signals.reduce((sum, h) => sum + h.weight, 0); + const best = signals[0]!; suggestions.push({ skillId: entry.id, - reason: `you mentioned "${topKeyword.kw}" ${topKeyword.count} times`, + // Names the keyword that contributed most to the score, counted from the + // same frequency map — so the stated reason and the ranking can't disagree. + reason: `${matched} of its keywords in your sessions — "${best.kw}" ${best.count}×`, mentions, - confidence, + confidence: 1 - Math.exp(-score / CONFIDENCE_SCALE), }); } From ae59ebf3045742bc473256016df0195b1f57d63a Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 27 Jul 2026 19:04:19 +0200 Subject: [PATCH 12/33] fix(auth): keep concurrent sessions from revoking each other's tokens Anthropic's OAuth rotates the refresh token on every refresh, and cue gives each profile runtime its own copy of .credentials.json. Two sessions on different profiles therefore hold two copies of one token: whichever refreshes first silently revokes the other, which then hits a login prompt mid-session. Measured on a real machine - 121 runtime copies, 75 distinct refresh tokens, 114 of 117 access tokens expired. Sharing one file via symlink is the obvious fix and does not work: Claude Code rewrites .credentials.json atomically (tmp -> rename), which replaces a symlink with a regular file on the first refresh. Observable in any authmux runtime, where cue symlinks .claude.json and every one has since become a plain file while its neighbours (projects/, agents/) are still links. So the sessions have to talk instead. cue already published a rotation to the owning account dir on exit, which is too late - by then the sibling has already been dropped. A live session now reconciles once a minute for as long as it runs: it republishes its own rotation, and adopts anyone else's. pullFreshestToRuntime is the missing inbound direction, gated on matching accountUuid so alternating accounts can't hand each other tokens, and on strictly newer expiresAt so two reconcilers settle rather than trading the file back and forth. Polling rather than watching is deliberate: the same atomic rename that defeats symlinks also breaks an inode watch. --- src/commands/launch.ts | 56 ++++++++++++++++++- src/lib/credentials-sync.test.ts | 95 +++++++++++++++++++++++++++++++- src/lib/credentials-sync.ts | 78 ++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 2 deletions(-) diff --git a/src/commands/launch.ts b/src/commands/launch.ts index 182083a1..2be7fe08 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -1373,6 +1373,50 @@ async function resolveClaudeCredentialsSource(): Promise { return resolveSharedClaudeCredentialsSource({ healFromRuntime: true }); } +/** + * How often a live session checks whether its OAuth token still matches the + * account's. Token lifetimes are hours and rotation is rare, so a minute of + * lag costs nothing; polling (rather than watching) is deliberate, since the + * atomic tmp→rename rewrite that replaces the file also breaks an inode watch. + */ +const CREDENTIAL_RECONCILE_MS = 60_000; + +/** + * Keep a running session's tokens in step with every other session on the same + * account, for as long as it runs. + * + * Concurrent sessions on different profiles hold separate copies of one refresh + * token, and Anthropic rotates that token on each refresh — so whichever + * session refreshes first silently revokes the others, and they hit a login + * prompt mid-session. Rescue-on-exit was too late to help: by then the other + * session has already been dropped. Each session now republishes its own + * rotation and adopts anyone else's within a minute. + * + * Best-effort throughout, and unref'd so it can never hold the process open. + * Returns a stop function. + */ +function startCredentialReconciler(runtimeKey: string): () => void { + let running = false; + const tick = async (): Promise => { + if (running) return; // a slow disk must not stack overlapping reconciles + running = true; + try { + const { listKnownAccountDirs, reconcileCredentials } = await import("../lib/credentials-sync"); + // basename() pins the path inside the runtime tree — this writes token + // files, so a runtime key carrying a separator must not escape it. + const runtimeClaudeDir = join(configDir(), "runtime", basename(runtimeKey), "claude"); + await reconcileCredentials(runtimeClaudeDir, await listKnownAccountDirs(homedir())); + } catch (err) { + debug("launch:cred-reconcile", err); + } finally { + running = false; + } + }; + const timer = setInterval(() => void tick(), CREDENTIAL_RECONCILE_MS); + timer.unref(); + return () => clearInterval(timer); +} + /** * Write the runtime's login-fresh `.credentials.json` back to the account * dir that owns it (matched by accountUuid). Runs (a) before materialization @@ -2505,7 +2549,17 @@ export async function run(args: string[]): Promise { } catch (err) { debug("launch:brief", err); } } - const exitCode = await execAgent(realBin, [...briefArgs, ...parsed.passthrough], childEnv); + // Keep tokens in step with sibling sessions *while* this one runs — a + // rotation elsewhere would otherwise revoke ours and force a mid-session + // re-login. + const stopReconciler = + agentKind === "claude-code" ? startCredentialReconciler(runtimeKey) : undefined; + let exitCode: number; + try { + exitCode = await execAgent(realBin, [...briefArgs, ...parsed.passthrough], childEnv); + } finally { + stopReconciler?.(); + } // Persist any /login done inside the session to its account dir now — // don't leave the only live rotated token stranded in the per-account runtime. if (agentKind === "claude-code") await rescueRuntimeCredsToOwner(runtimeKey); diff --git a/src/lib/credentials-sync.test.ts b/src/lib/credentials-sync.test.ts index bb4a46ee..a9884aa3 100644 --- a/src/lib/credentials-sync.test.ts +++ b/src/lib/credentials-sync.test.ts @@ -3,7 +3,14 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { findFreshestCredentials, listKnownAccountDirs, rescueRuntimeCredentials, syncFreshestToSource } from "./credentials-sync"; +import { + findFreshestCredentials, + listKnownAccountDirs, + pullFreshestToRuntime, + reconcileCredentials, + rescueRuntimeCredentials, + syncFreshestToSource, +} from "./credentials-sync"; let root: string; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "cue-credsync-")); }); @@ -306,6 +313,92 @@ describe("rescueRuntimeCredentials", () => { }); }); +describe("pullFreshestToRuntime", () => { + test("adopts the owner's fresher token — a sibling session rotated ours away", async () => { + // Two live sessions on different profiles share one refresh token. The + // other one refreshed, rotating ours dead; its rescue already published + // the new token to the account dir. This is how we find out. + const account = join(root, "accounts", "account2"); + await writeAccountDir(account, UUID_B, { refreshToken: "rt-rotated-by-sibling", expiresAt: 9999 }); + + const runtimeDir = join(root, "runtime", "core", "claude"); + await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-now-dead", expiresAt: 1000 }); + + const result = await pullFreshestToRuntime(runtimeDir, [account]); + expect(result.pulled).toBe(true); + const after = JSON.parse(await readFile(join(runtimeDir, ".credentials.json"), "utf8")); + expect(after.claudeAiOauth.refreshToken).toBe("rt-rotated-by-sibling"); + }); + + test("never adopts a different account's token", async () => { + const other = join(root, "accounts", "account1"); + await writeAccountDir(other, UUID_A, { refreshToken: "rt-other-account", expiresAt: 9999 }); + + const runtimeDir = join(root, "runtime", "core", "claude"); + await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-ours", expiresAt: 1000 }); + + expect((await pullFreshestToRuntime(runtimeDir, [other])).pulled).toBe(false); + const after = JSON.parse(await readFile(join(runtimeDir, ".credentials.json"), "utf8")); + expect(after.claudeAiOauth.refreshToken).toBe("rt-ours"); + }); + + test("leaves the runtime alone when it is already the freshest", async () => { + const account = join(root, "accounts", "account2"); + await writeAccountDir(account, UUID_B, { refreshToken: "rt-stale", expiresAt: 5000 }); + const runtimeDir = join(root, "runtime", "core", "claude"); + await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-ours", expiresAt: 9999 }); + + expect((await pullFreshestToRuntime(runtimeDir, [account])).pulled).toBe(false); + }); + + test("a runtime with no credentials is not guessed at", async () => { + const account = join(root, "accounts", "account2"); + await writeAccountDir(account, UUID_B, { refreshToken: "rt-owner", expiresAt: 9999 }); + const runtimeDir = join(root, "runtime", "empty", "claude"); + await mkdir(runtimeDir, { recursive: true }); + + expect((await pullFreshestToRuntime(runtimeDir, [account])).pulled).toBe(false); + }); +}); + +describe("reconcileCredentials", () => { + test("publishes our rotation, so the sibling session can adopt it", async () => { + const account = join(root, "accounts", "account2"); + await writeAccountDir(account, UUID_B, { refreshToken: "rt-old", expiresAt: 1000 }); + const runtimeDir = join(root, "runtime", "core", "claude"); + await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-we-just-rotated", expiresAt: 9999 }); + + const out = await reconcileCredentials(runtimeDir, [account]); + expect(out.pushed).toContain(join(account, ".credentials.json")); + const owner = JSON.parse(await readFile(join(account, ".credentials.json"), "utf8")); + expect(owner.claudeAiOauth.refreshToken).toBe("rt-we-just-rotated"); + }); + + test("adopts the sibling's rotation when we are the stale one", async () => { + const account = join(root, "accounts", "account2"); + await writeAccountDir(account, UUID_B, { refreshToken: "rt-fresh", expiresAt: 9999 }); + const runtimeDir = join(root, "runtime", "core", "claude"); + await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-dead", expiresAt: 1000 }); + + const out = await reconcileCredentials(runtimeDir, [account]); + expect(out.pulled).toBe(join(account, ".credentials.json")); + const mine = JSON.parse(await readFile(join(runtimeDir, ".credentials.json"), "utf8")); + expect(mine.claudeAiOauth.refreshToken).toBe("rt-fresh"); + }); + + test("settles — an already-agreed pair produces no writes in either direction", async () => { + // Guards against a push/pull ping-pong between two reconcilers. + const account = join(root, "accounts", "account2"); + await writeAccountDir(account, UUID_B, { refreshToken: "rt-same", expiresAt: 9999 }); + const runtimeDir = join(root, "runtime", "core", "claude"); + await writeAccountDir(runtimeDir, UUID_B, { refreshToken: "rt-same", expiresAt: 9999 }); + + const out = await reconcileCredentials(runtimeDir, [account]); + expect(out.pushed).toEqual([]); + expect(out.pulled).toBeUndefined(); + }); +}); + describe("listKnownAccountDirs", () => { test("returns ~/.claude plus every ~/.claude-accounts/ directory", async () => { await mkdir(join(root, ".claude"), { recursive: true }); diff --git a/src/lib/credentials-sync.ts b/src/lib/credentials-sync.ts index dd09c384..2548fd2a 100644 --- a/src/lib/credentials-sync.ts +++ b/src/lib/credentials-sync.ts @@ -211,6 +211,84 @@ export async function syncFreshestToSource( } } +/** + * Adopt the owning account's credentials when they are fresher than this + * runtime's — the mirror image of `rescueRuntimeCredentials`. + * + * Why a *running* session needs this: two concurrent sessions on different + * profiles hold separate copies of one refresh token, and Anthropic rotates + * that token on every refresh. The moment one refreshes, the other's copy is + * revoked, and its next refresh drops the user into a login prompt mid-session. + * The refresher publishes its new token to the account dir (that is + * `rescueRuntimeCredentials`); this is how the other session hears about it. + * + * Symlinking the runtimes at one shared file would be the obvious fix and does + * not work: Claude Code rewrites `.credentials.json` atomically (tmp → rename), + * which replaces a symlink with a regular file on the first refresh. Observable + * in any authmux runtime, where cue symlinks `.claude.json` and every one has + * since become a plain file while its neighbours (`projects/`, `agents/`) are + * still links. + * + * Only ever adopts from a dir claiming the same `accountUuid`, so alternating + * accounts can't hand each other tokens. Atomic tmp+rename write, mirroring the + * other writers here, so a concurrent reader never sees a partial file. + */ +export async function pullFreshestToRuntime( + runtimeClaudeDir: string, + accountDirs: string[], +): Promise<{ pulled: false } | { pulled: true; from: string; expiresAt: number }> { + const mine = await readCredentials(runtimeClaudeDir); + // No credentials, or no identity to match on — never guess which account a + // runtime belongs to; adopting the wrong one pairs tokens with an identity + // that doesn't own them. + if (!mine?.accountUuid) return { pulled: false }; + + let best: { path: string; expiresAt: number } | undefined; + for (const dir of accountDirs) { + if ((await readAccountUuid(dir)) !== mine.accountUuid) continue; + const owner = await readCredentials(dir); + if (!owner || owner.refreshToken.trim().length === 0) continue; + if (owner.expiresAt <= mine.expiresAt) continue; + if (!best || owner.expiresAt > best.expiresAt) { + best = { path: owner.path, expiresAt: owner.expiresAt }; + } + } + if (!best) return { pulled: false }; + + const target = join(runtimeClaudeDir, ".credentials.json"); + const tmp = `${target}.cue-pull.${process.pid}`; + try { + await copyFile(best.path, tmp); + await rename(tmp, target); + return { pulled: true, from: best.path, expiresAt: best.expiresAt }; + } catch { + try { await rm(tmp, { force: true }); } catch { /* best-effort cleanup */ } + return { pulled: false }; + } +} + +/** + * Bring a live session's tokens and its account dir back into agreement, in + * whichever direction is stale. + * + * Push first: if we hold the newest token, publish it before adopting anything, + * so a sibling reconciler running at the same moment can only ever move tokens + * forward. Both directions are gated on *strictly* newer `expiresAt`, so once + * the pair agrees neither writes again — two reconcilers polling each other + * settle instead of trading the file back and forth. + */ +export async function reconcileCredentials( + runtimeClaudeDir: string, + accountDirs: string[], +): Promise<{ pushed: string[]; pulled?: string }> { + const pushed: string[] = []; + const push = await rescueRuntimeCredentials(runtimeClaudeDir, accountDirs); + if (push.rescued) pushed.push(push.to); + + const pull = await pullFreshestToRuntime(runtimeClaudeDir, accountDirs); + return pull.pulled ? { pushed, pulled: pull.from } : { pushed }; +} + /** * Known Claude account dirs a runtime's credentials could belong to: * `~/.claude` plus every `~/.claude-accounts/` (authmux's parallel From cbeeb6b1b9071112690668c112cb4f8f086097a8 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 28 Jul 2026 10:06:56 +0200 Subject: [PATCH 13/33] fix(auth): read the default account's identity where Claude Code keeps it The rotation heal added in ae59ebf3 never ran for the default account. Every direction in credentials-sync is gated on a known accountUuid, and readAccountUuid looked only at /.claude.json. With no CLAUDE_CONFIG_DIR set, Claude Code keeps oauthAccount in the home-root ~/.claude.json and leaves ~/.claude/.claude.json a settings-only stub - so the default account read as unknown and all three heals silently no-op'd: no candidates to sync from, no publish to ~/.claude, no adopt out of it. Measured here: 76 distinct refresh tokens across 123 runtimes, and 32 runtimes the heal could not see. The bug hid because authmux account dirs DO carry identity in-dir, so they worked. The basename gate keeps the fallback off those account dirs and off runtime dirs, which all carry identity in-dir - a stray sibling .claude.json must never be read as an account's identity or two accounts could trade tokens. Second path to the same symptom: overlaySourceState copied .credentials.json unconditionally, with none of the expiresAt comparison the rebuild path does. It runs on every cache-hit launch, and cue sync / cue install resolve their source with healFromRuntime: false - so one bulk sync could stamp a dead token over every runtime at once. It now keeps whichever side is newer, scoped to a single account so a deliberate account switch still re-seeds wholesale. Last, the reconcile cadence. Copies of a blob share one expiresAt, so concurrent sessions reach expiry together and refresh within moments of each other; only the first rotation survives. A flat 60s poll cannot help when the contended window is seconds wide, so the cadence now tightens to 5s across that window and idles at a minute elsewhere. This narrows the race and does not close it: cue does not perform the refresh, Claude Code does in-process, so there is no point at which cue can serialize the two callers. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/launch.ts | 52 +++++++---- src/lib/credentials-sync.test.ts | 134 +++++++++++++++++++++++++++ src/lib/credentials-sync.ts | 95 +++++++++++++++++-- src/lib/runtime-materializer.test.ts | 85 +++++++++++++++++ src/lib/runtime-materializer.ts | 31 +++++++ 5 files changed, 370 insertions(+), 27 deletions(-) diff --git a/src/commands/launch.ts b/src/commands/launch.ts index 2be7fe08..3a2e9833 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -1374,12 +1374,14 @@ async function resolveClaudeCredentialsSource(): Promise { } /** - * How often a live session checks whether its OAuth token still matches the - * account's. Token lifetimes are hours and rotation is rare, so a minute of - * lag costs nothing; polling (rather than watching) is deliberate, since the - * atomic tmp→rename rewrite that replaces the file also breaks an inode watch. + * Fallback reconcile cadence, used only if the credentials-sync import itself + * fails. Matches `RECONCILE_IDLE_MS` there, which owns the real schedule. + * + * Polling (rather than watching) is deliberate throughout: the atomic + * tmp→rename rewrite that replaces `.credentials.json` also breaks an inode + * watch. */ -const CREDENTIAL_RECONCILE_MS = 60_000; +const CREDENTIAL_RECONCILE_FALLBACK_MS = 60_000; /** * Keep a running session's tokens in step with every other session on the same @@ -1392,29 +1394,45 @@ const CREDENTIAL_RECONCILE_MS = 60_000; * session has already been dropped. Each session now republishes its own * rotation and adopts anyone else's within a minute. * + * The cadence is not fixed: it tightens across the window where every copy's + * access token expires at once (see `nextReconcileDelayMs`), because that is + * precisely when a rotation is contended and a minute of lag loses the race. + * * Best-effort throughout, and unref'd so it can never hold the process open. * Returns a stop function. */ function startCredentialReconciler(runtimeKey: string): () => void { - let running = false; + // basename() pins the path inside the runtime tree — this writes token + // files, so a runtime key carrying a separator must not escape it. + const runtimeClaudeDir = join(configDir(), "runtime", basename(runtimeKey), "claude"); + let stopped = false; + let timer: ReturnType | undefined; + + // Self-chaining rather than setInterval: the delay varies per tick, and a + // slow disk can no longer stack overlapping reconciles. const tick = async (): Promise => { - if (running) return; // a slow disk must not stack overlapping reconciles - running = true; + let delayMs = CREDENTIAL_RECONCILE_FALLBACK_MS; try { - const { listKnownAccountDirs, reconcileCredentials } = await import("../lib/credentials-sync"); - // basename() pins the path inside the runtime tree — this writes token - // files, so a runtime key carrying a separator must not escape it. - const runtimeClaudeDir = join(configDir(), "runtime", basename(runtimeKey), "claude"); + const { listKnownAccountDirs, reconcileCredentials, readExpiresAt, nextReconcileDelayMs } = + await import("../lib/credentials-sync"); await reconcileCredentials(runtimeClaudeDir, await listKnownAccountDirs(homedir())); + delayMs = nextReconcileDelayMs(await readExpiresAt(runtimeClaudeDir), Date.now()); } catch (err) { debug("launch:cred-reconcile", err); - } finally { - running = false; } + if (stopped) return; + timer = setTimeout(() => void tick(), delayMs); + timer.unref(); + }; + + // Reconcile once up front: a session launched while a sibling is mid-rotation + // should adopt the live token now, not a minute from now. + void tick(); + + return () => { + stopped = true; + if (timer !== undefined) clearTimeout(timer); }; - const timer = setInterval(() => void tick(), CREDENTIAL_RECONCILE_MS); - timer.unref(); - return () => clearInterval(timer); } /** diff --git a/src/lib/credentials-sync.test.ts b/src/lib/credentials-sync.test.ts index a9884aa3..70e5a86a 100644 --- a/src/lib/credentials-sync.test.ts +++ b/src/lib/credentials-sync.test.ts @@ -6,8 +6,12 @@ import { join } from "node:path"; import { findFreshestCredentials, listKnownAccountDirs, + nextReconcileDelayMs, pullFreshestToRuntime, + readExpiresAt, reconcileCredentials, + RECONCILE_IDLE_MS, + RECONCILE_ROTATION_MS, rescueRuntimeCredentials, syncFreshestToSource, } from "./credentials-sync"; @@ -41,6 +45,136 @@ async function writeAccountDir(dir: string, uuid: string | undefined, creds: Cre } } +/** + * Claude Code's real layout when no CLAUDE_CONFIG_DIR is set: `oauthAccount` + * lives in the home-root `~/.claude.json`, while `~/.claude/.claude.json` is a + * settings-only stub. Returns the `.claude` dir to pass as source/account dir. + */ +async function writeDefaultAccountDir(home: string, uuid: string, creds: Creds): Promise { + const dir = join(home, ".claude"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, ".claude.json"), JSON.stringify({ firstStartTime: "t", userID: "u" })); + await writeFile(join(home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: uuid } })); + await writeFile( + join(dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "at-" + creds.refreshToken, ...creds } }), + ); + return dir; +} + +describe("home-root .claude.json fallback", () => { + test("finds a fresher runtime for a default ~/.claude source", async () => { + // Regression: reading identity only from `/.claude.json` reported the + // DEFAULT account as unknown, so every heal here silently no-op'd for it and + // the runtimes each drifted onto their own dead rotated token. + const sourceDir = await writeDefaultAccountDir(join(root, "home"), UUID_A, { + refreshToken: "rt-stale", + expiresAt: 1000, + }); + const runtimeRoot = join(root, "runtime"); + await writeAccountDir(join(runtimeRoot, "live", "claude"), UUID_A, { + refreshToken: "rt-live", + expiresAt: 9999, + }); + + const out = await findFreshestCredentials(sourceDir, runtimeRoot); + expect(out?.refreshToken).toBe("rt-live"); + }); + + test("adopts into a runtime from a default ~/.claude account dir", async () => { + const accountDir = await writeDefaultAccountDir(join(root, "home"), UUID_A, { + refreshToken: "rt-new", + expiresAt: 9999, + }); + const runtimeDir = join(root, "runtime", "p", "claude"); + await writeAccountDir(runtimeDir, UUID_A, { refreshToken: "rt-old", expiresAt: 1000 }); + + expect((await pullFreshestToRuntime(runtimeDir, [accountDir])).pulled).toBe(true); + const after = JSON.parse(await readFile(join(runtimeDir, ".credentials.json"), "utf8")); + expect(after.claudeAiOauth.refreshToken).toBe("rt-new"); + }); + + test("publishes a runtime's rotation back to a default ~/.claude account dir", async () => { + const accountDir = await writeDefaultAccountDir(join(root, "home"), UUID_A, { + refreshToken: "rt-old", + expiresAt: 1000, + }); + const runtimeDir = join(root, "runtime", "p", "claude"); + await writeAccountDir(runtimeDir, UUID_A, { refreshToken: "rt-rotated", expiresAt: 9999 }); + + expect((await rescueRuntimeCredentials(runtimeDir, [accountDir])).rescued).toBe(true); + const after = JSON.parse(await readFile(join(accountDir, ".credentials.json"), "utf8")); + expect(after.claudeAiOauth.refreshToken).toBe("rt-rotated"); + }); + + test("does not apply the fallback to a dir that is not named .claude", async () => { + // authmux account dirs carry identity in-dir; a stray sibling `.claude.json` + // must never be read as their identity, or accounts could swap tokens. + const parent = join(root, "accounts"); + const accountDir = join(parent, "account1"); + await mkdir(accountDir, { recursive: true }); + await writeFile(join(parent, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: UUID_A } })); + await writeFile( + join(accountDir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { refreshToken: "rt-other", expiresAt: 9999 } }), + ); + const runtimeDir = join(root, "runtime", "p", "claude"); + await writeAccountDir(runtimeDir, UUID_A, { refreshToken: "rt-mine", expiresAt: 1000 }); + + expect((await pullFreshestToRuntime(runtimeDir, [accountDir])).pulled).toBe(false); + }); + + test("an in-dir identity still wins over the home-root file", async () => { + const home = join(root, "home"); + const dir = join(home, ".claude"); + await mkdir(dir, { recursive: true }); + await writeFile(join(home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: UUID_B } })); + await writeAccountDir(dir, UUID_A, { refreshToken: "rt-a", expiresAt: 1000 }); + + const runtimeRoot = join(root, "runtime"); + await writeAccountDir(join(runtimeRoot, "live", "claude"), UUID_A, { + refreshToken: "rt-live-a", + expiresAt: 9999, + }); + + const out = await findFreshestCredentials(dir, runtimeRoot); + expect(out?.refreshToken).toBe("rt-live-a"); + }); +}); + +describe("nextReconcileDelayMs", () => { + const EXPIRES = 8_000_000_000_000; + const MIN = 60_000; + + test("idles at a minute far from expiry", () => { + expect(nextReconcileDelayMs(EXPIRES, EXPIRES - 240 * MIN)).toBe(RECONCILE_IDLE_MS); + }); + + test("tightens as the shared expiry approaches", () => { + // Every copy carries the same expiresAt, so all live sessions rotate here. + expect(nextReconcileDelayMs(EXPIRES, EXPIRES - 5 * MIN)).toBe(RECONCILE_ROTATION_MS); + expect(nextReconcileDelayMs(EXPIRES, EXPIRES)).toBe(RECONCILE_ROTATION_MS); + expect(nextReconcileDelayMs(EXPIRES, EXPIRES + 5 * MIN)).toBe(RECONCILE_ROTATION_MS); + }); + + test("drops back once the window has passed", () => { + expect(nextReconcileDelayMs(EXPIRES, EXPIRES + 120 * MIN)).toBe(RECONCILE_IDLE_MS); + }); + + test("idles when the expiry is unknown", () => { + expect(nextReconcileDelayMs(0, EXPIRES)).toBe(RECONCILE_IDLE_MS); + }); +}); + +describe("readExpiresAt", () => { + test("reads the expiry, and reports 0 when there are no credentials", async () => { + const dir = join(root, "rt", "claude"); + await writeAccountDir(dir, UUID_A, { refreshToken: "rt", expiresAt: 4242 }); + expect(await readExpiresAt(dir)).toBe(4242); + expect(await readExpiresAt(join(root, "nope"))).toBe(0); + }); +}); + describe("findFreshestCredentials", () => { test("returns undefined when no credentials exist anywhere", async () => { const sourceDir = join(root, "source"); diff --git a/src/lib/credentials-sync.ts b/src/lib/credentials-sync.ts index 2548fd2a..1b00247f 100644 --- a/src/lib/credentials-sync.ts +++ b/src/lib/credentials-sync.ts @@ -27,7 +27,7 @@ */ import { readFile, readdir, copyFile, rename, rm, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; interface CredentialsBlob { claudeAiOauth?: { @@ -53,17 +53,42 @@ export interface FreshestCandidate { } /** - * Read the `accountUuid` recorded in `/.claude.json`. Returns undefined - * if the file is missing or doesn't have the OAuth metadata. + * Read the `accountUuid` recorded in `/.claude.json`, falling back to the + * home-root `.claude.json` one level up when `dir` is a `.claude` config dir + * whose own file carries no identity. + * + * The fallback is load-bearing, not a nicety. With no CLAUDE_CONFIG_DIR set, + * Claude Code keeps `oauthAccount` in `~/.claude.json` and leaves + * `~/.claude/.claude.json` as a settings-only stub (`firstStartTime`, + * `userID`, …). Reading only in-dir therefore reports the DEFAULT account as + * "unknown" — and every heal in this module is gated on a known uuid, so all + * three directions silently no-op for it: `syncFreshestToSource` returns no + * candidates, `rescueRuntimeCredentials` never publishes to `~/.claude`, and + * `pullFreshestToRuntime` never adopts from it. The runtimes then diverge into + * one dead rotated token each, which is the exact desync this module exists to + * fix. authmux account dirs and runtime dirs all carry identity in-dir, so the + * `basename` gate keeps the fallback off them. + * + * Mirrors the legacy home-root fallback in runtime-materializer's + * `overlaySourceState`; keep the two in step. + * + * Returns undefined when neither file exists or has the OAuth metadata. */ async function readAccountUuid(dir: string): Promise { - try { - const raw = await readFile(join(dir, ".claude.json"), "utf8"); - const parsed = JSON.parse(raw) as ClaudeJsonBlob; - return parsed?.oauthAccount?.accountUuid; - } catch { - return undefined; - } + const uuidAt = async (path: string): Promise => { + try { + const raw = await readFile(path, "utf8"); + const parsed = JSON.parse(raw) as ClaudeJsonBlob; + return parsed?.oauthAccount?.accountUuid; + } catch { + return undefined; + } + }; + + const inDir = await uuidAt(join(dir, ".claude.json")); + if (inDir) return inDir; + if (basename(dir) === ".claude") return await uuidAt(join(dirname(dir), ".claude.json")); + return undefined; } /** @@ -289,6 +314,56 @@ export async function reconcileCredentials( return pull.pulled ? { pushed, pulled: pull.from } : { pushed }; } +/** Reconcile cadence away from a rotation. Token lifetimes are ~8h, so a + * minute of lag costs nothing for almost the whole session. */ +export const RECONCILE_IDLE_MS = 60_000; + +/** Cadence across the rotation window. See `nextReconcileDelayMs`. */ +export const RECONCILE_ROTATION_MS = 5_000; + +/** Start polling fast this long before expiry… */ +const ROTATION_LEAD_MS = 10 * 60_000; +/** …and drop back this long after it, by which point any session that was + * going to contend for the rotation already has. */ +const ROTATION_TRAIL_MS = 30 * 60_000; + +/** + * How long to wait before the next reconcile, given this runtime's access-token + * expiry. + * + * Every copy of a credential blob carries the SAME `expiresAt` — they are + * copies — so concurrent sessions all reach expiry in the same instant and + * refresh within moments of each other. Only the first rotation succeeds; the + * rest present a refresh token Anthropic has already revoked and land on a + * login prompt. A flat 60s poll cannot help there: the contended window is + * seconds wide, so the winner's new token routinely arrives after the losers + * have already tried. + * + * Polling fast across that window (and only there) shrinks the gap between the + * winner publishing and the others adopting to ~5s, which is short enough that + * a session which hasn't refreshed yet picks up the live token first and + * rotates cleanly off it. Away from the window the cost of a fast poll buys + * nothing, so it stays at a minute. + * + * This narrows the race; it cannot close it. cue does not perform the refresh — + * Claude Code does, in-process — so there is no point at which cue can + * serialize the two callers. A session that refreshes inside the same few + * seconds as the winner still loses. + */ +export function nextReconcileDelayMs(expiresAt: number, now: number): number { + if (expiresAt <= 0) return RECONCILE_IDLE_MS; // unknown expiry — no window to track + const inWindow = now >= expiresAt - ROTATION_LEAD_MS && now <= expiresAt + ROTATION_TRAIL_MS; + return inWindow ? RECONCILE_ROTATION_MS : RECONCILE_IDLE_MS; +} + +/** + * `claudeAiOauth.expiresAt` for the credentials in `dir`, or 0 when absent or + * unreadable. Feeds `nextReconcileDelayMs`. + */ +export async function readExpiresAt(dir: string): Promise { + return (await readCredentials(dir))?.expiresAt ?? 0; +} + /** * Known Claude account dirs a runtime's credentials could belong to: * `~/.claude` plus every `~/.claude-accounts/` (authmux's parallel diff --git a/src/lib/runtime-materializer.test.ts b/src/lib/runtime-materializer.test.ts index 24be64e0..c2601004 100644 --- a/src/lib/runtime-materializer.test.ts +++ b/src/lib/runtime-materializer.test.ts @@ -666,6 +666,91 @@ describe("materializeRuntime", () => { expect(cj.projects).toEqual({ "/w": { history: [1] } }); }); + test("credentialsSource: cache hit keeps the runtime's fresher token", async () => { + // Regression: the overlay stamped source over the runtime unconditionally. + // Anthropic rotates the refresh token on every refresh, so only the highest + // expiresAt is live — and `cue sync` / `cue install` materialize with an + // UNHEALED source, so one bulk sync could hand every runtime a dead token + // and force a re-login. + const STALE = 1_000; + const FRESH = 9_999_999; + const credSrc = join(root, "accStaleSource"); + await mkdir(credSrc, { recursive: true }); + await writeFile(join(credSrc, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "uuid-A" } })); + await writeFile( + join(credSrc, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { expiresAt: STALE, refreshToken: "dead" } }), + ); + + const args = { + profile: { ...sampleProfile, name: "cred-cachehit-freshness" }, + agent: "claude-code" as const, + runtimeRoot: join(root, "runtime"), + skillSourceLookup: async (id: string) => `/fake/source/${id}`, + mcpRegistry: { "claude-mem": { command: "claude-mem" } }, + userClaudeMd: "", + }; + + const first = await materializeRuntime({ ...args, credentialsSource: credSrc }); + expect(first.rebuilt).toBe(true); + // The running session refreshed, rotating source's token dead. + await writeFile( + join(first.runtimeDir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { expiresAt: FRESH, refreshToken: "live" } }), + ); + + const second = await materializeRuntime({ ...args, credentialsSource: credSrc }); + expect(second.rebuilt).toBe(false); + const creds = JSON.parse(await readFile(join(second.runtimeDir, ".credentials.json"), "utf8")); + expect(creds.claudeAiOauth.refreshToken).toBe("live"); + expect(creds.claudeAiOauth.expiresAt).toBe(FRESH); + }); + + test("credentialsSource: cache hit re-seeds tokens on account switch even when the runtime's are newer", async () => { + // The freshness guard above is scoped to ONE account — across accounts the + // expiry comparison is meaningless and source must win. Source here uses the + // real default-account layout (identity at the home root, stub inside + // `.claude/`), so this also covers that fallback: without it the switch reads + // as same-account and the runtime's newer token would wrongly survive. + const home = join(root, "homeB"); + const credSrc = join(home, ".claude"); + await mkdir(credSrc, { recursive: true }); + await writeFile(join(credSrc, ".claude.json"), JSON.stringify({ firstStartTime: "t" })); + await writeFile(join(home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "uuid-B" } })); + await writeFile( + join(credSrc, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { expiresAt: 1_000, refreshToken: "B" } }), + ); + + const args = { + profile: { ...sampleProfile, name: "cred-cachehit-switch" }, + agent: "claude-code" as const, + runtimeRoot: join(root, "runtime"), + skillSourceLookup: async (id: string) => `/fake/source/${id}`, + mcpRegistry: { "claude-mem": { command: "claude-mem" } }, + userClaudeMd: "", + }; + + const first = await materializeRuntime({ ...args, credentialsSource: credSrc }); + expect(first.rebuilt).toBe(true); + // Account A had been logged in here: Claude's atomic rewrite left a local + // FILE identity, and A's token outlives B's. + await rm(join(first.runtimeDir, ".claude.json"), { force: true }); + await writeFile( + join(first.runtimeDir, ".claude.json"), + JSON.stringify({ oauthAccount: { accountUuid: "uuid-A" } }), + ); + await writeFile( + join(first.runtimeDir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { expiresAt: 9_999_999, refreshToken: "A" } }), + ); + + const second = await materializeRuntime({ ...args, credentialsSource: credSrc }); + expect(second.rebuilt).toBe(false); + const creds = JSON.parse(await readFile(join(second.runtimeDir, ".credentials.json"), "utf8")); + expect(creds.claudeAiOauth.refreshToken).toBe("B"); + }); + test("credentialsSource: rebuild does not resurrect another account's identity or tokens", async () => { // The preserve step's expiresAt comparison is meaningless across accounts: // the old runtime's token may expire later yet belong to the OTHER account. diff --git a/src/lib/runtime-materializer.ts b/src/lib/runtime-materializer.ts index 9dd4ed3c..eb37c737 100644 --- a/src/lib/runtime-materializer.ts +++ b/src/lib/runtime-materializer.ts @@ -1005,6 +1005,20 @@ async function overlaySourceState(targetDir: string, sourceDir: string): Promise } catch { /* skip */ } } + // Account identity of both sides, resolved ONCE before the loop below can + // swap `.claude.json` out from under the comparison. Same home-root fallback + // as the entry list above: with no CLAUDE_CONFIG_DIR, Claude Code keeps + // `oauthAccount` in `~/.claude.json`, not in `~/.claude/.claude.json`. + const identityOf = async (dir: string): Promise => + (await accountUuidAt(join(dir, ".claude.json"))) + ?? (basename(dir) === ".claude" ? await accountUuidAt(join(dirname(dir), ".claude.json")) : undefined); + const srcAccount = await identityOf(sourceDir); + const dstAccount = await identityOf(targetDir); + // Unknown on either side → treat as the same account: the expiry comparison + // below is a no-op when the files agree, and refusing to compare would put us + // back on the unconditional stamp this guard exists to prevent. + const sameAccount = !srcAccount || !dstAccount || srcAccount === dstAccount; + for (const name of entries) { if (CUE_MANAGED_ENTRIES.has(name)) continue; const targetPath = join(targetDir, name); @@ -1059,6 +1073,23 @@ async function overlaySourceState(targetDir: string, sourceDir: string): Promise continue; // cue override — don't touch } + // Freshness guard on the rotated OAuth token. Anthropic rotates the refresh + // token on every refresh, so only the copy with the highest expiresAt still + // holds a LIVE one. This overlay runs on every cache-hit launch — and via + // `cue sync` / `cue install`, which resolve their source with + // `healFromRuntime: false` — so stamping source over the runtime + // unconditionally can hand a session a dead token and force a mid-session + // re-login, across every runtime at once on a bulk sync. Keep whichever side + // is newer; mirror of the rebuild path's `preserveFiles` guard. + // + // Scoped to one account: when the identities differ this is a deliberate + // account switch, and source must win regardless of expiry. + if (name === ".credentials.json" && sameAccount) { + const srcExpiresAt = await credentialsExpiresAt(sourcePath); + const dstExpiresAt = await credentialsExpiresAt(targetPath); + if (dstExpiresAt > srcExpiresAt) continue; // runtime holds the live token + } + if (existingType === "symlink" || (existingType === "other" && isCopyFile)) { // Replace if it points elsewhere (e.g. previous account on cache hit). try { From 6385de56efa432c324cd7db85625e243df467427 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 5 Aug 2026 11:21:45 +0200 Subject: [PATCH 14/33] fix(resolver): follow symlinked skill directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skill maintained in its own repo can be linked into the tree rather than copied — browser/ego-browser now points at ~/Documents/ego-lite-linux, so the Linux port's skill has exactly one source of truth instead of a hand-synced duplicate that silently drifts. walk() filtered category entries on dirent.isDirectory(), which is false for the symlink itself, so the linked skill vanished from the index and `cue validate` failed it as E3 SKILL_NOT_FOUND. Stat through symlinks; dangling links are treated as absent. The materializer already symlinks skills, so this only makes the read path agree with the write path. Also register ego-browser in KNOWN_CLIS so the skill's Prerequisites section is picked up by the CLI extractor. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/optimizer.ts | 2 +- src/lib/resolver-local.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/commands/optimizer.ts b/src/commands/optimizer.ts index b752df68..08ce004c 100644 --- a/src/commands/optimizer.ts +++ b/src/commands/optimizer.ts @@ -68,7 +68,7 @@ const KNOWN_CLIS = new Set([ "release-plz", "typos", "cargo-chef", "cargo-msrv", "cargo-readme", "maturin", "napi", "uniffi-bindgen", "bindgen", "cbindgen", "probe-rs", "cargo-embed", "cargo-binutils", "chisel", - "chromium", "chrome", "google-chrome", "microsoft-edge", + "chromium", "chrome", "google-chrome", "microsoft-edge", "ego-browser", "openssl", "ssh", "ncat", "netcat", "socat", "splunk", "elastic", "kibana", "logstash", "peepdf", "pdfid", "pdf-parser", "olevba", "oletools", diff --git a/src/lib/resolver-local.ts b/src/lib/resolver-local.ts index 24f80677..e7241c55 100644 --- a/src/lib/resolver-local.ts +++ b/src/lib/resolver-local.ts @@ -183,7 +183,23 @@ async function walk(root: string): Promise { let slugs: string[]; try { const entries = await readdir(catPath, { withFileTypes: true }); - slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + // A skill maintained in its own repo can be symlinked into the tree + // (e.g. browser/ego-browser -> ~/Documents/ego-lite-linux/skills/...). + // dirent.isDirectory() is false for the link itself, so stat through it. + const resolved = await Promise.all( + entries.map(async (e) => { + if (e.isDirectory()) return e.name; + if (!e.isSymbolicLink()) return null; + try { + return (await stat(join(catPath, e.name))).isDirectory() + ? e.name + : null; + } catch { + return null; // dangling link — treat as absent + } + }), + ); + slugs = resolved.filter((name): name is string => name !== null); } catch { // A non-directory or unreadable entry at the category level is ignored // rather than fatal — keeps walk robust against stray files. From 120e3276af758c82de80491ad41d20df10f6776f Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 7 Aug 2026 04:57:06 +0200 Subject: [PATCH 15/33] feat(core): keep ego-browser loaded in every project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core declared browser/ego-browser, but the per-project loadout deferred it everywhere no project signal matched — which is most projects, since the signal set comes from package.json deps and framework detection and nothing there says "browser". The declaration was real and the skill still never loaded. Deferring it does not save a browser session; it sends the agent to MCP round-trips or web fetch instead, which costs more than this skill's frontmatter. So it joins the operational primitives in ALWAYS_KEEP, and the matching slug set in profile-merge so a budgeted composite can't drop it either. Verified on a neutral cwd with zero project signals: browser/ego-browser now classifies full, where it previously landed in deferred. Co-Authored-By: Claude Opus 5 (1M context) --- profiles/core/profile.yaml | 1 + src/lib/profile-merge.ts | 4 ++++ src/lib/skill-subset.ts | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/profiles/core/profile.yaml b/profiles/core/profile.yaml index 688a94df..4a57ab69 100644 --- a/profiles/core/profile.yaml +++ b/profiles/core/profile.yaml @@ -151,6 +151,7 @@ skills: # ── Security ── - security/trivy-scan # /trivy-scan — pre-merge supply-chain gate (Trivy: dependency CVEs + secrets + IaC misconfig). Hard-blocks HIGH/CRITICAL. Invoked by code-review-deep Pass 0 + ship Step 9.0. # ── Browser ── + - browser/ego-browser # DEFAULT browser path. One JS heredoc drives a real Chromium (navigate, forms, clicks, snapshot, screenshots) instead of many MCP tool-call round trips — chosen for token cost. Reuses the user's real logins. Linux port lives in ~/Documents/ego-lite-linux (package/ego-linux). - browser/lightpanda # Lightpanda — fast headless browser for scraping, DOM dump, CDP. Pairs with lightpanda MCP. # ── Source-fetching ── - tools/opensrc # opensrc — fetch dependency source (npm/PyPI/crates/GitHub) so agents read implementations, not just types diff --git a/src/lib/profile-merge.ts b/src/lib/profile-merge.ts index ed186e73..639eaaa7 100644 --- a/src/lib/profile-merge.ts +++ b/src/lib/profile-merge.ts @@ -39,6 +39,10 @@ function skillSetTokens(ids: string[]): number { // skill discovery — they bootstrap the rest). Matched by slug suffix. const ALWAYS_KEEP_SLUGS = new Set([ "caveman-commit", "find-skills", "smart-loader", "help", + // Default browser path. A merged profile that budgets it out doesn't skip + // the browser work, it downgrades it to MCP round-trips. Mirrors + // skill-subset's ALWAYS_KEEP, which holds it through the project loadout. + "ego-browser", ]); export type OptimizeAction = "prune" | "dedupe" | "budget" | "router"; diff --git a/src/lib/skill-subset.ts b/src/lib/skill-subset.ts index 253418e8..a535b64e 100644 --- a/src/lib/skill-subset.ts +++ b/src/lib/skill-subset.ts @@ -48,6 +48,10 @@ export const ALWAYS_KEEP = new Set([ "meta/acpx", "caveman/caveman", "caveman/caveman-commit", + // The default browser path. Deferring it doesn't save a browser session — + // it sends the agent to MCP round-trips or web fetch instead, which costs + // more than this skill's frontmatter. Keep it on in every project. + "browser/ego-browser", ]); // Re-exported so callers and tests that reached for these here keep working. From 788d72d0028ac61e699643e1d98907cc09feef48 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 7 Aug 2026 04:57:22 +0200 Subject: [PATCH 16/33] feat(security): gate freshly-fetched skills through NVIDIA SkillSpector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every path that lands a new skill on disk — `skills add`, `discover install`, `marketplace install-skill` — now scans it before anything registers it to a profile. SkillSpector covers 68 vulnerability patterns across 17 categories (prompt injection, data exfiltration, supply chain, dangerous code via AST, YARA, MCP tool poisoning) on top of cue's own SEC1-3 criticals. Policy reads the report's `recommendation` rather than the exit code, so the three verdicts stay distinguishable: DO_NOT_INSTALL blocks, CAUTION registers with a visible warning, SAFE is quiet. `--allow-unsafe` overrides the block. install-skill differs from the others on purpose: the files are already on disk by the time it runs, so a block reports findings and exits non-zero rather than deleting anything, leaving them for review. When the scanner isn't installed the gate degrades to cue's own rules and says so, rather than silently passing everything. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/discover.ts | 12 +- src/commands/marketplace.ts | 56 ++++++- src/commands/security.test.ts | 97 +++++++++++- src/commands/security.ts | 125 +++++++++++++-- src/commands/skills.ts | 13 +- src/lib/skillspector.test.ts | 202 ++++++++++++++++++++++++ src/lib/skillspector.ts | 279 ++++++++++++++++++++++++++++++++++ 7 files changed, 763 insertions(+), 21 deletions(-) create mode 100644 src/lib/skillspector.test.ts create mode 100644 src/lib/skillspector.ts diff --git a/src/commands/discover.ts b/src/commands/discover.ts index 652af56d..60299b64 100644 --- a/src/commands/discover.ts +++ b/src/commands/discover.ts @@ -20,6 +20,7 @@ import { clusterByKeywords, clusterByEmbeddings, unclustered, type Cluster, type import { findRealClaudeBin } from "../lib/claude-binary"; import { fetchCompanionFiles, detectSkillPath } from "../lib/companion-fetch"; import { gateFreshSkill } from "./security"; +import { unavailableNote, formatVerdict } from "../lib/skillspector"; import { repoRoot } from "../lib/repo-root"; // Cache path resolved lazily so tests can redirect via XDG_CONFIG_HOME without @@ -2100,9 +2101,16 @@ async function cmdInstall(opts: { profile?: string; minScore: number; minQuality } // Security gate: the skill files are now on disk but not yet registered to - // a profile. Scan the just-fetched (untrusted) skill and block on critical - // findings (secret/data exfiltration, prompt injection) unless --allow-unsafe. + // a profile. Scan the just-fetched (untrusted) skill with NVIDIA + // SkillSpector plus cue's own rules, and block on a DO_NOT_INSTALL verdict + // or critical findings (secret/data exfiltration, prompt injection) unless + // --allow-unsafe. const gate = gateFreshSkill(gem.name, { allowUnsafe: opts.allowUnsafe }); + const ssNote = unavailableNote(gate.skillspector); + if (ssNote) process.stdout.write(` ${ssNote}\n`); + if (gate.ok && gate.skillspector.recommendation === "CAUTION") { + process.stdout.write(` 🟡 ${formatVerdict(gate.skillspector)} — registered, review it.\n`); + } if (!gate.ok) { process.stdout.write(` 🔴 BLOCKED: ${gem.full_name} has ${gate.critical.length} critical security finding(s):\n`); for (const c of gate.critical) { diff --git a/src/commands/marketplace.ts b/src/commands/marketplace.ts index b1351e9f..d7a5988d 100644 --- a/src/commands/marketplace.ts +++ b/src/commands/marketplace.ts @@ -17,6 +17,8 @@ import { readFileSync, existsSync } from "node:fs"; import { resolveActiveProfile } from "../lib/cwd-resolver"; import { fetchCompanionFiles, detectSkillPath } from "../lib/companion-fetch"; +import { gateFreshSkill, resolveSkillDir } from "./security"; +import { unavailableNote, formatVerdict } from "../lib/skillspector"; import type { FileChange } from "../lib/pr-poster"; import { repoRoot } from "../lib/repo-root"; @@ -234,13 +236,48 @@ async function cmdInstallMcp(id: string): Promise { return 0; } -async function cmdInstallSkill(repo: string): Promise { +/** + * Post-install security gate for `install-skill`. Unlike `cue skills add` there + * is no profile registration to withhold here — the files are already on disk — + * so a block reports the findings, leaves the files for review, and exits + * non-zero rather than deleting anything. + */ +function gateInstalledSkill(skillName: string, allowUnsafe: boolean): number { + process.stdout.write(`🔍 Scanning "${skillName}" with NVIDIA SkillSpector…\n`); + const gate = gateFreshSkill(skillName, { allowUnsafe }); + const note = unavailableNote(gate.skillspector); + if (note) process.stderr.write(` ${note}\n`); + + if (!gate.ok) { + process.stderr.write(`🔴 BLOCKED "${skillName}": ${gate.critical.length} critical security finding(s)\n`); + for (const c of gate.critical) { + process.stderr.write(` [${c.code}] ${c.message}${c.line ? ` (line ${c.line})` : ""}\n`); + } + const dir = resolveSkillDir(skillName); + if (dir) process.stderr.write(` Files left for review at ${dir} — remove them or re-run with --allow-unsafe.\n`); + return 1; + } + if (!gate.scanned) { + process.stderr.write(`⚠️ ${skillName}: no SKILL.md found to scan — review manually.\n`); + } else if (gate.skillspector.recommendation === "CAUTION") { + process.stderr.write(`🟡 ${formatVerdict(gate.skillspector)} — installed, review it.\n`); + } else if (gate.skillspector.recommendation === "SAFE") { + process.stdout.write(`🟢 SkillSpector SAFE\n`); + } + return 0; +} + +async function cmdInstallSkill(repo: string, allowUnsafe = false): Promise { + const installedName = repo.split("/").pop() ?? repo; + // Try smithery first if (hasSmithery()) { process.stdout.write(`Installing skill "${repo}" via Smithery...\n`); const res = smithery(["skill", "add", repo, "--agent", "claude-code"]); if (res.ok) { process.stdout.write(res.stdout); + const gated = gateInstalledSkill(installedName, allowUnsafe); + if (gated !== 0) return gated; process.stdout.write(`✅ Skill installed.\n`); return 0; } @@ -260,7 +297,7 @@ async function cmdInstallSkill(repo: string): Promise { // Fetch companion files (scripts/, forms.md, reference.md, etc.) const { homedir } = await import("node:os"); - const skillName = repo.split("/").pop() ?? repo; + const skillName = installedName; const skillsDir = join(homedir(), ".claude", "skills"); const localDir = join(skillsDir, skillName); if (existsSync(localDir)) { @@ -273,6 +310,10 @@ async function cmdInstallSkill(repo: string): Promise { } } + // Scan after companion files land so scripts/ is covered by the same pass. + const gated = gateInstalledSkill(skillName, allowUnsafe); + if (gated !== 0) return gated; + process.stdout.write(`✅ Skill installed.\n`); return 0; } @@ -1208,7 +1249,9 @@ Subcommands: cleanup-forks Delete cue's forks for PRs that are merged/closed. Use --dry-run to see what would be deleted. install-mcp Install MCP via Smithery - install-skill Install skill from GitHub + install-skill Install skill from GitHub. Scanned with NVIDIA + SkillSpector before it is accepted; a DO_NOT_INSTALL + verdict fails the command (--allow-unsafe overrides). list-mcps List connected Smithery MCPs list-tools [conn] List tools from connected MCPs find-tools Search tools by intent @@ -1244,8 +1287,11 @@ Examples: return cmdSearchSkills(rest.slice(1).join(" ") || "", json); case "install-mcp": return cmdInstallMcp(rest[1] ?? ""); - case "install-skill": - return cmdInstallSkill(rest[1] ?? ""); + case "install-skill": { + // Take the first non-flag so `--allow-unsafe` can appear on either side. + const repo = rest.slice(1).find((a) => !a.startsWith("-")) ?? ""; + return cmdInstallSkill(repo, args.includes("--allow-unsafe")); + } case "list-mcps": return cmdListMcps(json); case "list-tools": diff --git a/src/commands/security.test.ts b/src/commands/security.test.ts index e1a4d541..d51f62a8 100644 --- a/src/commands/security.test.ts +++ b/src/commands/security.test.ts @@ -7,7 +7,7 @@ */ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -55,7 +55,8 @@ describe.skipIf(!BUN_SPAWNABLE)("security gate", () => { const res = spawnSync("bun", ["-e", script], { encoding: "utf8", timeout: 20000, - env: { ...process.env, HOME: home, CUE_REPO_ROOT: repo }, + // CUE_SKILLSPECTOR=0 keeps these cases about the regex rules alone. + env: { ...process.env, HOME: home, CUE_REPO_ROOT: repo, CUE_SKILLSPECTOR: "0" }, }); return JSON.parse((res.stdout ?? "").trim().split("\n").pop() ?? "{}"); } @@ -86,3 +87,95 @@ describe.skipIf(!BUN_SPAWNABLE)("security gate", () => { expect(r.missingScanned).toBe(false); }); }); + +// A skill that cue's own SEC1-7 regex rules have nothing to say about. Any +// block here therefore comes from SkillSpector, which is the point. +const CLEAN_SKILL = + "---\nname: clean\ndescription: formats markdown tables\n---\n" + + "# Clean\nRun `bun run format` and commit the result.\n"; + +describe.skipIf(!BUN_SPAWNABLE)("SkillSpector install gate", () => { + let home: string; + let repo: string; + let bin: string; + + beforeEach(() => { + const fake = mkdtempSync(join(tmpdir(), "cue-ss-gate-")); + home = join(fake, "home"); + repo = join(fake, "repo"); + bin = join(fake, "fake-skillspector"); + mkdirSync(join(home, ".claude", "skills", "clean"), { recursive: true }); + mkdirSync(repo, { recursive: true }); + writeFileSync(join(home, ".claude", "skills", "clean", "SKILL.md"), CLEAN_SKILL); + }); + afterEach(() => rmSync(join(home, ".."), { recursive: true, force: true })); + + /** Fake scanner emitting a fixed verdict. `null` = no scanner on the box. */ + function withVerdict(verdict: string | null, severity = "HIGH", score = 80) { + if (verdict === null) return join(home, "..", "no-such-binary"); + const body = JSON.stringify({ + risk_assessment: { score, severity, recommendation: verdict }, + issues: [{ id: "PI1", category: "prompt-injection", severity, location: { file: "SKILL.md", start_line: 3 } }], + }); + writeFileSync(bin, `#!/bin/sh\ncat <<'JSON'\n${body}\nJSON\n`); + chmodSync(bin, 0o755); + return bin; + } + + function gate(scanner: string, allowUnsafe = false): { + ok: boolean; + codes: string[]; + status: string; + recommendation?: string; + } { + const script = + `import { gateFreshSkill } from ${JSON.stringify(SECURITY_TS)};\n` + + `const g = gateFreshSkill("clean", { allowUnsafe: ${allowUnsafe} });\n` + + `console.log(JSON.stringify({ ok: g.ok, codes: g.issues.map(i=>i.code),\n` + + ` status: g.skillspector.status, recommendation: g.skillspector.recommendation }));`; + const res = spawnSync("bun", ["-e", script], { + encoding: "utf8", + timeout: 20000, + env: { + ...process.env, + HOME: home, + CUE_REPO_ROOT: repo, + CUE_SKILLSPECTOR: "1", + SKILLSPECTOR_BIN: scanner, + }, + }); + return JSON.parse((res.stdout ?? "").trim().split("\n").pop() ?? "{}"); + } + + test("DO_NOT_INSTALL blocks a skill the regex rules consider clean", () => { + const r = gate(withVerdict("DO_NOT_INSTALL")); + expect(r.recommendation).toBe("DO_NOT_INSTALL"); + expect(r.codes).toContain("SS1"); + expect(r.ok).toBe(false); + }); + + test("--allow-unsafe overrides a DO_NOT_INSTALL block", () => { + const r = gate(withVerdict("DO_NOT_INSTALL"), true); + expect(r.ok).toBe(true); + expect(r.codes).toContain("SS1"); // still reported, just not blocking + }); + + test("CAUTION reports but does not block", () => { + const r = gate(withVerdict("CAUTION", "MEDIUM", 35)); + expect(r.ok).toBe(true); + expect(r.codes).toContain("SS1"); + }); + + test("SAFE adds no finding at all", () => { + const r = gate(withVerdict("SAFE", "LOW", 2)); + expect(r.ok).toBe(true); + expect(r.codes).not.toContain("SS1"); + }); + + test("a missing scanner falls back to the regex rules instead of blocking", () => { + const r = gate(withVerdict(null)); + expect(r.status).toBe("unavailable"); + expect(r.ok).toBe(true); // fail-open: clean skill still installs + expect(r.codes).not.toContain("SS1"); + }); +}); diff --git a/src/commands/security.ts b/src/commands/security.ts index 51ea4a8e..2b806592 100644 --- a/src/commands/security.ts +++ b/src/commands/security.ts @@ -18,6 +18,13 @@ import { homedir } from "node:os"; import { listAllSkillIds } from "../lib/resolver-local"; import { loadProfile, } from "../lib/profile-loader"; import { repoRoot } from "../lib/repo-root"; +import { + runSkillSpector, + formatVerdict, + unavailableNote, + SKILLSPECTOR_INSTALL_HINT, + type SkillSpectorReport, +} from "../lib/skillspector"; const SKILLS_ROOT = join(repoRoot(), "resources", "skills", "skills"); const GLOBAL_SKILLS_ROOT = join(homedir(), ".claude", "skills"); @@ -228,27 +235,70 @@ export function scanSkill(id: string, opts: { trustGlobalPack?: boolean } = {}): return issues; } +/** Where a skill's files live: repo copy first, then the global install dir. */ +export function resolveSkillDir(skillId: string): string | null { + for (const root of [SKILLS_ROOT, GLOBAL_SKILLS_ROOT]) { + if (existsSync(join(root, skillId, "SKILL.md"))) return join(root, skillId); + } + return null; +} + +export interface GateResult { + ok: boolean; + issues: SecurityIssue[]; + critical: SecurityIssue[]; + scanned: boolean; + skillspector: SkillSpectorReport; +} + /** - * Enforcement gate for a freshly-fetched remote skill. Scans with category - * suppressions OFF (the skill is untrusted), and treats SEC1-3 (secret - * exfiltration, data exfiltration, safety-override / prompt injection) as - * blocking. `allowUnsafe` lets the caller override. Pure (only scanSkill's - * file read); the caller owns all messaging. + * Enforcement gate for a freshly-fetched remote skill. + * + * Two scanners run, and the union decides: + * 1. NVIDIA SkillSpector (68 patterns / 17 categories, AST + YARA + OSV). + * `DO_NOT_INSTALL` blocks, `CAUTION` is reported but still registers, + * `SAFE` passes. See lib/skillspector.ts for runner resolution. + * 2. cue's own SEC1-7 regex rules, with category suppressions OFF (the skill + * is untrusted). SEC1-3 block. This is the fallback that still works when + * SkillSpector isn't installed. + * + * A missing/failed SkillSpector never blocks on its own — callers surface + * `unavailableNote()` and fall through to the regex rules. `allowUnsafe` lets + * the caller override any block. The caller owns all messaging. */ export function gateFreshSkill( skillId: string, opts: { allowUnsafe?: boolean } = {}, -): { ok: boolean; issues: SecurityIssue[]; critical: SecurityIssue[]; scanned: boolean } { +): GateResult { // Did we actually find a SKILL.md to scan? If a skill installs at a subpath // (not ~/.claude/skills//SKILL.md), scanSkill returns [] for "nothing // found" — indistinguishable from "clean" without this. Callers should warn // when scanned===false rather than treat it as a pass. - const scanned = - existsSync(join(SKILLS_ROOT, skillId, "SKILL.md")) || - existsSync(join(GLOBAL_SKILLS_ROOT, skillId, "SKILL.md")); + const dir = resolveSkillDir(skillId); + const scanned = dir !== null; const issues = scanSkill(skillId, { trustGlobalPack: false }); + + const skillspector: SkillSpectorReport = dir + ? runSkillSpector(dir) + : { status: "unavailable", findings: [], error: "no SKILL.md found to scan" }; + + if (skillspector.status === "ok" && skillspector.recommendation !== "SAFE") { + issues.push({ + code: "SS1", + severity: skillspector.recommendation === "DO_NOT_INSTALL" ? "critical" : "high", + skill: skillId, + message: formatVerdict(skillspector), + }); + } + const critical = issues.filter((issue) => issue.severity === "critical"); - return { ok: critical.length === 0 || opts.allowUnsafe === true, issues, critical, scanned }; + return { + ok: critical.length === 0 || opts.allowUnsafe === true, + issues, + critical, + scanned, + skillspector, + }; } /** Check if a line index is inside a fenced code block */ @@ -260,11 +310,57 @@ function isInsideCodeBlock(lines: string[], idx: number): boolean { return inside; } +/** + * `cue security scan ` — run SkillSpector directly against any directory + * or SKILL.md. This is the "review it, then decide" companion to the install + * gate: when a skill is blocked it stays on disk, and this prints the findings. + * Exit code mirrors the gate: 1 when the verdict is DO_NOT_INSTALL. + */ +async function cmdSkillSpectorScan(args: string[]): Promise { + const json = args.includes("--json"); + const target = args.find((a) => !a.startsWith("-")); + if (!target) { + process.stderr.write("Usage: cue security scan [--json]\n"); + return 2; + } + if (!existsSync(target)) { + process.stderr.write(`Path not found: ${target}\n`); + return 2; + } + + // Explicit user request — run even under NODE_ENV=test, where the gate is off. + const report = runSkillSpector(target, { env: { ...process.env, CUE_SKILLSPECTOR: "1" } }); + + if (json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return report.recommendation === "DO_NOT_INSTALL" ? 1 : report.status === "ok" ? 0 : 2; + } + + const note = unavailableNote(report); + if (note) { + process.stderr.write(`${note}\n`); + if (report.status === "unavailable") { + process.stderr.write(` Install it with: ${SKILLSPECTOR_INSTALL_HINT}\n`); + } + return 2; + } + + const icon = report.recommendation === "SAFE" ? "🟢" : report.recommendation === "CAUTION" ? "🟡" : "🔴"; + process.stdout.write(`${icon} ${formatVerdict(report)}\n`); + for (const f of report.findings) { + const where = f.file ? ` ${f.file}${f.line ? `:${f.line}` : ""}` : ""; + process.stdout.write(` [${f.severity}] ${f.id} ${f.category}${where}\n`); + } + process.stdout.write(` scanner: ${report.runner}\n`); + return report.recommendation === "DO_NOT_INSTALL" ? 1 : 0; +} + export async function run(args: string[]): Promise { if (args.includes("-h") || args.includes("--help")) { process.stdout.write(`cue security — scan skills for prompt injection & secret exfiltration Usage: cue security [profile|--all] + cue security scan Checks: SEC1 Reads/exposes secrets (API keys, .env, credentials) [critical] @@ -274,16 +370,25 @@ Checks: SEC5 Disables security controls [high] SEC6 Encoded/obfuscated content [medium] SEC7 Modifies shell config/crontab/sudoers [high] + SS1 NVIDIA SkillSpector verdict (install gate) [critical/high] Examples: cue security # scan active profile cue security --all # scan ALL skills cue security backend # scan one profile cue security --json # machine-readable + cue security scan ./my-skill/ # deep SkillSpector scan of any path + +SkillSpector (the SS1 gate) runs automatically on every skill install: + cue skills add / cue discover install / cue marketplace install-skill. + DO_NOT_INSTALL blocks (override with --allow-unsafe), CAUTION warns. + Set CUE_SKILLSPECTOR=0 to disable. Install: ${SKILLSPECTOR_INSTALL_HINT} `); return 0; } + if (args[0] === "scan") return cmdSkillSpectorScan(args.slice(1)); + const json = args.includes("--json"); const all = args.includes("--all"); const profileName = args.find(a => !a.startsWith("-")); diff --git a/src/commands/skills.ts b/src/commands/skills.ts index ae305501..69ef2873 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -23,6 +23,7 @@ import { resolveActiveProfile } from "../lib/cwd-resolver"; import { listAllSkillIds } from "../lib/resolver-local"; import { fetchCompanionFiles, readSourceFile, findIncompleteSkills } from "../lib/companion-fetch"; import { gateFreshSkill } from "./security"; +import { unavailableNote, formatVerdict } from "../lib/skillspector"; const PROFILES_DIR = process.env.CUE_PROFILES_DIR ?? join(repoRoot(), "profiles"); const SKILLS_ROOT = join(repoRoot(), "resources", "skills", "skills"); @@ -494,12 +495,16 @@ async function cmdNpxAdd(args: string[]): Promise { if (newSkills.length === 0) return 0; // Security gate: scan freshly-fetched skills before the profile hook below - // registers any into profile.yaml. Block criticals (SEC1-3) unless - // --allow-unsafe; flagged skills stay on disk but are dropped from the set. + // registers any into profile.yaml. NVIDIA SkillSpector's DO_NOT_INSTALL and + // cue's own criticals (SEC1-3) block unless --allow-unsafe; flagged skills + // stay on disk but are dropped from the set. { + process.stdout.write(`🔍 Scanning ${newSkills.length} new skill(s) with NVIDIA SkillSpector…\n`); const blocked: string[] = []; for (const slug of newSkills) { const gate = gateFreshSkill(slug, { allowUnsafe }); + const note = unavailableNote(gate.skillspector); + if (note) process.stderr.write(` ${note}\n`); if (!gate.ok) { blocked.push(slug); process.stderr.write(`🔴 BLOCKED ${slug}: ${gate.critical.length} critical security finding(s)\n`); @@ -508,6 +513,10 @@ async function cmdNpxAdd(args: string[]): Promise { } } else if (!gate.scanned) { process.stderr.write(`⚠️ ${slug}: no SKILL.md found to scan — review manually.\n`); + } else if (gate.skillspector.recommendation === "CAUTION") { + process.stderr.write(`🟡 ${slug}: ${formatVerdict(gate.skillspector)} — registered, review it.\n`); + } else if (gate.skillspector.recommendation === "SAFE") { + process.stdout.write(`🟢 ${slug}: SkillSpector SAFE\n`); } } if (blocked.length > 0) { diff --git a/src/lib/skillspector.test.ts b/src/lib/skillspector.test.ts new file mode 100644 index 00000000..8342774b --- /dev/null +++ b/src/lib/skillspector.test.ts @@ -0,0 +1,202 @@ +/** + * NVIDIA SkillSpector integration. + * + * The parsing/argv layer is tested pure. The spawn layer is tested against a + * fake scanner binary pointed at by $SKILLSPECTOR_BIN, which both fakes a + * verdict and records the argv it was called with — that argv is the upstream + * contract we depend on (`scan --no-llm --format json`). + */ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + isEnabled, + buildRunner, + parseSkillSpectorJson, + formatVerdict, + unavailableNote, + runSkillSpector, + resetRunnerCache, + SKILLSPECTOR_PKG, +} from "./skillspector"; + +function report(assessment: Record, issues: unknown[] = []) { + return JSON.stringify({ risk_assessment: assessment, issues }); +} + +describe("isEnabled", () => { + test("on by default outside tests", () => { + expect(isEnabled({})).toBe(true); + }); + + test("off when explicitly disabled", () => { + for (const v of ["0", "false", "off", "no", "OFF"]) { + expect(isEnabled({ CUE_SKILLSPECTOR: v })).toBe(false); + } + }); + + test("off under NODE_ENV=test so suites never shell out", () => { + expect(isEnabled({ NODE_ENV: "test" })).toBe(false); + }); + + test("explicit opt-in beats the NODE_ENV=test guard", () => { + expect(isEnabled({ NODE_ENV: "test", CUE_SKILLSPECTOR: "1" })).toBe(true); + }); + + test("explicit off beats explicit on-ish NODE_ENV", () => { + expect(isEnabled({ NODE_ENV: "production", CUE_SKILLSPECTOR: "0" })).toBe(false); + }); +}); + +describe("buildRunner", () => { + test("uvx runs the package straight from git, scanning the host path", () => { + const r = buildRunner("uvx", "/skills/evil"); + expect(r.cmd).toBe("uvx"); + expect(r.args).toEqual(["--from", SKILLSPECTOR_PKG, "skillspector"]); + expect(r.target).toBe("/skills/evil"); + }); + + test("docker mounts read-only and rewrites the target to the container path", () => { + const r = buildRunner("docker", "/skills/evil"); + expect(r.args).toContain("-v"); + expect(r.args).toContain("/skills/evil:/scan:ro"); + // The host path must NOT be handed to `scan` — it does not exist in the container. + expect(r.target).toBe("/scan"); + }); + + test("explicit binary wins verbatim", () => { + const r = buildRunner("bin", "/skills/evil", "/opt/skillspector"); + expect(r.cmd).toBe("/opt/skillspector"); + expect(r.args).toEqual([]); + }); +}); + +describe("parseSkillSpectorJson", () => { + test("reads the recommendation, score and severity", () => { + const r = parseSkillSpectorJson(report({ score: 78, severity: "HIGH", recommendation: "DO_NOT_INSTALL" })); + expect(r.status).toBe("ok"); + expect(r.recommendation).toBe("DO_NOT_INSTALL"); + expect(r.score).toBe(78); + expect(r.severity).toBe("HIGH"); + }); + + test("maps findings including file/line", () => { + const r = parseSkillSpectorJson( + report({ score: 40, severity: "MEDIUM", recommendation: "CAUTION" }, [ + { id: "PI1", category: "prompt-injection", severity: "high", location: { file: "SKILL.md", start_line: 12 } }, + ]), + ); + expect(r.findings).toEqual([ + { id: "PI1", category: "prompt-injection", severity: "HIGH", file: "SKILL.md", line: 12 }, + ]); + }); + + test("falls back to the documented severity mapping when recommendation is absent", () => { + expect(parseSkillSpectorJson(report({ severity: "LOW" })).recommendation).toBe("SAFE"); + expect(parseSkillSpectorJson(report({ severity: "MEDIUM" })).recommendation).toBe("CAUTION"); + expect(parseSkillSpectorJson(report({ severity: "CRITICAL" })).recommendation).toBe("DO_NOT_INSTALL"); + }); + + test("tolerates progress noise printed before the JSON", () => { + const r = parseSkillSpectorJson(`Scanning...\n${report({ severity: "LOW", recommendation: "SAFE" })}\n`); + expect(r.status).toBe("ok"); + expect(r.recommendation).toBe("SAFE"); + }); + + test("no verdict is an error, never a silent pass", () => { + expect(parseSkillSpectorJson("").status).toBe("error"); + expect(parseSkillSpectorJson("not json at all").status).toBe("error"); + expect(parseSkillSpectorJson("{oops").status).toBe("error"); + // Valid JSON, but no risk_assessment — must not read as SAFE. + const r = parseSkillSpectorJson('{"skill":{"name":"x"}}'); + expect(r.status).toBe("error"); + expect(r.recommendation).toBeUndefined(); + }); +}); + +describe("formatVerdict / unavailableNote", () => { + test("verdict line carries score and finding categories", () => { + const r = parseSkillSpectorJson( + report({ score: 78, severity: "HIGH", recommendation: "DO_NOT_INSTALL" }, [ + { id: "PI1", category: "prompt-injection", severity: "HIGH" }, + { id: "DE2", category: "data-exfiltration", severity: "HIGH" }, + ]), + ); + const line = formatVerdict(r); + expect(line).toContain("DO_NOT_INSTALL"); + expect(line).toContain("78/100"); + expect(line).toContain("prompt-injection"); + expect(line).toContain("data-exfiltration"); + }); + + test("a scan that ran produces no warning note; a skipped one does", () => { + expect(unavailableNote({ status: "ok", findings: [], recommendation: "SAFE" })).toBeNull(); + expect(unavailableNote({ status: "disabled", findings: [] })).toBeNull(); + expect(unavailableNote({ status: "unavailable", findings: [], error: "not found" })).toContain("not found"); + expect(unavailableNote({ status: "error", findings: [], error: "boom" })).toContain("boom"); + }); +}); + +describe("runSkillSpector", () => { + let dir: string; + let bin: string; + let argvLog: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "cue-ss-")); + bin = join(dir, "fake-skillspector"); + argvLog = join(dir, "argv.txt"); + resetRunnerCache(); + }); + afterEach(() => { + resetRunnerCache(); + rmSync(dir, { recursive: true, force: true }); + }); + + function writeFakeScanner(stdout: string, exitCode = 0) { + writeFileSync( + bin, + `#!/bin/sh\nprintf '%s\\n' "$*" > ${JSON.stringify(argvLog)}\ncat <<'JSON'\n${stdout}\nJSON\nexit ${exitCode}\n`, + ); + chmodSync(bin, 0o755); + } + + test("is a no-op when disabled", () => { + writeFakeScanner(report({ severity: "LOW", recommendation: "SAFE" })); + const r = runSkillSpector(dir, { env: { CUE_SKILLSPECTOR: "0", SKILLSPECTOR_BIN: bin } }); + expect(r.status).toBe("disabled"); + }); + + test("invokes the upstream contract: scan --no-llm --format json", () => { + writeFakeScanner(report({ score: 0, severity: "LOW", recommendation: "SAFE" })); + const r = runSkillSpector(dir, { env: { CUE_SKILLSPECTOR: "1", SKILLSPECTOR_BIN: bin } }); + expect(r.status).toBe("ok"); + expect(r.recommendation).toBe("SAFE"); + const argv = readFileSync(argvLog, "utf8").trim(); + expect(argv).toBe(`scan ${dir} --no-llm --format json`); + }); + + test("exit code 1 (DO_NOT_INSTALL) still yields a parsed verdict, not an error", () => { + writeFakeScanner(report({ score: 90, severity: "CRITICAL", recommendation: "DO_NOT_INSTALL" }), 1); + const r = runSkillSpector(dir, { env: { CUE_SKILLSPECTOR: "1", SKILLSPECTOR_BIN: bin } }); + expect(r.status).toBe("ok"); + expect(r.recommendation).toBe("DO_NOT_INSTALL"); + }); + + test("a missing scanner is 'unavailable', never a pass", () => { + const r = runSkillSpector(dir, { + env: { CUE_SKILLSPECTOR: "1", SKILLSPECTOR_BIN: join(dir, "does-not-exist") }, + }); + expect(r.status).toBe("unavailable"); + expect(r.recommendation).toBeUndefined(); + }); + + test("a scanner that emits garbage is an error, never a pass", () => { + writeFakeScanner("totally not json", 0); + const r = runSkillSpector(dir, { env: { CUE_SKILLSPECTOR: "1", SKILLSPECTOR_BIN: bin } }); + expect(r.status).toBe("error"); + expect(r.recommendation).toBeUndefined(); + }); +}); diff --git a/src/lib/skillspector.ts b/src/lib/skillspector.ts new file mode 100644 index 00000000..72a0d359 --- /dev/null +++ b/src/lib/skillspector.ts @@ -0,0 +1,279 @@ +/** + * NVIDIA SkillSpector integration — deep security scan for freshly-fetched skills. + * + * SkillSpector (https://github.com/NVIDIA/SkillSpector) is a security scanner for + * agent skills: 68 vulnerability patterns across 17 categories (prompt injection, + * data exfiltration, supply chain, excessive agency, dangerous code via AST, + * YARA signatures, MCP tool poisoning, …). cue runs it as an install gate in + * front of every path that lands a new skill on disk. + * + * Contract we rely on (documented as stable by upstream): + * `skillspector scan --no-llm --format json` writes a JSON report to + * stdout with `risk_assessment.{score,severity,recommendation}` and `issues[]`. + * Exit codes: 0 = SAFE|CAUTION, 1 = DO_NOT_INSTALL, 2 = error. We read the + * `recommendation` field rather than the exit code so CAUTION and SAFE can be + * treated differently (see security.ts for the policy). + * + * Runner resolution order (first hit wins, cached per process): + * 1. $SKILLSPECTOR_BIN explicit override + * 2. `skillspector` on PATH uv tool install / pip install + * 3. `uvx --from git+…` no install step; uv caches after first run + * 4. `docker run skillspector` only if the image is already built locally + * + * Privacy: we always pass `--no-llm`, so file contents never leave the machine. + * The supply-chain check (SC4) still queries OSV.dev with dependency names only, + * and falls back to a bundled list when offline. + */ + +import { spawnSync } from "node:child_process"; + +/** Upstream package spec for the uvx fallback. */ +export const SKILLSPECTOR_PKG = "git+https://github.com/NVIDIA/skillspector.git"; + +/** Install hint printed when no runner is available. */ +export const SKILLSPECTOR_INSTALL_HINT = + "uv tool install git+https://github.com/NVIDIA/skillspector.git"; + +const DEFAULT_TIMEOUT_MS = 180_000; + +export type Recommendation = "SAFE" | "CAUTION" | "DO_NOT_INSTALL"; + +export interface SkillSpectorFinding { + id: string; + category: string; + severity: string; + file?: string; + line?: number; +} + +export interface SkillSpectorReport { + /** ok = scan produced a verdict; the rest mean we have no verdict. */ + status: "ok" | "disabled" | "unavailable" | "error"; + /** Human-readable command used, for logs. */ + runner?: string; + recommendation?: Recommendation; + score?: number; + severity?: string; + findings: SkillSpectorFinding[]; + error?: string; +} + +export type RunnerKind = "bin" | "path" | "uvx" | "docker"; + +export interface Runner { + kind: RunnerKind; + cmd: string; + args: string[]; + /** Path to hand to `scan` — differs from the host path under docker. */ + target: string; + label: string; +} + +/** + * Is the gate active? Enabled by default. + * + * `CUE_SKILLSPECTOR=0|false|off` turns it off. Test runs are off unless the + * variable is explicitly truthy, so a `bun test` never shells out to uvx or + * reaches the network. + */ +export function isEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = (env.CUE_SKILLSPECTOR ?? "").trim().toLowerCase(); + if (raw === "0" || raw === "false" || raw === "off" || raw === "no") return false; + const explicitlyOn = raw === "1" || raw === "true" || raw === "on" || raw === "yes"; + if (env.NODE_ENV === "test" && !explicitlyOn) return false; + return true; +} + +function which(bin: string): boolean { + const probe = process.platform === "win32" ? "where" : "which"; + return spawnSync(probe, [bin], { stdio: "ignore" }).status === 0; +} + +function hasDockerImage(): boolean { + if (!which("docker")) return false; + return spawnSync("docker", ["image", "inspect", "skillspector"], { stdio: "ignore" }).status === 0; +} + +/** + * Build the argv for a given runner kind. Pure — the docker case rewrites the + * scan target to the container mount point, which is why callers must use + * `runner.target` instead of the host path they passed in. + */ +export function buildRunner(kind: RunnerKind, dir: string, bin?: string): Runner { + switch (kind) { + case "bin": + return { kind, cmd: bin!, args: [], target: dir, label: bin! }; + case "path": + return { kind, cmd: "skillspector", args: [], target: dir, label: "skillspector" }; + case "uvx": + return { + kind, + cmd: "uvx", + args: ["--from", SKILLSPECTOR_PKG, "skillspector"], + target: dir, + label: "uvx skillspector", + }; + case "docker": + return { + kind, + cmd: "docker", + args: ["run", "--rm", "-v", `${dir}:/scan:ro`, "skillspector"], + target: "/scan", + label: "docker skillspector", + }; + } +} + +let cachedKind: { kind: RunnerKind; bin?: string } | null | undefined; + +/** Reset the memoized runner lookup (tests). */ +export function resetRunnerCache(): void { + cachedKind = undefined; +} + +function detectRunnerKind(env: NodeJS.ProcessEnv): { kind: RunnerKind; bin?: string } | null { + if (cachedKind !== undefined) return cachedKind; + let found: { kind: RunnerKind; bin?: string } | null = null; + const bin = env.SKILLSPECTOR_BIN?.trim(); + if (bin) found = { kind: "bin", bin }; + else if (which("skillspector")) found = { kind: "path" }; + else if (which("uvx")) found = { kind: "uvx" }; + else if (hasDockerImage()) found = { kind: "docker" }; + cachedKind = found; + return found; +} + +/** + * Pull the JSON report out of stdout. SkillSpector writes the report to stdout + * with `--format json`, but we slice from the first `{` so any progress noise + * on the same stream can't break parsing. + */ +export function parseSkillSpectorJson(stdout: string): SkillSpectorReport { + const start = stdout.indexOf("{"); + const end = stdout.lastIndexOf("}"); + if (start === -1 || end <= start) { + return { status: "error", findings: [], error: "no JSON report in scanner output" }; + } + + let raw: any; + try { + raw = JSON.parse(stdout.slice(start, end + 1)); + } catch (e) { + return { status: "error", findings: [], error: `unparseable JSON report: ${(e as Error).message}` }; + } + + const assessment = raw?.risk_assessment ?? {}; + const severity = typeof assessment.severity === "string" ? assessment.severity.toUpperCase() : undefined; + let recommendation: Recommendation | undefined; + const rec = typeof assessment.recommendation === "string" ? assessment.recommendation.toUpperCase() : ""; + if (rec === "SAFE" || rec === "CAUTION" || rec === "DO_NOT_INSTALL") { + recommendation = rec; + } else if (severity) { + // Upstream's documented severity → recommendation mapping, used only when + // the field is absent (older builds). + recommendation = severity === "LOW" ? "SAFE" : severity === "MEDIUM" ? "CAUTION" : "DO_NOT_INSTALL"; + } + + if (!recommendation) { + return { status: "error", findings: [], error: "report has no risk_assessment.recommendation" }; + } + + const findings: SkillSpectorFinding[] = Array.isArray(raw?.issues) + ? raw.issues.map((i: any) => ({ + id: String(i?.id ?? "?"), + category: String(i?.category ?? "uncategorized"), + severity: String(i?.severity ?? "unknown").toUpperCase(), + file: typeof i?.location?.file === "string" ? i.location.file : undefined, + line: typeof i?.location?.start_line === "number" ? i.location.start_line : undefined, + })) + : []; + + return { + status: "ok", + recommendation, + score: typeof assessment.score === "number" ? assessment.score : undefined, + severity, + findings, + }; +} + +/** + * Scan a skill directory (or SKILL.md) with SkillSpector. + * + * Never throws: a missing scanner, a crash, or a timeout comes back as a + * non-`ok` status so the caller can decide the policy. Blocking decisions are + * made by the caller from `recommendation`, not from the exit code. + */ +export function runSkillSpector( + dir: string, + opts: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, +): SkillSpectorReport { + const env = opts.env ?? process.env; + if (!isEnabled(env)) { + return { status: "disabled", findings: [] }; + } + + const detected = detectRunnerKind(env); + if (!detected) { + return { + status: "unavailable", + findings: [], + error: `SkillSpector not found. Install it with: ${SKILLSPECTOR_INSTALL_HINT}`, + }; + } + + const runner = buildRunner(detected.kind, dir, detected.bin); + const timeout = Number(env.CUE_SKILLSPECTOR_TIMEOUT_MS ?? opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + const res = spawnSync( + runner.cmd, + [...runner.args, "scan", runner.target, "--no-llm", "--format", "json"], + { encoding: "utf8", timeout, stdio: ["ignore", "pipe", "pipe"], env: env as NodeJS.ProcessEnv }, + ); + + if (res.error) { + const timedOut = (res.error as NodeJS.ErrnoException).code === "ETIMEDOUT"; + return { + status: timedOut ? "error" : "unavailable", + runner: runner.label, + findings: [], + error: timedOut ? `scan timed out after ${timeout}ms` : res.error.message, + }; + } + + const report = parseSkillSpectorJson(res.stdout ?? ""); + report.runner = runner.label; + if (report.status !== "ok" && res.status === 2) { + // Exit 2 is upstream's "bad input / internal failure" — surface its stderr. + report.error = (res.stderr ?? "").trim().split("\n").pop() || report.error; + } + return report; +} + +/** One-line summary for CLI output, e.g. `SkillSpector: CAUTION (risk 34/100, MEDIUM)`. */ +export function formatVerdict(report: SkillSpectorReport): string { + if (report.status !== "ok") return `SkillSpector: not run (${report.error ?? report.status})`; + const score = report.score === undefined ? "" : ` (risk ${report.score}/100, ${report.severity})`; + const count = report.findings.length; + const tail = count > 0 ? ` — ${count} finding(s): ${summarizeCategories(report.findings)}` : ""; + return `SkillSpector: ${report.recommendation}${score}${tail}`; +} + +function summarizeCategories(findings: SkillSpectorFinding[]): string { + const seen: string[] = []; + for (const f of findings) { + if (!seen.includes(f.category)) seen.push(f.category); + if (seen.length === 4) break; + } + const extra = new Set(findings.map((f) => f.category)).size - seen.length; + return extra > 0 ? `${seen.join(", ")} +${extra} more` : seen.join(", "); +} + +/** + * Warning line for the cases where we have no verdict, so every call site + * reports a skipped scan the same way. Returns null when a scan did run. + */ +export function unavailableNote(report: SkillSpectorReport): string | null { + if (report.status === "ok" || report.status === "disabled") return null; + return `⚠️ SkillSpector scan skipped: ${report.error ?? report.status}`; +} From 8112f7bb36586dc9bebd3caad95cd3ffb861a598 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 7 Aug 2026 04:57:22 +0200 Subject: [PATCH 17/33] fix(materializer): stop unresolving the live runtime path mid-swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rematerializing did `rm -rf runtimeDir` and only then renamed the new tree into place, so the live path stayed nonexistent for the whole recursive delete — seconds, on a runtime carrying a plugin cache and a backup chain. A Claude Code session already running against that profile resolves its hooks through exactly that path, so every hook firing inside the gap died with "No such file or directory" (observed 2026-08-03: nine Stop hooks at once, mid-session). Move the old tree aside instead: the path is unresolvable only between two renames, and the delete runs after the new runtime is live. The `.old-*` dir is a sibling of the swap target, so it cannot cross a filesystem boundary and sits one level below the root runtime-gc scans. Leftovers from a swap killed between the renames are swept best-effort on the next materialize. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/runtime-materializer.test.ts | 58 ++++++++++++++++++++++++++++ src/lib/runtime-materializer.ts | 47 +++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/lib/runtime-materializer.test.ts b/src/lib/runtime-materializer.test.ts index c2601004..01ee593c 100644 --- a/src/lib/runtime-materializer.test.ts +++ b/src/lib/runtime-materializer.test.ts @@ -1373,3 +1373,61 @@ describe("materializeRuntime — session-telemetry gating", () => { expect(onBytes).toBeGreaterThanOrEqual(offBytes); }); }); + +/** + * The rebuild swap used to be `rm -rf runtimeDir` followed by `rename(tmp)`, + * which left CLAUDE_CONFIG_DIR nonexistent for the entire recursive delete. + * A Claude Code session already running against the profile resolves its hooks + * through that path, so every hook firing in the gap died with "No such file or + * directory" (2026-08-03: nine Stop hooks at once). + * + * The swap now moves the old tree to a `.old--` sibling first. These + * tests pin the observable half of that — the sibling is transient, and a + * leftover from a swap killed between the two renames gets swept. They do NOT + * pin the ordering itself; a revert to rm-then-rename would still pass, so keep + * the comment above the swap in runtime-materializer.ts. + */ +describe("materializeRuntime — rebuild swap leftovers", () => { + const swapArgs = (runtimeRoot: string) => ({ + profile: sampleProfile, + agent: "claude-code" as const, + runtimeRoot, + skillSourceLookup: async (id: string) => `/fake/skills/${id}`, + mcpRegistry: { "claude-mem": { command: "claude-mem", args: [] } }, + userClaudeMd: "# user CLAUDE.md\n", + }); + + const swapSiblings = async (runtimeDir: string) => { + const { readdir } = await import("node:fs/promises"); + const { dirname, basename } = await import("node:path"); + const names = await readdir(dirname(runtimeDir)); + return names.filter((n) => n.startsWith(`${basename(runtimeDir)}.old-`)); + }; + + test("a rebuild leaves no .old-* sibling behind", async () => { + const runtimeRoot = join(root, "runtime"); + const first = await materializeRuntime(swapArgs(runtimeRoot)); + // Force a real rebuild rather than the hash-unchanged fast path. + await writeFile(join(first.runtimeDir, ".cue-hash"), "0".repeat(64)); + const second = await materializeRuntime(swapArgs(runtimeRoot)); + + expect(second.rebuilt).toBe(true); + expect(await swapSiblings(second.runtimeDir)).toEqual([]); + // The runtime is intact, not a half-swapped shell. + expect(JSON.parse(await readFile(join(second.runtimeDir, "settings.json"), "utf8"))).toBeTruthy(); + }); + + test("sweeps a .old-* left by a swap that died between the two renames", async () => { + const runtimeRoot = join(root, "runtime"); + const first = await materializeRuntime(swapArgs(runtimeRoot)); + + const stale = `${first.runtimeDir}.old-99999-deadbeef`; + await mkdir(stale, { recursive: true }); + await writeFile(join(stale, "junk"), "x"); + await writeFile(join(first.runtimeDir, ".cue-hash"), "0".repeat(64)); + + const second = await materializeRuntime(swapArgs(runtimeRoot)); + + expect(await swapSiblings(second.runtimeDir)).toEqual([]); + }); +}); diff --git a/src/lib/runtime-materializer.ts b/src/lib/runtime-materializer.ts index eb37c737..77ec6f48 100644 --- a/src/lib/runtime-materializer.ts +++ b/src/lib/runtime-materializer.ts @@ -828,8 +828,33 @@ export async function materializeRuntime(input: MaterializeInput): Promise { + /* the new runtime is already live; a stale .old-* is swept below */ + }); + } + await sweepStaleSwapDirs(runtimeDir); if (agent === "claude-code") { await syncMcpsIntoClaudeJson(runtimeDir, mcpServers, effectiveInput.disabledMcpIds); @@ -838,6 +863,26 @@ export async function materializeRuntime(input: MaterializeInput): Promise.old-*` leftovers from an earlier swap that was killed + * between the two renames. Best-effort and never fatal: the runtime it belongs + * to is already live, so a leftover only wastes disk. + */ +async function sweepStaleSwapDirs(runtimeDir: string): Promise { + const parent = dirname(runtimeDir); + const prefix = `${basename(runtimeDir)}.old-`; + try { + const names = await readdir(parent); + await Promise.all( + names + .filter((name) => name.startsWith(prefix)) + .map((name) => rm(join(parent, name), { recursive: true, force: true }).catch(() => {})), + ); + } catch { + /* parent unreadable — nothing to sweep */ + } +} + /** * Read `claudeAiOauth.expiresAt` (ms epoch) from a `.credentials.json`. Returns * 0 when the file is missing, unparseable, or carries no expiry — so anything From ceebde61a75ccbeebb8ad8b923d6cc03ce491048 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 7 Aug 2026 04:57:33 +0200 Subject: [PATCH 18/33] refactor(picker): pull the shared visual primitives out of card and palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 card and the stack palette had grown their own copies of the same drawing code. Both now hang off one set of primitives in picker/ui.ts, styled after iOS grouped-inset lists: one rounded card per idea, uppercase muted section headers instead of heavy rules, a filled pill for the single primary action, circular selection marks instead of ASCII brackets, page dots for "there is more to see here". Everything in ui.ts is pure — no I/O, no TTY — and styleText is a no-op off a TTY, so the tests assert on plain text. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/picker/card.test.ts | 72 ++++++++--- src/lib/picker/card.ts | 200 ++++++++++++++++++++---------- src/lib/picker/palette.test.ts | 77 ++++++++---- src/lib/picker/palette.ts | 197 ++++++++++++++++++++--------- src/lib/picker/ui.test.ts | 184 +++++++++++++++++++++++++++ src/lib/picker/ui.ts | 219 +++++++++++++++++++++++++++++++++ 6 files changed, 783 insertions(+), 166 deletions(-) create mode 100644 src/lib/picker/ui.test.ts create mode 100644 src/lib/picker/ui.ts diff --git a/src/lib/picker/card.test.ts b/src/lib/picker/card.test.ts index b8766357..8c7a570b 100644 --- a/src/lib/picker/card.test.ts +++ b/src/lib/picker/card.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { COMPACT_ROWS, renderCardFrame, type CardSuggestion } from "./card"; +import { displayWidth } from "./render-util"; const rust: CardSuggestion = { parts: ["rust", "secops"], @@ -34,30 +35,45 @@ describe("renderCardFrame", () => { expect(frame).toContain("/home/u/proj"); }); - test("shows a counter only when there is more than one suggestion", () => { - expect(renderCardFrame({ ...base, suggestions: [rust] })).not.toContain("1/1"); + /** The card's top border, where the page indicator rides. */ + const topBorder = (frame: string) => + plain(frame).split("\n").find((l) => l.startsWith("╭")) ?? ""; + + test("page dots appear only when there is more than one suggestion", () => { + expect(topBorder(renderCardFrame({ ...base, suggestions: [rust] }))).not.toContain("○"); const many = renderCardFrame({ ...base, suggestions: [rust, python], index: 1 }); - expect(many).toContain("2/2"); + // One dot per suggestion, filled on the one being shown. + expect(topBorder(many)).toContain("○ ●"); expect(many).toContain("🐍 python"); - expect(many).toContain("↹ next suggestion"); + expect(plain(many)).toContain("↹ next suggestion"); + }); + + test("falls back to a numeric page indicator past eight suggestions", () => { + const many = Array.from({ length: 12 }, () => python); + expect(topBorder(renderCardFrame({ ...base, suggestions: many, index: 2 }))).toContain("3/12"); }); - test("states the pin decision on the launch key", () => { - expect(renderCardFrame({ ...base, suggestions: [rust] })).toContain( - "⏎ launch · pins to this directory", - ); - expect(renderCardFrame({ ...base, suggestions: [rust], pin: false })).toContain("⏎ launch · no pin"); - const disabled = renderCardFrame({ ...base, suggestions: [rust], pinDisabled: true }); + test("shows the pin decision as a switch beside the launch button", () => { + const on = plain(renderCardFrame({ ...base, suggestions: [rust] })); + expect(on).toContain("⏎ launch"); + expect(on).toContain("p ● pin to this folder"); + const off = plain(renderCardFrame({ ...base, suggestions: [rust], pin: false })); + expect(off).toContain("p ○ pin to this folder"); + const disabled = plain(renderCardFrame({ ...base, suggestions: [rust], pinDisabled: true })); expect(disabled).toContain("⏎ launch"); - expect(disabled).not.toContain("pins to this directory"); - expect(disabled).not.toContain("p pin"); + expect(disabled).not.toContain("pin to this folder"); }); - test("warns about a heavy stack, stays quiet about a light one", () => { - expect(renderCardFrame({ ...base, suggestions: [{ ...rust, alwaysOn: 32_000 }] })).toContain( - "⚠ heavy: ~32k always-on", - ); - expect(renderCardFrame({ ...base, suggestions: [rust] })).not.toContain("⚠ heavy"); + test("meters the stack weight and only calls out a heavy one", () => { + const heavy = plain(renderCardFrame({ ...base, suggestions: [{ ...rust, alwaysOn: 32_000 }] })); + expect(heavy).toContain("~32k always-on"); + expect(heavy).toContain("⚠ heavy, slows the agent"); + expect(heavy).toContain("█"); + const light = plain(renderCardFrame({ ...base, suggestions: [rust] })); + expect(light).toContain("~6.0k always-on"); + expect(light).not.toContain("⚠ heavy"); + // A light stack still gets a meter — the bar is a comparison, not a warning. + expect(light).toContain("█"); }); test("help overlay replaces the body and lists every key", () => { @@ -69,10 +85,21 @@ describe("renderCardFrame", () => { test("degrades to a usable frame when there is nothing to suggest", () => { const frame = renderCardFrame({ ...base, suggestions: [] }); - expect(frame).toContain("no profiles available"); + expect(frame).toContain("nothing to suggest"); expect(frame).toContain("a browse every profile"); }); + test("the card's right border lands in one column on every row", () => { + const frame = plain(renderCardFrame({ ...base, suggestions: [{ ...rust, alwaysOn: 32_000 }] })); + const bordered = frame + .split("\n") + .filter((l) => l.startsWith("╭") || l.startsWith("╰") || (l.startsWith("│") && l.endsWith("│") && l.length > 2)); + // Width in terminal cells, not code points — the emoji labels are 2 cells wide. + const widths = new Set(bordered.map(displayWidth)); + expect(bordered.length).toBeGreaterThan(4); + expect(widths.size).toBe(1); + }); + test("ascii mode drops the emoji icons", () => { const frame = renderCardFrame({ ...base, suggestions: [rust], ascii: true }); expect(frame).toContain("rust"); @@ -98,6 +125,15 @@ describe("renderCardFrame", () => { expect(short).toContain("⏎ launch"); }); + test("the action rows shed words instead of wrapping a narrow terminal", () => { + for (const cols of [40, 56, 60, 72, 80, 120]) { + const frame = plain(renderCardFrame({ ...base, cols, suggestions: [rust, python] })); + for (const line of frame.split("\n")) expect(displayWidth(line)).toBeLessThanOrEqual(cols); + expect(frame).toContain("⏎ launch"); + expect(frame).toContain("esc quit"); + } + }); + test("caps the reason list at three lines", () => { const frame = renderCardFrame({ ...base, diff --git a/src/lib/picker/card.ts b/src/lib/picker/card.ts index b5c80abc..14a786c9 100644 --- a/src/lib/picker/card.ts +++ b/src/lib/picker/card.ts @@ -9,8 +9,23 @@ import { Prompt, type PromptOptions } from "@clack/core"; import { styleText } from "node:util"; -import { asciiIconsEnabled, clipToWidth, displayWidth, stripIconIfAscii } from "./render-util"; -import { formatOverheadBadge } from "./tally"; +import { asciiIconsEnabled, clipToWidth, stripIconIfAscii } from "./render-util"; +import { OVERHEAD_WARN_TOKENS } from "./tally"; +import { + BAR, + button, + cardBottom, + cardInner, + cardLine, + cardTop, + cardWidth, + fitLine, + formatAlwaysOn, + keyHints, + keyTable, + meterBar, + pageDots, +} from "./ui"; /** What the user asked the card to do. */ export type CardAction = "launch" | "edit" | "search" | "all"; @@ -48,8 +63,10 @@ export interface CardState { rows?: number; } -/** Terminal height below which the card renders without blank spacer lines. */ -export const COMPACT_ROWS = 16; +/** Terminal height below which the card renders without blank spacer lines. + * Set above the card's full height (~18 rows) so the squeeze happens *before* + * the frame would overflow, not after. */ +export const COMPACT_ROWS = 20; const KEY_HELP: ReadonlyArray<[string, string]> = [ ["⏎", "launch this stack"], @@ -65,96 +82,147 @@ const KEY_HELP: ReadonlyArray<[string, string]> = [ /** * Render one frame of the suggestion card. Pure — `styleText` is a no-op when * stdout isn't a TTY, so tests assert on plain text. + * + * Layout follows the grouped-inset idiom: the answer sits inside a rounded card + * whose left border continues clack's gutter, and the actions live below it — + * one filled pill for the thing you almost always want, a quiet keycap row for + * everything else. */ export function renderCardFrame(state: CardState): string { - const BAR = styleText("gray", "│"); + const bar = BAR(); const cols = state.cols ?? process.stdout.columns ?? 80; const compact = (state.rows ?? process.stdout.rows ?? 24) < COMPACT_ROWS; const ascii = state.ascii ?? asciiIconsEnabled(); const icon = (s: string) => stripIconIfAscii(s, ascii); - // Room for the "│ " gutter plus a right margin. - const width = Math.max(20, cols - 6); + const width = cardWidth(cols); + const inner = cardInner(width); const lines: string[] = []; - const blank = () => { - if (!compact) lines.push(BAR); + /** A blank card row — dropped on a short terminal, where height is scarcer + * than calm. */ + const airInCard = () => { + if (!compact) lines.push(cardLine(width)); }; - lines.push(BAR); + lines.push(bar); lines.push( - `${BAR} ${styleText("cyan", "◆")} ${styleText("bold", "cue")} ${styleText( + `${bar} ${styleText("cyan", "◆")} ${styleText("bold", "cue")} ${styleText( "dim", - clipToWidth(state.cwd, width - 6), + clipToWidth(state.cwd, width - 10), )}`, ); + lines.push(bar); if (state.help === true) { - blank(); - for (const [key, what] of KEY_HELP) { - const k = styleText("cyan", key.padEnd(8)); - lines.push(`${BAR} ${k}${styleText("dim", what)}`); - } - lines.push(BAR); - lines.push(`${BAR} ${styleText("dim", "? close help")}`); + lines.push(cardTop(width, "keys")); + airInCard(); + for (const row of keyTable(KEY_HELP)) lines.push(cardLine(width, row)); + airInCard(); + lines.push(cardBottom(width)); + lines.push(bar); + lines.push(`${bar} ${keyHints([["?", "close help"]])}`); return lines.join("\n"); } const current = state.suggestions[state.index]; if (!current) { - blank(); - lines.push(`${BAR} ${styleText("yellow", "no profiles available")}`); - lines.push(BAR); - lines.push(`${BAR} ${styleText("dim", "a browse every profile · esc cancel")}`); + lines.push(cardTop(width, "nothing to suggest")); + airInCard(); + lines.push( + cardLine(width, styleText("dim", "no profiles are installed for this directory yet")), + ); + airInCard(); + lines.push(cardBottom(width)); + lines.push(bar); + lines.push( + `${bar} ${button("a browse every profile")} ${keyHints([["esc", "cancel"]])}`, + ); return lines.join("\n"); } - blank(); - const counter = - state.suggestions.length > 1 ? `${state.index + 1}/${state.suggestions.length}` : ""; - const heading = styleText("blueBright", styleText("bold", "suggested stack")); - const pad = counter - ? " ".repeat(Math.max(2, width - displayWidth("suggested stack") - displayWidth(counter))) - : ""; - lines.push(`${BAR} ${heading}${pad}${counter ? styleText("dim", counter) : ""}`); - blank(); + // Page dots ride the top border, iOS-style: the count is ambient rather than + // another line of text competing with the answer. + lines.push(cardTop(width, "suggested stack", pageDots(state.index, state.suggestions.length))); + airInCard(); - const stack = current.labels.map(icon).join(styleText("dim", " + ")); - lines.push(`${BAR} ${clipToWidth(stack, width - 2)}`); - blank(); + // The answer itself, given the most visual weight on the screen. + const stack = current.labels + .map((l) => styleText("bold", icon(l))) + .join(styleText("dim", " + ")); + lines.push(cardLine(width, stack)); + airInCard(); for (const reason of current.reasons.slice(0, 3)) { - lines.push(`${BAR} ${styleText("dim", clipToWidth(reason, width - 2))}`); + lines.push(cardLine(width, styleText("dim", clipToWidth(reason, inner)))); } - if (current.totals) { - lines.push(`${BAR} ${styleText("dim", clipToWidth(current.totals, width - 2))}`); + + if (current.totals || (current.alwaysOn ?? 0) > 0) { + airInCard(); + if (current.totals) lines.push(cardLine(width, styleText("dim", current.totals))); + const alwaysOn = current.alwaysOn ?? 0; + if (alwaysOn > 0) { + // Weight as a meter, not just a number — "how heavy is this" is a + // comparison, and a bar answers it without the reader doing arithmetic. + const heavy = alwaysOn > OVERHEAD_WARN_TOKENS; + const caption = heavy + ? styleText("yellow", `${formatAlwaysOn(alwaysOn)} · ⚠ heavy, slows the agent`) + : styleText("dim", formatAlwaysOn(alwaysOn)); + lines.push(cardLine(width, `${meterBar(alwaysOn)} ${caption}`)); + } } - const badge = formatOverheadBadge(current.alwaysOn ?? 0); - if (badge) lines.push(`${BAR} ${styleText("yellow", clipToWidth(badge, width - 2))}`); - lines.push(BAR); - // Lead with the one key that matters, and say exactly what it does — the pin - // decision lives here now instead of in a follow-up confirm. - const launchText = - state.pinDisabled === true - ? "⏎ launch" - : state.pin - ? "⏎ launch · pins to this directory" - : "⏎ launch · no pin"; + airInCard(); + lines.push(cardBottom(width)); + lines.push(bar); + + // One filled pill for the action you almost always want, then the two + // controls that change what it would do: which suggestion, and whether it + // sticks. The pin reads as a switch — constant label, changing dot. + // Everything below is offered in a long and a short form so a narrow + // terminal drops words instead of wrapping the row. + const budget = cols - 4; + const actionRow = (pinLabel: string, nextLabel: string): string => { + const parts: string[] = [button("⏎ launch")]; + if (state.suggestions.length > 1) parts.push(keyHints([["↹", nextLabel]])); + if (state.pinDisabled !== true) { + const dot = state.pin ? styleText("green", "●") : styleText("dim", "○"); + parts.push(`${styleText("dim", "p")} ${dot} ${styleText("dim", pinLabel)}`); + } + return parts.join(styleText("dim", " ")); + }; lines.push( - `${BAR} ${styleText("cyan", launchText)}${ - state.suggestions.length > 1 ? styleText("dim", " ↹ next suggestion") : "" - }`, + `${bar} ${fitLine( + budget, + actionRow("pin to this folder", "next suggestion"), + actionRow("pin here", "next"), + )}`, + ); + + lines.push( + `${bar} ${fitLine( + budget, + keyHints([ + ["e", "edit stack"], + ["/", "search"], + ["a", "all profiles"], + ["?", "keys"], + ["esc", "quit"], + ]), + keyHints([ + ["e", "edit"], + ["/", "search"], + ["a", "all"], + ["?", "keys"], + ["esc", "quit"], + ]), + // Last resort on a very narrow terminal: keep editing, the full + // catalogue, and the way out. `?` still lists everything else. + keyHints([ + ["e", "edit"], + ["a", "all"], + ["esc", "quit"], + ]), + )}`, ); - const secondary = [ - "e edit", - "/ search", - "a all profiles", - state.pinDisabled === true ? "" : state.pin ? "p don't pin" : "p pin", - "? help", - "esc cancel", - ] - .filter((s) => s.length > 0) - .join(" · "); - lines.push(`${BAR} ${styleText("dim", clipToWidth(secondary, width))}`); return lines.join("\n"); } @@ -256,13 +324,13 @@ export class CardPrompt extends Prompt { } renderFrame(this: CardPrompt): string { - const BAR = styleText("gray", "│"); - if (this.state === "cancel") return `${BAR} ${styleText("red", "■")} cancelled`; + const bar = BAR(); + if (this.state === "cancel") return `${bar} ${styleText("red", "■")} cancelled`; if (this.state === "submit" && this.value === "launch") { const current = this.suggestions[this.index]; const ascii = asciiIconsEnabled(); const label = (current?.labels ?? []).map((l) => stripIconIfAscii(l, ascii)).join(" + "); - return `${BAR} ${styleText("green", "◇")} ${label}`; + return `${bar} ${styleText("green", "◇")} ${label}`; } if (this.state === "submit") return ""; return renderCardFrame({ diff --git a/src/lib/picker/palette.test.ts b/src/lib/picker/palette.test.ts index f2348e72..0bafeb1d 100644 --- a/src/lib/picker/palette.test.ts +++ b/src/lib/picker/palette.test.ts @@ -13,6 +13,7 @@ import { type PaletteProfile, type PaletteRow, } from "./palette"; +import { displayWidth } from "./render-util"; import type { ProfileTally } from "./tally"; const profiles: PaletteProfile[] = [ @@ -154,35 +155,56 @@ describe("buildPaletteRows", () => { describe("renderPaletteFrame", () => { const base = { cursor: 0, query: "", cols: 80, ascii: false }; - test("shows checkbox state, section headers and the suggested marker", () => { - const frame = renderPaletteFrame({ ...base, rows: rows(), selected: ["rust"] }); - expect(frame).toContain(SUGGESTED_SECTION); - expect(plain(frame)).toContain("[x] 🦀 rust"); - expect(plain(frame)).toContain("[ ] 🔒 secops"); + test("shows selection marks and an uppercase section header", () => { + const frame = plain(renderPaletteFrame({ ...base, rows: rows(), selected: ["rust"] })); + expect(frame).toContain(SUGGESTED_SECTION.toUpperCase()); + expect(frame).toContain("● 🦀 rust"); + expect(frame).toContain("○ 🔒 secops"); + // The section header already says "suggested" — the per-row tag would only + // repeat it, so it stays off until a search flattens the sections away. + expect(frame).not.toContain(" suggested"); + }); + + test("tags a suggested row once a search has flattened the sections", () => { + const frame = plain( + renderPaletteFrame({ ...base, rows: rows(), selected: [], query: "rust" }), + ); + expect(frame).toContain(MATCHES_SECTION.toUpperCase()); expect(frame).toContain("suggested"); }); + test("reports how much is on screen next to the search field", () => { + const frame = plain(renderPaletteFrame({ ...base, rows: rows(), selected: ["rust"] })); + expect(frame).toContain("type to filter…"); + expect(frame).toContain("4 profiles"); + expect(frame).toContain("1 selected"); + const searching = plain( + renderPaletteFrame({ ...base, rows: rows(), selected: [], query: "medusa" }), + ); + expect(searching).toContain("2 matches"); + expect(searching).toContain("none selected"); + }); + test("disables a row that conflicts with the selection and says why", () => { const frame = renderPaletteFrame({ ...base, rows: rows(), selected: ["medusa-next"] }); - expect(frame).toContain("[—]"); + expect(plain(frame)).toContain("⊘"); expect(plain(frame)).toContain("conflicts with medusa-next"); }); - test("footer totals the selected stack and flags a heavy one", () => { + test("footer totals the selected stack, meters it and flags a heavy one", () => { const tallies = new Map([ ["rust", tally("rust", 20, 4000)], ["secops", tally("secops", 11, 9000)], ]); - const frame = renderPaletteFrame({ - ...base, - rows: rows(), - selected: ["rust", "secops"], - tallies, - }); - expect(frame).toContain("🦀 rust + 🔒 secops"); + const frame = plain( + renderPaletteFrame({ ...base, rows: rows(), selected: ["rust", "secops"], tallies }), + ); + expect(frame).toContain("🦀 rust + 🔒 secops"); expect(frame).toContain("31 skills"); - expect(frame).toContain("⚠ heavy: ~13k always-on"); - expect(frame).toContain("⏎ launch 2 selected"); + expect(frame).toContain("~13k always-on"); + expect(frame).toContain("⚠ heavy, slows the agent"); + expect(frame).toContain("█"); + expect(frame).toContain("⏎ launch 2"); }); test("marks totals as pending while a tally is still loading", () => { @@ -196,9 +218,21 @@ describe("renderPaletteFrame", () => { }); test("nudges when nothing is selected", () => { - const frame = renderPaletteFrame({ ...base, rows: rows(), selected: [] }); - expect(frame).toContain("nothing selected yet"); - expect(frame).toContain("⏎ pick at least one profile"); + const frame = plain(renderPaletteFrame({ ...base, rows: rows(), selected: [] })); + expect(frame).toContain("nothing selected yet — press space to add the focused profile"); + expect(frame).toContain("⏎ pick a profile"); + }); + + test("the action row sheds words instead of wrapping a narrow terminal", () => { + for (const cols of [40, 56, 60, 72, 80, 120]) { + const frame = plain( + renderPaletteFrame({ ...base, cols, rows: rows(), selected: ["rust"], maxRows: 6 }), + ); + for (const line of frame.split("\n")) expect(displayWidth(line)).toBeLessThanOrEqual(cols); + // However narrow it gets, the two keys you cannot guess still show. + expect(frame).toContain("⏎ launch 1"); + expect(frame).toContain("space add"); + } }); test("windows a long list with scroll markers", () => { @@ -222,12 +256,13 @@ describe("renderPaletteFrame", () => { const frame = renderPaletteFrame({ ...base, rows: rows(), selected: [], help: true }); expect(frame).toContain("add / remove the focused profile"); expect(frame).toContain("back to the suggestion"); - expect(plain(frame)).not.toContain("[ ]"); + expect(plain(frame)).not.toContain("○"); }); - test("ascii mode drops the emoji icons", () => { + test("ascii mode drops the emoji icons and keeps bracket checkboxes", () => { const frame = renderPaletteFrame({ ...base, rows: rows(), selected: ["rust"], ascii: true }); expect(plain(frame)).toContain("[x] rust"); + expect(plain(frame)).toContain("[ ] secops"); expect(frame).not.toContain("🦀"); }); }); diff --git a/src/lib/picker/palette.ts b/src/lib/picker/palette.ts index 99e0f8eb..7eb0a8a9 100644 --- a/src/lib/picker/palette.ts +++ b/src/lib/picker/palette.ts @@ -24,11 +24,26 @@ import { } from "./render-util"; import { EMPTY_TALLY, - formatOverheadBadge, formatStackTotals, + OVERHEAD_WARN_TOKENS, unionTallyCounts, type ProfileTally, } from "./tally"; +import { + BAR, + button, + CARD_MAX_WIDTH, + clipVisible, + fitLine, + formatAlwaysOn, + keyHints, + keyTable, + markWidth, + meterBar, + padVisible, + sectionHeader, + selectMark, +} from "./ui"; /** Section title used for the flat ranked list while a filter is active. */ export const MATCHES_SECTION = "matches"; @@ -80,6 +95,7 @@ const KEY_HELP: ReadonlyArray<[string, string]> = [ ["⏎", "launch the stack you built"], ["space", "add / remove the focused profile"], ["↑↓", "move"], + ["pgup/pgdn", "move ten rows"], ["type", "fuzzy-search every profile"], ["⌫", "delete a search character"], ["?", "toggle this help"], @@ -229,44 +245,65 @@ export function filterRows(rows: PaletteRow[], query: string): PaletteRow[] { .map(({ row }) => ({ ...row, section: MATCHES_SECTION })); } -/** Column the section-header count is aligned to. */ -export const SECTION_RULE_COL = 30; - /** * Render one frame of the palette. Pure — same state in, same string out. + * + * Reading order top to bottom: what screen is this and how much have I picked → + * the search field → the grouped list → what I've built and what it costs → the + * one key that ships it. */ export function renderPaletteFrame(state: PaletteState): string { - const BAR = styleText("gray", "│"); + const bar = BAR(); const cols = state.cols ?? process.stdout.columns ?? 80; const width = Math.max(24, cols - 6); + // Rows clip to the full terminal width (a long hint deserves the room), but + // the right-aligned header meta tracks the card's capped width so the two + // screens line up instead of drifting apart on a wide terminal. + const headWidth = Math.min(width, CARD_MAX_WIDTH); const ascii = state.ascii ?? asciiIconsEnabled(); const icon = (s: string) => stripIconIfAscii(s, ascii); const lines: string[] = []; - lines.push(BAR); - const filterTag = - state.query.length > 0 - ? styleText("cyan", ` ${state.query}▏`) - : styleText("dim", " type to search"); - lines.push(`${BAR} ${styleText("cyan", "◆")} ${styleText("bold", "build your stack")}${filterTag}`); + const visible = filterRows(state.rows, state.query); + const conflictMap = buildConflictMap(state.rows); + const effective = new Set(resolveConflicts(state.selected, conflictMap)); + + // Title bar: the screen's name on the left, how far along you are on the + // right — the two things you'd check before touching a key. + lines.push(bar); + const title = `${styleText("cyan", "◆")} ${styleText("bold", "build your stack")}`; + const picked = + effective.size > 0 + ? styleText("green", `${effective.size} selected`) + : styleText("dim", "none selected"); + lines.push(`${bar} ${padVisible(title, Math.max(0, headWidth - 14))}${picked}`); if (state.help === true) { - lines.push(BAR); - for (const [key, what] of KEY_HELP) { - lines.push(`${BAR} ${styleText("cyan", key.padEnd(8))}${styleText("dim", what)}`); - } - lines.push(BAR); - lines.push(`${BAR} ${styleText("dim", "? close help")}`); + lines.push(bar); + for (const row of keyTable(KEY_HELP, 12)) lines.push(`${bar} ${row}`); + lines.push(bar); + lines.push(`${bar} ${keyHints([["?", "close help"]])}`); return lines.join("\n"); } - const visible = filterRows(state.rows, state.query); - const conflictMap = buildConflictMap(state.rows); - const effective = new Set(resolveConflicts(state.selected, conflictMap)); + // Search field. Rendering it as its own labelled row — rather than a tag + // welded onto the title — is what makes "you can just type" discoverable. + lines.push(bar); + const field = + state.query.length > 0 + ? `${styleText("cyan", state.query)}${styleText("cyan", "▏")}` + : styleText("dim", "type to filter…"); + const scope = + state.query.length > 0 + ? styleText("dim", `${visible.length} ${visible.length === 1 ? "match" : "matches"}`) + : styleText("dim", `${state.rows.length} profiles`); + lines.push( + `${bar} ${styleText("dim", "search")} ${padVisible(field, Math.max(0, headWidth - 22))}${scope}`, + ); - lines.push(BAR); + lines.push(bar); if (visible.length === 0) { - lines.push(`${BAR} ${styleText("yellow", `nothing matches "${state.query}"`)}`); + lines.push(`${bar} ${styleText("yellow", `nothing matches "${state.query}"`)}`); } // Per-section totals (across the whole filtered list, not just the window) so @@ -281,61 +318,86 @@ export function renderPaletteFrame(state: PaletteState): string { const max = state.maxRows && state.maxRows > 0 ? state.maxRows : visible.length; const cursor = Math.max(0, Math.min(state.cursor, visible.length - 1)); const win = windowOptions(visible, cursor, max); - if (win.hiddenAbove > 0) lines.push(`${BAR} ${styleText("dim", `↑ ${win.hiddenAbove} more`)}`); + if (win.hiddenAbove > 0) lines.push(`${bar} ${styleText("dim", `↑ ${win.hiddenAbove} more`)}`); let lastSection: string | undefined; win.items.forEach((row, offset) => { const idx = win.start + offset; if (row.section !== lastSection) { - if (lastSection !== undefined) lines.push(BAR); - const count = String(sectionTotals.get(row.section) ?? 0); - const rule = styleText( - "gray", - "─".repeat(Math.max(3, SECTION_RULE_COL - displayWidth(row.section) - 2)), - ); - lines.push( - `${BAR} ${styleText("bold", styleText("blueBright", row.section))} ${rule} ${styleText("dim", count)}`, - ); + if (lastSection !== undefined) lines.push(bar); + lines.push(`${bar} ${sectionHeader(row.section, sectionTotals.get(row.section) ?? 0)}`); lastSection = row.section; } - lines.push(renderRow(row, idx === cursor, effective, conflictMap, { icon, labelCol, state, width })); + lines.push( + renderRow(row, idx === cursor, effective, conflictMap, { icon, labelCol, state, width, ascii }), + ); }); - if (win.hiddenBelow > 0) lines.push(`${BAR} ${styleText("dim", `↓ ${win.hiddenBelow} more`)}`); + if (win.hiddenBelow > 0) lines.push(`${bar} ${styleText("dim", `↓ ${win.hiddenBelow} more`)}`); // Sticky footer: what you're about to launch, and what it costs. - lines.push(BAR); + lines.push(bar); const chosen = [...effective]; if (chosen.length === 0) { - lines.push(`${BAR} ${styleText("dim", "nothing selected yet — space adds the focused profile")}`); + lines.push( + `${bar} ${styleText("dim", "nothing selected yet — press space to add the focused profile")}`, + ); } else { const labelOf = (v: string) => icon(state.rows.find((r) => r.value === v)?.label ?? v); - lines.push(`${BAR} ${styleText("green", clipToWidth(chosen.map(labelOf).join(" + "), width))}`); + lines.push( + `${bar} ${styleText("green", clipToWidth(chosen.map(labelOf).join(" + "), width))}`, + ); const tallies = state.tallies; if (tallies) { - const picked = chosen.map((v) => tallies.get(v)); - const totals = formatStackTotals(unionTallyCounts(picked.map((t) => t ?? EMPTY_TALLY))); - const pending = picked.some((t) => t === undefined) ? " …" : ""; - if (totals) lines.push(`${BAR} ${styleText("dim", `${totals}${pending}`)}`); - const badge = formatOverheadBadge( - picked.reduce((sum, t) => sum + (t?.alwaysOn ?? 0), 0), + const pickedTallies = chosen.map((v) => tallies.get(v)); + const totals = formatStackTotals( + unionTallyCounts(pickedTallies.map((t) => t ?? EMPTY_TALLY)), ); - if (badge) lines.push(`${BAR} ${styleText("yellow", clipToWidth(badge, width))}`); + const pending = pickedTallies.some((t) => t === undefined) ? " …" : ""; + if (totals) lines.push(`${bar} ${styleText("dim", `${totals}${pending}`)}`); + const alwaysOn = pickedTallies.reduce((sum, t) => sum + (t?.alwaysOn ?? 0), 0); + if (alwaysOn > 0) { + const heavy = alwaysOn > OVERHEAD_WARN_TOKENS; + const caption = heavy + ? styleText("yellow", `${formatAlwaysOn(alwaysOn)} · ⚠ heavy, slows the agent`) + : styleText("dim", formatAlwaysOn(alwaysOn)); + lines.push(`${bar} ${clipVisible(`${meterBar(alwaysOn)} ${caption}`, width)}`); + } } } - lines.push(BAR); - const enterText = - chosen.length > 0 ? `⏎ launch ${chosen.length} selected` : "⏎ pick at least one profile"; + lines.push(bar); + const ship = + chosen.length > 0 + ? button(`⏎ launch ${chosen.length}`) + : styleText("dim", " ⏎ pick a profile "); + // Three widths of the same row, so a narrow terminal drops words rather than + // wrapping — a wrapped action row reflows the list on every keystroke. + const row = (pairs: ReadonlyArray<[string, string]>) => `${ship} ${keyHints(pairs)}`; lines.push( - `${BAR} ${styleText(chosen.length > 0 ? "cyan" : "dim", enterText)}${styleText( - "dim", - " · space toggle · ↑↓ move · ? help · esc back", + `${bar} ${fitLine( + cols - 4, + row([ + ["space", "add/remove"], + ["↑↓", "move"], + ["?", "keys"], + ["esc", "back"], + ]), + row([ + ["space", "add"], + ["↑↓", "move"], + ["?", "keys"], + ["esc", "back"], + ]), + row([ + ["space", "add"], + ["esc", "back"], + ]), )}`, ); return lines.join("\n"); } -/** One list row: checkbox, label, per-row delta, conflict/recommendation tags. */ +/** One list row: selection mark, label, per-row delta, conflict/danger tags. */ function renderRow( row: PaletteRow, isCursor: boolean, @@ -346,9 +408,10 @@ function renderRow( labelCol: number; state: PaletteState; width: number; + ascii: boolean; }, ): string { - const BAR = styleText("gray", "│"); + const bar = BAR(); const isSel = effective.has(row.value); const arrow = isCursor ? styleText("cyan", "›") @@ -363,7 +426,7 @@ function renderRow( if (partners) { for (const sel of effective) { if (partners.has(sel)) { - return `${BAR} ${arrow} ${styleText("dim", "[—]")} ${styleText( + return `${bar} ${arrow} ${selectMark("blocked", ctx.ascii)} ${styleText( "dim", `${ctx.icon(row.label)} (conflicts with ${sel})`, )}`; @@ -372,28 +435,39 @@ function renderRow( } } - const box = isSel ? styleText("green", "[x]") : styleText("dim", "[ ]"); + const box = selectMark(isSel ? "on" : "off", ctx.ascii); const rawLabel = ctx.icon(row.label); - const labelStyled = isSel || isCursor ? rawLabel : styleText("dim", rawLabel); + // Three weights, so the eye lands on the focused row first and the selected + // ones second: focused is bold, selected is normal, everything else recedes. + const labelStyled = isCursor + ? styleText("bold", rawLabel) + : isSel + ? rawLabel + : styleText("dim", rawLabel); const tally = ctx.state.tallies?.get(row.value); const delta = tally && tally.skills.length > 0 ? `${tally.skills.length} skills` : ""; // The focused row's description can be a paragraph (some profiles carry a // 200-character blurb). Clip it to what's left on the line so one row can't // wrap across the screen and shove the rest of the list out of view. - const gutter = 9; // "│ › [x] " + const gutter = 6 + markWidth(ctx.ascii); // "│ › ● " const used = gutter + Math.max(displayWidth(rawLabel), ctx.labelCol) + displayWidth(delta) + 4; const hint = row.hint && isCursor ? styleText("dim", ` (${clipToWidth(row.hint, Math.max(16, ctx.width - used))})`) : ""; - const recTag = row.recommended === true ? styleText("dim", " suggested") : ""; + // The "suggested" tag earns its place only where the section header doesn't + // already say it — i.e. once a search has flattened everything into `matches`. + const recTag = + row.recommended === true && row.section !== SUGGESTED_SECTION + ? styleText("dim", " suggested") + : ""; const hasTrailer = delta !== "" || Boolean(row.danger); const pad = hasTrailer ? " ".repeat(Math.max(2, ctx.labelCol + 2 - displayWidth(rawLabel))) : ""; const deltaStr = delta ? styleText("dim", delta) : ""; const dangerTag = row.danger ? styleText("red", `${delta ? " · " : ""}${row.danger}`) : ""; - return `${BAR} ${arrow} ${box} ${labelStyled}${pad}${deltaStr}${dangerTag}${hint}${recTag}`; + return `${bar} ${arrow} ${box} ${labelStyled}${pad}${deltaStr}${dangerTag}${hint}${recTag}`; } /** @@ -528,9 +602,10 @@ export class StackPalettePrompt extends Prompt { private visibleWindow(): number { const rows = (this.output as { rows?: number } | undefined)?.rows ?? process.stdout.rows ?? 24; - // Reserve the header (3), section spacers (2), footer (4) and scroll - // markers (2); floor at 5 so a short terminal still lists something. - return Math.max(5, rows - 11); + // Reserve the header + search field (5), section spacers (2), the footer's + // stack / totals / meter / keys (5) and the scroll markers (2); floor at 5 + // so a short terminal still lists something. + return Math.max(5, rows - 14); } renderFrame(this: StackPalettePrompt): string { diff --git a/src/lib/picker/ui.test.ts b/src/lib/picker/ui.test.ts new file mode 100644 index 00000000..12cbc68d --- /dev/null +++ b/src/lib/picker/ui.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; + +import { displayWidth } from "./render-util"; +import { + cardBottom, + cardInner, + cardLine, + cardTop, + cardWidth, + clipVisible, + formatAlwaysOn, + keyTable, + markWidth, + meterBar, + METER_FULL_TOKENS, + padVisible, + pageDots, + sectionHeader, + selectMark, + stripAnsi, + visibleWidth, +} from "./ui"; + +const ESC = String.fromCharCode(27); +/** A cyan-wrapped string, built by hand so the test doesn't depend on whether + * the runner's stdout happens to be a TTY. */ +const styled = (s: string) => `${ESC}[36m${s}${ESC}[39m`; + +describe("width helpers", () => { + test("measures and strips styling rather than counting escape bytes", () => { + expect(stripAnsi(styled("rust"))).toBe("rust"); + expect(visibleWidth(styled("rust"))).toBe(4); + // Emoji are two cells wide even though they are one code point. + expect(visibleWidth(styled("🦀 rust"))).toBe(7); + }); + + test("pads a styled string to a cell width without disturbing the styling", () => { + const padded = padVisible(styled("rust"), 10); + expect(visibleWidth(padded)).toBe(10); + expect(padded).toContain(styled("rust")); + }); + + test("padding never truncates a string that is already too wide", () => { + expect(stripAnsi(padVisible(styled("rust-core"), 4))).toBe("rust-core"); + }); + + test("clipping keeps the styling when it fits and drops it when it cannot", () => { + expect(clipVisible(styled("rust"), 10)).toBe(styled("rust")); + const clipped = clipVisible(styled("rust-core"), 6); + expect(displayWidth(clipped)).toBeLessThanOrEqual(6); + expect(clipped).toContain("…"); + }); +}); + +describe("inset card", () => { + test("caps its width on a wide terminal and shrinks on a narrow one", () => { + expect(cardWidth(400)).toBe(74); + expect(cardWidth(60)).toBe(58); + expect(cardWidth(10)).toBe(24); + }); + + test("every row is the same number of cells wide", () => { + const w = cardWidth(80); + const frame = [ + cardTop(w, "suggested stack", pageDots(0, 3)), + cardLine(w), + cardLine(w, "🦀 rust + 🔒 secops"), + cardLine(w, styled("39 skills · 3 mcps")), + cardBottom(w), + ]; + const widths = new Set(frame.map((l) => displayWidth(stripAnsi(l)))); + expect(widths).toEqual(new Set([w])); + }); + + test("over-long content is clipped to the card, never wrapped past it", () => { + const w = cardWidth(80); + const line = cardLine(w, "x".repeat(500)); + expect(displayWidth(stripAnsi(line))).toBe(w); + expect(line).toContain("…"); + }); + + test("a title and badge still leave rule between them", () => { + const top = stripAnsi(cardTop(cardWidth(80), "keys", "1/9")); + expect(top).toContain("keys"); + expect(top).toContain("1/9"); + expect(top).toContain("─"); + expect(top.startsWith("╭")).toBe(true); + expect(top.endsWith("╮")).toBe(true); + }); + + test("inner width leaves room for both borders and the side padding", () => { + const w = cardWidth(80); + expect(cardInner(w)).toBe(w - 6); + }); +}); + +describe("pageDots", () => { + test("says nothing when there is only one page", () => { + expect(pageDots(0, 1)).toBe(""); + expect(pageDots(0, 0)).toBe(""); + }); + + test("fills the dot for the current page", () => { + expect(stripAnsi(pageDots(1, 3))).toBe("○ ● ○"); + }); + + test("switches to a numeric indicator past the dot budget", () => { + expect(stripAnsi(pageDots(2, 12))).toBe("3/12"); + expect(stripAnsi(pageDots(2, 12, 20))).toContain("○"); + }); +}); + +describe("selectMark", () => { + test("uses circles by default and brackets in ascii mode", () => { + expect(stripAnsi(selectMark("on", false))).toBe("●"); + expect(stripAnsi(selectMark("off", false))).toBe("○"); + expect(stripAnsi(selectMark("blocked", false))).toBe("⊘"); + expect(stripAnsi(selectMark("on", true))).toBe("[x]"); + expect(stripAnsi(selectMark("off", true))).toBe("[ ]"); + expect(stripAnsi(selectMark("blocked", true))).toBe("[-]"); + }); + + test("markWidth matches what selectMark actually renders", () => { + for (const ascii of [true, false]) { + for (const state of ["on", "off", "blocked"] as const) { + expect(displayWidth(stripAnsi(selectMark(state, ascii)))).toBe(markWidth(ascii)); + } + } + }); +}); + +describe("meterBar", () => { + test("is the requested width whatever the value", () => { + for (const v of [0, 1, 5_000, 20_000, 500_000]) { + expect(displayWidth(stripAnsi(meterBar(v, 12)))).toBe(12); + } + }); + + test("grows with the cost and pegs at full", () => { + const filled = (v: number) => stripAnsi(meterBar(v, 12)).replace(/░/g, "").length; + expect(filled(0)).toBe(0); + expect(filled(10_000)).toBeGreaterThan(filled(4_000)); + expect(filled(METER_FULL_TOKENS)).toBe(12); + expect(filled(METER_FULL_TOKENS * 5)).toBe(12); + }); + + test("a non-zero cost always shows at least one cell", () => { + expect(stripAnsi(meterBar(100, 12)).startsWith("█")).toBe(true); + }); +}); + +describe("formatAlwaysOn", () => { + test("keeps a decimal below 10k and drops it above", () => { + expect(formatAlwaysOn(6_000)).toBe("~6.0k always-on"); + expect(formatAlwaysOn(13_400)).toBe("~13k always-on"); + }); + + test("says nothing when the cost is unknown", () => { + expect(formatAlwaysOn(0)).toBe(""); + expect(formatAlwaysOn(-1)).toBe(""); + }); +}); + +describe("sectionHeader", () => { + test("uppercases the name and trails the count", () => { + expect(stripAnsi(sectionHeader("detected here", 3))).toBe("DETECTED HERE 3"); + expect(stripAnsi(sectionHeader("featured"))).toBe("FEATURED"); + }); +}); + +describe("keyTable", () => { + test("aligns the descriptions into one column", () => { + const out = keyTable( + [ + ["⏎", "launch"], + ["space", "toggle"], + ], + 9, + ).map(stripAnsi); + const starts = out.map((l) => l.indexOf(l.trimStart().split(" ").pop()!)); + expect(new Set(starts).size).toBe(1); + expect(out[0]).toContain("launch"); + }); +}); diff --git a/src/lib/picker/ui.ts b/src/lib/picker/ui.ts new file mode 100644 index 00000000..5f36c8a1 --- /dev/null +++ b/src/lib/picker/ui.ts @@ -0,0 +1,219 @@ +/** + * Visual primitives shared by the picker surfaces (v2 card + stack palette). + * + * The house style borrows from iOS's grouped-inset lists: one rounded card per + * idea, uppercase muted section headers instead of heavy rules, a filled pill + * for the single primary action, circular selection marks instead of ASCII + * brackets, and page dots for "there is more to see here". + * + * Everything here is pure — no I/O, no TTY. `styleText` is a no-op when stdout + * isn't a TTY, so tests assert on plain text. + */ + +import { styleText } from "node:util"; +import { clipToWidth, displayWidth } from "./render-util"; + +/** The clack gutter every picker line hangs off. */ +export const BAR = (): string => styleText("gray", "│"); + +const ANSI = /\u001b\[[0-9;]*m/g; + +/** Drop SGR escapes so a styled string can be measured and padded correctly. */ +export function stripAnsi(s: string): string { + return s.replace(ANSI, ""); +} + +/** Rendered cell width of a possibly-styled string. */ +export function visibleWidth(s: string): number { + return displayWidth(stripAnsi(s)); +} + +/** Right-pad a possibly-styled string to `width` cells (never truncates). */ +export function padVisible(s: string, width: number): string { + return s + " ".repeat(Math.max(0, width - visibleWidth(s))); +} + +/** Clip a possibly-styled string to `width` cells, keeping the styling intact + * when it already fits. Styled strings that overflow are clipped plain — an + * over-long line is a layout bug we'd rather see unstyled than wrapped. */ +export function clipVisible(s: string, width: number): string { + if (visibleWidth(s) <= width) return s; + return clipToWidth(stripAnsi(s), width); +} + +// ── inset card ───────────────────────────────────────────────────────────── +// +// The card's left border sits in the same column as clack's `│` gutter, so the +// rail appears to thicken into a card rather than doubling up next to one. + +/** Widest an inset card grows to, however wide the terminal is. Long lines are + * harder to scan than short ones; iOS caps its content width for the same + * reason. */ +export const CARD_MAX_WIDTH = 74; + +/** Outer width of the inset card for a given terminal width. */ +export function cardWidth(cols: number): number { + return Math.max(24, Math.min(CARD_MAX_WIDTH, cols - 2)); +} + +/** Inner (content) width — the outer width minus both borders and the 2-cell + * breathing room on each side. */ +export function cardInner(width: number): number { + return Math.max(8, width - 6); +} + +/** + * Top border, with an optional inline title on the left and a trailing badge + * (page dots, a count) on the right: + * + * ╭─ suggested stack ─────────────────────────────── ● ○ ○ ─╮ + */ +export function cardTop(width: number, title?: string, badge?: string): string { + if (!title && !badge) { + return styleText("gray", `╭${"─".repeat(width - 2)}╮`); + } + const head = title ? ` ${styleText("bold", title)} ` : ""; + const tail = badge ? ` ${badge} ` : ""; + const fill = Math.max(1, width - 4 - visibleWidth(head) - visibleWidth(tail)); + return ( + styleText("gray", "╭─") + + head + + styleText("gray", "─".repeat(fill)) + + tail + + styleText("gray", "─╮") + ); +} + +/** One content row inside the card, padded so the right border lines up. */ +export function cardLine(width: number, content = ""): string { + const inner = cardInner(width); + const body = padVisible(clipVisible(content, inner), inner); + const edge = styleText("gray", "│"); + return `${edge} ${body} ${edge}`; +} + +/** Bottom border. */ +export function cardBottom(width: number): string { + return styleText("gray", `╰${"─".repeat(width - 2)}╯`); +} + +// ── controls ─────────────────────────────────────────────────────────────── + +/** iOS page control: one dot per suggestion, filled for the current one. Falls + * back to `3/12` past `maxDots`, the same way iOS switches to a numeric page + * indicator rather than rendering an unreadable row of dots. */ +export function pageDots(index: number, total: number, maxDots = 8): string { + if (total <= 1) return ""; + if (total > maxDots) return styleText("dim", `${index + 1}/${total}`); + return Array.from({ length: total }, (_, i) => + i === index ? styleText("cyan", "●") : styleText("dim", "○"), + ).join(" "); +} + +/** + * A filled pill for the one action that matters on a screen — the terminal's + * closest thing to iOS's tinted primary button. Inverse video paints the whole + * label, so it reads as a solid block rather than another line of text. + */ +export function button(label: string, tone: "primary" | "muted" = "primary"): string { + const text = ` ${label} `; + return tone === "primary" + ? styleText(["inverse", "cyan"], text) + : styleText(["inverse", "gray"], text); +} + +/** + * Section header in the grouped-list idiom: uppercase, muted, with the row + * count trailing. No rule — whitespace does the separating, which reads calmer + * than a screen full of dashes. + */ +export function sectionHeader(name: string, count?: number): string { + const label = styleText("bold", styleText("blueBright", name.toUpperCase())); + return count === undefined ? label : `${label} ${styleText("dim", String(count))}`; +} + +/** Selection state of a list row. */ +export type MarkState = "on" | "off" | "blocked"; + +/** + * The selection mark. Circles read as "tap to toggle" the way iOS's selection + * circles do, and a filled dot is far easier to spot down a column than `[x]`. + * ASCII mode keeps the bracket form for fonts without the geometric shapes. + */ +export function selectMark(state: MarkState, ascii: boolean): string { + if (ascii) { + return state === "on" + ? styleText("green", "[x]") + : state === "blocked" + ? styleText("dim", "[-]") + : styleText("dim", "[ ]"); + } + return state === "on" + ? styleText("green", "●") + : state === "blocked" + ? styleText("dim", "⊘") + : styleText("dim", "○"); +} + +/** Cell width `selectMark` occupies, so callers can budget the row. */ +export function markWidth(ascii: boolean): number { + return ascii ? 3 : 1; +} + +/** Always-on token cost at which the meter reads full. Roughly the point where + * a stack has eaten a serious slice of the startup budget. */ +export const METER_FULL_TOKENS = 20_000; + +/** + * A weight meter for a stack's always-on cost — the same information the + * `⚠ heavy` badge carries, but readable at a glance and present even when the + * stack is light. Colour tracks `tokenLevelEmoji`'s bands. + */ +export function meterBar(alwaysOn: number, width = 12): string { + const ratio = Math.max(0, Math.min(1, alwaysOn / METER_FULL_TOKENS)); + const filled = Math.max(alwaysOn > 0 ? 1 : 0, Math.round(ratio * width)); + // `tokenLevelEmoji`'s four bands collapsed onto the three colours a terminal + // reliably distinguishes: 🟢 green, 🟡/🟠 yellow, 🔴 red. + const color = alwaysOn > 15_000 ? "red" : alwaysOn > 5_000 ? "yellow" : "green"; + return ( + styleText(color, "█".repeat(filled)) + styleText("gray", "░".repeat(Math.max(0, width - filled))) + ); +} + +/** "~14k always-on" — the meter's caption. Returns "" for an unknown cost. */ +export function formatAlwaysOn(alwaysOn: number): string { + if (alwaysOn <= 0) return ""; + const k = alwaysOn >= 10_000 ? String(Math.round(alwaysOn / 1000)) : (alwaysOn / 1000).toFixed(1); + return `~${k}k always-on`; +} + +/** + * A quiet keycap row: `space add · ↑↓ move · esc back`. Keys keep their normal + * weight so they stand out from the dim descriptions around them. + */ +export function keyHints(pairs: ReadonlyArray<[string, string]>): string { + // The key is left unstyled so it keeps the terminal's default (bright) + // foreground next to its dim description — the contrast is what makes the + // row scannable without adding another colour. + return pairs + .map(([key, what]) => `${key} ${styleText("dim", what)}`) + .join(styleText("dim", " · ")); +} + +/** + * Pick the first candidate that fits `width`, falling back to a clip of the + * last one. Footers degrade rather than wrap: a wrapped action row reflows the + * whole list on a keystroke, which reads as the screen flickering. + */ +export function fitLine(width: number, ...candidates: string[]): string { + for (const c of candidates) if (visibleWidth(c) <= width) return c; + return clipVisible(candidates[candidates.length - 1] ?? "", width); +} + +/** A two-column key/description list, used by the `?` help overlays. */ +export function keyTable(pairs: ReadonlyArray<[string, string]>, keyCol = 9): string[] { + return pairs.map( + ([key, what]) => + `${styleText("cyan", padVisible(key, keyCol))}${styleText("dim", what)}`, + ); +} From 062b40a38681f5b3c5b57f6e7e6a516e481e3ebe Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 7 Aug 2026 04:57:33 +0200 Subject: [PATCH 19/33] chore: integrity-protocol wording, tag hooks, two new profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collects the remaining working-tree changes from prior sessions: - resources/personas/integrity-protocol{,-compact}.md — wording pass - resources/hooks/{tag-audit,liedetector-tag-density}.sh — confidence-tag density checks - profiles/frontend-design, profiles/reverse-skill — two new profiles - README.md, .cue.profile (cue's own pin: core -> core+skill-writer) Co-Authored-By: Claude Opus 5 (1M context) --- .cue.profile | 2 +- README.md | 21 ++++- profiles/frontend-design/profile.yaml | 60 ++++++++++++ profiles/reverse-skill/profile.yaml | 93 +++++++++++++++++++ resources/hooks/liedetector-tag-density.sh | 53 +++++++++-- resources/hooks/tag-audit.sh | 58 ++++++++++-- .../personas/integrity-protocol-compact.md | 4 +- resources/personas/integrity-protocol.md | 9 +- 8 files changed, 278 insertions(+), 22 deletions(-) create mode 100644 profiles/frontend-design/profile.yaml create mode 100644 profiles/reverse-skill/profile.yaml diff --git a/.cue.profile b/.cue.profile index f5bd37c1..73eefa07 100644 --- a/.cue.profile +++ b/.cue.profile @@ -1 +1 @@ -core +core+skill-writer diff --git a/README.md b/README.md index a677c145..2076d6a8 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,20 @@ cuecards don't just load tools — they hold your agent to a standard. Enable it with `touch ~/.config/cue/auto-review-enabled`, watch reviews live with `cue-review-watch`, and skip one turn with `[skip-auto-review]`. Details: [docs/review-visibility.md](https://github.com/opencue/cuecards/blob/main/docs/review-visibility.md). +**The install gate.** Every path that lands a new skill on disk — `cue skills add`, `cue discover install`, `cue marketplace install-skill` — is scanned by [NVIDIA SkillSpector](https://github.com/NVIDIA/SkillSpector) before the skill is registered to a profile. Research behind that scanner puts 26.1% of published skills at "contains vulnerabilities" and 5.2% at "likely malicious intent", so a skill you found on GitHub 30 seconds ago does not get to run on trust. + +A `DO_NOT_INSTALL` verdict blocks: the files stay on disk for review but never reach your `profile.yaml`. `CAUTION` registers with a warning. `SAFE` passes silently. Override a block with `--allow-unsafe`, scan anything by hand with `cue security scan `, and turn the gate off with `CUE_SKILLSPECTOR=0`. + +```console +$ cue security scan ./notes-organizer/ +🔴 SkillSpector: DO_NOT_INSTALL (risk 85/100, CRITICAL) — 6 finding(s): Anti-Refusal, Privilege Escalation, Prompt Injection, YARA Match + [HIGH] AR3 Anti-Refusal SKILL.md:8 + [HIGH] PE3 Privilege Escalation SKILL.md:13 + [HIGH] P1 Prompt Injection SKILL.md:8 +``` + +No setup needed: cue uses `skillspector` if it's on your PATH, otherwise runs it through `uvx` (cached after the first run), otherwise a local docker image. If none of those exist the gate says so out loud and falls back to cue's own SEC1-7 rules rather than silently passing. Scans run with `--no-llm`, so skill contents never leave your machine. + **Confidence tags.** cue-managed agents tag research- and decision-relevant claims with colored confidence markers so you can scan trust at a glance: | Tier | Tags | Meaning | @@ -246,9 +260,14 @@ cue cost --compare # all profiles ranked vs `full` # Skills & discovery cue discover search # find skills on GitHub -cue discover install # install one +cue discover install # install one (SkillSpector-gated) cue lint-skill --fix # validate a SKILL.md +# Security +cue security # scan the active profile's skills +cue security scan # deep NVIDIA SkillSpector scan of any skill +cue security --all --json # every skill, machine-readable + # Marketplace (push your own to cuecards.cc) cue marketplace login --token # save the API token from the studio → API view cue marketplace publish profile ship-fast # push a profile / skill / mcp for everyone diff --git a/profiles/frontend-design/profile.yaml b/profiles/frontend-design/profile.yaml new file mode 100644 index 00000000..822aaacc --- /dev/null +++ b/profiles/frontend-design/profile.yaml @@ -0,0 +1,60 @@ +name: frontend-design +icon: "🖌️" +description: Distinctive frontend visual design — Anthropic's frontend-design plugin as the method, plus a browser to verify what actually rendered +inherits: web-frontend-base +recommends: + - frontend # combine to implement the design in React/Next/Svelte + - designer # combine for the heavy kit: brandkit, imagegen, taste-skills, Figma + - medusa-vite # combine to design a Medusa + Vite storefront + - medusa-next # combine to design a Medusa + Next.js storefront +playbooks: + # Inherits ship-feature, triage-bug, sprint from core. + - designer-workflow # brief → layout → build → design-review → polish +persona: | + You design frontends that could not be mistaken for anyone else's. The + `frontend-design` plugin skill is the method — load it and follow its + process (brainstorm → explore → plan → critique → build → critique again) + rather than improvising a look. + + - **Name the subject before you design.** If the brief doesn't pin down what + the product is, who it's for, and the page's single job, pin it yourself + and say so. Distinctive choices come from the subject's own world — its + materials, vernacular, artifacts — not from a palette generator. + - **The template answer is the one to beat.** A big number with a small + label, a gradient accent, 01/02/03 markers: use them only when the content + genuinely is a sequence or a statistic. Otherwise they read as defaults. + - **Typography carries the personality.** Pair display and body faces + deliberately, set an intentional scale. Not a neutral delivery vehicle. + - **Take one real aesthetic risk you can justify.** Then justify it out loud. + - **Motion is deliberate or absent.** One orchestrated moment beats scattered + effects; excess animation is itself a tell that a machine made this. + - **Match complexity to the vision.** Maximalist needs elaborate execution; + minimal needs precision in spacing, type, and detail. + - **No em-dashes in UI copy.** Copy makes a design feel templated as fast as + the layout does — write it with the same intent. + + ## Verify what rendered, not what you wrote + + A design claim is unproven until you have looked at it. `web-frontend-base` + supplies `agent-browser` and the screenshot skill — open the page, capture + it, and check the result against the brief before calling it done. Reading + your own CSS is not verification. + + ## When combined with other profiles + + - With **frontend**: you own the visual layer, that profile owns the + component/state/a11y correctness bar. + - With **designer**: reach for that profile's brandkit, image generation, and + Figma MCP when the job needs assets or an existing design file. This + profile stays lean on purpose — combine rather than duplicate. +skills: + local: + # Creation is the plugin's job (see `plugins:` below); this covers the + # plugin's own "critique again" step with a structured review pass. + - gstack/design-review +plugins: + # Anthropic's official frontend-design plugin — the aesthetic-direction + # method this profile is built around. Already shipped by designer/commerce/ + # studio/webshop; here it is the centrepiece rather than an add-on. + - frontend-design@claude-plugins-official +mcps: [] # browser automation via the agent-browser CLI (no MCP needed) diff --git a/profiles/reverse-skill/profile.yaml b/profiles/reverse-skill/profile.yaml new file mode 100644 index 00000000..60bfeae5 --- /dev/null +++ b/profiles/reverse-skill/profile.yaml @@ -0,0 +1,93 @@ +name: reverse-skill +icon: "🧬" +description: "Authorized reverse-engineering, pentest & CTF skill router — 39 RE/pentest skills from NagyVikt/reverse-skill, scope-gated" +inherits: core +recommends: + - cybersecurity # combine for the 700-skill SOC/forensics set + mitre-attack / cve-search / hexstrike-ai MCPs +persona: | + You are a reverse-engineering and authorized-pentest operator. The + `reverse-skill` router is your method: route first, then act. Do not + improvise a toolchain when a skill already encodes the workflow. + + - **Scope gate before any action against a target.** Confirm the user has + legal authorization (own system, SRC/bug-bounty, or CTF) and a defined + range. Never expand attack surface beyond what the user specified. If + authorization is unclear, stop and ask — this gate is non-negotiable. + - **Route, don't freestyle.** Match the task against the routing ladder and + open the matching skill: APK/smali → apk-reverse, iOS/IPA → mobile-reverse, + frontend crypto/JS signing → js-reverse, .NET → dotnet-reverse, IDA/r2 deep + dive → ida-reverse/radare2, firmware/IoT → firmware-pentest, samples/YARA → + malware-analysis, nmap/nuclei/sqlmap → pentest-tools, GraphQL/JWT/BOLA → + api-security, cloud/K8s → cloud-k8s, memory/timeline → digital-forensics. + No keyword match → generic reverse-engineering, then consult the matrix. + - **Cite coordinates, not vibes.** Every finding names addresses, offsets, + and function names. Pentest findings ship a reproducible PoC (exact curl / + script / path). "Some function did X" is not a finding. + - **High-severity finding → stop and report.** Surface it to the user + immediately and wait for instructions before going further. + - **Never guess tool paths.** Read the skill's tool index first; a tool that + fails twice gets manual install steps, not a third blind retry. + - **Report hygiene.** Do not retain un-anonymized sensitive data in reports + or journals. Close a case with a formal report, not a loose set of notes. +skills: + npx: + # Reverse-engineering + authorized-pentest router pack. One skill per + # subdir under the repo's skills/ tree (cue resolves skills//SKILL.md). + # Pin for reproducibility with `pin: git@` or `pin: tag@`. + - repo: NagyVikt/reverse-skill + skills: + # --- reverse engineering --- + - reverse-engineering + - ida-reverse + - radare2 + - ghidra-reverse + - binary-diff + - patch-diff-exploit + - go-rust-reverse + - dotnet-reverse + - macos-reverse + - protocol-reverse + # --- mobile / apk --- + - apk-reverse + - mobile-reverse + # --- web / js / api --- + - js-reverse + - browser-automation + - browser-extension-reverse + - api-security + - email-security + # --- pentest / offense --- + - pentest-tools + - attack-chain + - pwn-chain + - edr-bypass-re + - windows-ad + - identity-federation + - thick-client + # --- infra / cloud / hardware --- + - cloud-k8s + - database-security + - firmware-pentest + - hardware-security + - radio-sdr + - wifi-wireless + - ot-ics + # --- analysis / defense-adjacent --- + - malware-analysis + - digital-forensics + - threat-hunting + - code-audit + - supply-chain-security + - llm-security + # --- deliverables --- + - docs-generator + - diagram-generator + # The repo also ships a CTF pack under CTF-Sandbox-Orchestrator/ (41 + # competition-* skills). They live outside the skills/ tree, so wire them + # into a `reverse-skill-ctf` child profile once you've confirmed the + # fetch path, e.g. cue new reverse-skill-ctf --inherits reverse-skill + +# Authorized use only. This profile assumes the scope gate in the persona: +# own systems, an explicit SRC/bug-bounty grant, or a CTF sandbox. Combine +# with `cybersecurity` (cue use reverse-skill+cybersecurity) to add the +# broader forensics/SOC skill set and the security MCPs. diff --git a/resources/hooks/liedetector-tag-density.sh b/resources/hooks/liedetector-tag-density.sh index 7dec7710..41fa5517 100755 --- a/resources/hooks/liedetector-tag-density.sh +++ b/resources/hooks/liedetector-tag-density.sh @@ -3,13 +3,15 @@ # # The integrity protocol asks the model to mark decision-relevant claims with # confidence tags (🟢 [VERIFIED], 🟡 [INFERRED ~80%], 🟠 [GUESSED ~30%], -# 🔴 [UNKNOWN], etc.). Two failure modes degrade that signal: +# 🔴 [UNKNOWN], etc.). Three failure modes degrade that signal: # (a) a long, substantive response with ZERO tags — no confidence signal at # all where the reader most needs one; -# (b) tag-spam — a tag on nearly every clause, which trains the reader to +# (b) a yellow/orange tag with no ~N%, or an ~N% that isn't on its tier's +# ladder — the tier alone can't order claims against each other; +# (c) tag-spam — a tag on nearly every clause, which trains the reader to # ignore the tags entirely. -# This hook nudges on both. It NEVER blocks the Stop; it only prints one line -# to stderr (which Claude Code surfaces) so the model can self-correct. +# This hook nudges on all three. It NEVER blocks the Stop; it only prints one +# line to stderr (which Claude Code surfaces) so the model can self-correct. # # Honest about its limits: this is a crude heuristic. It cannot tell whether a # response was actually "decision-relevant" — it uses response length (>1500 @@ -18,7 +20,9 @@ # narrative explanation). To keep the false-positive rate low it only fires on # CLEARLY long, completely tag-free responses, and stays silent otherwise. The # density check needs at least 4 tags before it can call something "spam". -# Treat every nudge as a question ("did this response need tags?"), not a verdict. +# Treat those two nudges as a question ("did this response need tags?"), not a +# verdict. Check (b) is the exception: the protocol names the legal ~N% values, +# so a missing or off-ladder percent is a fact, not a heuristic. # # Reliability: parsing the transcript can fail for many reasons (missing file, # truncated JSONL, schema drift). Every failure path FAILS OPEN — any error @@ -55,6 +59,18 @@ TAGS = ("VERIFIED", "KNOWN", "INFERRED", "ASSUMED", "GUESSED", "STALE", "UNKNOWN", "CORRECTION") TAG_RE = re.compile(r"\[(?:%s)[^\]]*\]" % "|".join(TAGS)) +# The protocol requires a ~N% on every yellow and orange tag, drawn from that +# tier's ladder. Yellow spans ~50-85%, orange ~20-45%, so the ladders don't +# overlap each other or green (>=90%). +LADDER = { + "INFERRED": {"50", "60", "70", "80"}, + "ASSUMED": {"50", "60", "70", "80"}, + "GUESSED": {"20", "30", "40"}, + "STALE": {"20", "30", "40"}, +} +CAL_RE = re.compile(r"\[(%s)([^\]]*)\]" % "|".join(LADDER)) +PCT_RE = re.compile(r"~\s*(\d+)\s*%") + LONG_CHARS = 1500 # proxy for "substantive response" SPAM_MIN_TAGS = 4 # need real density before calling it spam SPAM_WORDS_PER_TAG = 25 # > 1 tag / 25 words = spam @@ -106,7 +122,32 @@ if n_chars > LONG_CHARS and n_tags == 0: "Skip this nudge with [skip-tag-density]." % n_chars) sys.exit(0) -# (b) tag-spam → density trains the reader to ignore the tags. +# (b) calibration format → a yellow/orange tag with no ~N%, or with one that +# isn't on its tier's ladder. Unlike the two heuristics around it this is +# an exact check: the protocol names the legal values, so a miss is a +# violation rather than a guess about intent. +missing, offladder = [], [] +for tag, rest in CAL_RE.findall(last): + pct = PCT_RE.search(rest) + if not pct: + missing.append(tag) + elif pct.group(1) not in LADDER[tag]: + offladder.append("%s ~%s%%" % (tag, pct.group(1))) + +if missing or offladder: + parts = [] + if missing: + parts.append("%d tag(s) with no ~N%% (%s)" + % (len(missing), ", ".join(sorted(set(missing))))) + if offladder: + parts.append("%d off-ladder (%s)" + % (len(offladder), ", ".join(sorted(set(offladder))))) + print("liedetector: %s. Yellow ([INFERRED]/[ASSUMED]) takes ~50/60/70/80%%, " + "orange ([GUESSED]/[STALE]) takes ~20/30/40%% — nothing else. " + "Skip with [skip-tag-density]." % "; ".join(parts)) + sys.exit(0) + +# (c) tag-spam → density trains the reader to ignore the tags. if n_tags >= SPAM_MIN_TAGS and n_words > 0: words_per_tag = n_words / n_tags if words_per_tag < SPAM_WORDS_PER_TAG: diff --git a/resources/hooks/tag-audit.sh b/resources/hooks/tag-audit.sh index 1ca576c5..9e4dbc0c 100755 --- a/resources/hooks/tag-audit.sh +++ b/resources/hooks/tag-audit.sh @@ -11,6 +11,12 @@ # - For every [KNOWN] claim that mentions a time-sensitive subject # (versions, "latest", "current"), warn — training data goes stale. # +# It also reports the turn's TAG MIX — the green/yellow/orange/red split of +# the claims, with a "% grounded" and "% guess-or-worse" readout. The audits +# above catch violations; the mix answers the plainer question the tags exist +# for: how much of this answer did the model actually check? Prints on turns +# with >=3 tags. Disable with CUE_TAG_MIX_OFF=1. +# # When mismatches are detected, the hook emits a "⚠ Tag audit" block to # stderr (which Claude Code surfaces). It never blocks; it only flags. # Suppress per-turn via [skip-tag-audit] anywhere in the assistant response. @@ -46,8 +52,10 @@ touch "$throttle" last_user_line=$(awk 'BEGIN{n=0; last=0} {n++} /"type":"user"/{last=n} END{print last}' "$transcript_path") [ "$last_user_line" = "0" ] && exit 0 -# Slice the transcript: just this turn's lines. -turn_jsonl="$CACHE_DIR/turn.jsonl" +# Slice the transcript: just this turn's lines. Session-scoped — concurrent +# Claude sessions share $CACHE_DIR, and an unscoped path lets one session's +# Stop hook overwrite another's slice mid-read. +turn_jsonl="$CACHE_DIR/turn.${session_id:-default}.jsonl" tail -n +"$last_user_line" "$transcript_path" > "$turn_jsonl" # ─── Extract assistant text + tool_use names from this turn ──────────────── @@ -73,10 +81,22 @@ if grep -qF "[skip-tag-audit]" <<< "$assistant_text"; then exit 0; fi # ─── Count tags in the response ──────────────────────────────────────────── # Match [VERIFIED], 🟢 [VERIFIED], `[VERIFIED]`, etc. Single regex with # optional brackets/backticks. -verified_count=$(grep -oE '\[VERIFIED[^]]*\]' <<< "$assistant_text" | wc -l | tr -d '\n') -known_count=$(grep -oE '\[KNOWN[^]]*\]' <<< "$assistant_text" | wc -l | tr -d '\n') -verified_count=${verified_count:-0} -known_count=${known_count:-0} +count_tag() { grep -oE "\[$1[^]]*\]" <<< "$assistant_text" | wc -l | tr -d '\n'; } + +verified_count=$(count_tag VERIFIED); verified_count=${verified_count:-0} +known_count=$(count_tag KNOWN); known_count=${known_count:-0} +inferred_count=$(count_tag INFERRED); inferred_count=${inferred_count:-0} +assumed_count=$(count_tag ASSUMED); assumed_count=${assumed_count:-0} +guessed_count=$(count_tag GUESSED); guessed_count=${guessed_count:-0} +stale_count=$(count_tag STALE); stale_count=${stale_count:-0} +unknown_count=$(count_tag UNKNOWN); unknown_count=${unknown_count:-0} +correction_count=$(count_tag CORRECTION); correction_count=${correction_count:-0} + +green_count=$((verified_count + known_count)) +yellow_count=$((inferred_count + assumed_count)) +orange_count=$((guessed_count + stale_count)) +red_count=$unknown_count +claim_count=$((green_count + yellow_count + orange_count + red_count)) # ─── Count verification tool calls ───────────────────────────────────────── # A "verification action" is one of: @@ -157,7 +177,22 @@ if [ "$stale_known" -gt 0 ]; then warnings+=("⚠ Tag audit: ${stale_known}× [KNOWN] tag on time-sensitive subject(s) (versions / 'latest' / 'current'). Training data goes stale. Downgrade to [STALE] or re-verify via web search.") fi -[ "${#warnings[@]}" -eq 0 ] && exit 0 +# ─── Tag mix: how much of this turn was grounded vs guessed ──────────────── +# Everything above detects protocol *violations*. This block answers the +# plainer question the tags exist for: how much of what I just said did I +# actually check? Prints whenever the turn carries enough tags to form a +# distribution (>=3), so one-tag asides stay quiet. Disable: CUE_TAG_MIX_OFF=1. +mix_line="" +if [ "${CUE_TAG_MIX_OFF:-}" != "1" ] && [ "$claim_count" -ge 3 ]; then + green_pct=$((green_count * 100 / claim_count)) + soft_pct=$(((orange_count + red_count) * 100 / claim_count)) + mix_line="$(printf '🕵 Tag mix (%d claims): 🟢%d 🟡%d 🟠%d 🔴%d — %d%% grounded, %d%% guess-or-worse' \ + "$claim_count" "$green_count" "$yellow_count" "$orange_count" "$red_count" \ + "$green_pct" "$soft_pct")" + [ "$correction_count" -gt 0 ] && mix_line="${mix_line} | ${correction_count}x [CORRECTION]" +fi + +[ "${#warnings[@]}" -eq 0 ] && [ -z "$mix_line" ] && exit 0 # ─── Opt-in: auto-log detected miscalibrations to the calibration scoreboard ─ # The always-on audit detects exactly the events the scoreboard wants to tally @@ -190,9 +225,12 @@ fi # ─── Emit warnings to stderr (Claude Code surfaces) ──────────────────────── { printf '\n' - for w in "${warnings[@]}"; do printf '%s\n' "$w"; done - printf ' (turn tool calls: %d verification, %d non-verification | suppress with [skip-tag-audit])\n' \ - "$verification_count" "$non_verification_count" + [ -n "$mix_line" ] && printf '%s\n' "$mix_line" + if [ "${#warnings[@]}" -gt 0 ]; then + for w in "${warnings[@]}"; do printf '%s\n' "$w"; done + printf ' (turn tool calls: %d verification, %d non-verification | suppress with [skip-tag-audit])\n' \ + "$verification_count" "$non_verification_count" + fi } >&2 exit 0 diff --git a/resources/personas/integrity-protocol-compact.md b/resources/personas/integrity-protocol-compact.md index 2235bb55..28494fe6 100644 --- a/resources/personas/integrity-protocol-compact.md +++ b/resources/personas/integrity-protocol-compact.md @@ -12,7 +12,9 @@ Applies to every response. Flag uncertainty *before* the claim, never bury hedge - 🟠 `[STALE]` — true at training cutoff; re-check current docs. - 🔴 `[UNKNOWN]` — outside reliable knowledge; say so instead of fabricating. -Pick the *most specific* tag and **downgrade when in doubt** — false confidence hurts more than false hedging. Optional decile calibration on yellow/orange (`🟡 [INFERRED ~80%]`), required when the user must rank two of your suggestions. +Pick the *most specific* tag and **downgrade when in doubt** — false confidence hurts more than false hedging. + +**Every yellow and orange tag carries a `~N%`** drawn from its tier's ladder — yellow `~50/60/70/80%`, orange `~20/30/40%`, nothing else. A bare `[INFERRED]` or `[GUESSED]` is a protocol violation; so is `~67%` (false precision), `~90%` on yellow (green's range), or `~50%` on orange (yellow's). Skip the % on green and red — the tier already says it. Can't pick a value? You're in the wrong tier: downgrade. The number orders claims *within one response*; it is not a calibrated absolute probability. **Confidence audit** when a response has 2+ yellow-or-worse claims, recommends an action, or summarizes external evidence: end with Evidence quality (Strong/Moderate/Weak/Insufficient), the biggest confidence limiter, and one thing to verify externally. diff --git a/resources/personas/integrity-protocol.md b/resources/personas/integrity-protocol.md index 20993e92..72ae0561 100644 --- a/resources/personas/integrity-protocol.md +++ b/resources/personas/integrity-protocol.md @@ -24,11 +24,14 @@ Rewritten by Claude (Opus 4.7) from your hallucination-reduction draft. Applies **Red tier — don't trust, don't fabricate (~0–10%)** - 🔴 `[UNKNOWN]` — outside my reliable knowledge. I'm saying so instead of fabricating an answer. Hand off to a search or to the user. - **Optional percentage calibration on yellow/orange tags.** When a claim sits at a notable edge of its tier (or stakes warrant more precision), append a decile-snapped estimate with a tilde to signal it's a rough self-calibration, not a true probability: `🟡 [INFERRED ~80%]`, `🟠 [GUESSED ~30%]`. Rules: - - Snap to deciles (20 / 30 / 40 / 60 / 80 / 90), never `~67%` or `~73%` — that's false precision + **Required percentage calibration on yellow/orange tags.** Every yellow and orange tag carries a `~N%` drawn from its tier's ladder, with a tilde to signal it's a rough self-calibration rather than a true probability: `🟡 [INFERRED ~80%]`, `🟠 [GUESSED ~30%]`. Rules: + - Yellow (`[INFERRED]`, `[ASSUMED]`) → one of `~50%` `~60%` `~70%` `~80%` + - Orange (`[GUESSED]`, `[STALE]`) → one of `~20%` `~30%` `~40%` + - Nothing else on the ladder. Never `~67%` or `~73%` (false precision), never `~90%` on yellow (that's green's range) or `~50%` on orange (that's yellow's) + - A bare `[INFERRED]` / `[ASSUMED]` / `[GUESSED]` / `[STALE]` is a protocol violation - Always prefix `~` so the reader knows it's an estimate - Skip the % on green and red — the tier already says it - - Required when the user has to decide between two of your suggestions and the order of confidence matters more than the tier itself + - If you can't pick a value, you're in the wrong tier — downgrade to the one where the range fits - The number is meaningful as *relative* ordering across claims in the same response, *not* as a literal calibrated probability **Picking the tag.** Choose the *most specific* fit, never grade-inflate: From c495340638fe205324c29645a196d9bc0e9e47d6 Mon Sep 17 00:00:00 2001 From: Viktor Nagy <137165288+NagyVikt@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:43:06 +0200 Subject: [PATCH 20/33] fix(liedetector): one ~N% raster, a drift guard, and hook test coverage (#125) The confidence protocol stated four different rules for the ~N% calibration on yellow/orange tags. The always-on compact persona called it optional; the skill called a bare [INFERRED] a protocol violation. So the rule that applied depended on which source happened to be in context. Settle on one: required, snapped to a 5-point raster (yellow ~50-85%, orange ~20-45%). The tiers no longer overlap each other or green. 14 steps rather than a coarser ladder, because the number exists to order claims against each other; not finer, because self-reported confidence is miscalibrated in absolute terms and ~67% would read as a measurement that never happened. liedetector-tag-density.sh gains an exact check for it: a yellow/orange tag with no ~N%, or one off the raster, is now flagged. The two heuristics around it stay heuristics; this one is a fact, since the protocol names the legal values. src/lib/integrity-ladder.test.ts is a drift guard. The raster lives in two scripts that cannot import from each other -- the hook here, and the eval grader in the resources/skills submodule, which also ships standalone via npx to agents with no cue tree. The guard reads both definitions and asserts they match, plus that all four prose sources state the same rule. Verified by injecting a one-sided change and watching it fail. src/lib/liedetector-hooks.test.ts is the first coverage under resources/hooks: 16 tests driving both Stop hooks through synthetic transcripts. Note the two traps it documents -- transcript records must be compact JSON, and pointing HOME at a temp dir breaks a wrapper-script python3, which silently turns every "expects no output" assertion into a vacuous pass. summon.test.ts: the mcp_status test pinned browser/lightpanda + core, and 18570880 (#121) dropped the lightpanda MCP from every profile that pinned it, so it asserted a pairing that no longer existed. It now derives a live pairing instead of hardcoding one, and fails loudly rather than skipping if none is satisfiable. README/llms.txt: 86 -> 87 profiles, which 062b40a3 left behind. resources/skills: fast-forward the pointer onto opencue/skills#18, which carries the matching grader change. e9e6657 is an ancestor, so nothing is lost; the bump also closes a pre-existing 4-commit lag. Co-authored-by: NagyVikt Co-authored-by: Claude Opus 5 (1M context) --- README.md | 4 +- llms.txt | 2 +- resources/hooks/liedetector-tag-density.sh | 22 +- .../personas/integrity-protocol-compact.md | 2 +- resources/personas/integrity-protocol.md | 7 +- resources/skills | 2 +- src/commands/summon.test.ts | 73 ++++- src/lib/integrity-ladder.test.ts | 120 +++++++ src/lib/liedetector-hooks.test.ts | 302 ++++++++++++++++++ 9 files changed, 507 insertions(+), 27 deletions(-) create mode 100644 src/lib/integrity-ladder.test.ts create mode 100644 src/lib/liedetector-hooks.test.ts diff --git a/README.md b/README.md index 8b79b96f..157a67ac 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Cold start 50–200 ms, warm start under 5 ms. Nothing stays resident. Full flow --- -## 86 ready-made cuecards +## 87 ready-made cuecards cue ships with pre-built profiles for common stacks and workflows. A taste: @@ -208,7 +208,7 @@ cue ships with pre-built profiles for common stacks and workflows. A taste: | 🏢 **agency** | 63 delegatable subagents — design, sales, product, PM, QA | ```bash -cue list # see all 86 +cue list # see all 87 cue auto-detect # suggest the right one for the current directory cue use # pin it ``` diff --git a/llms.txt b/llms.txt index 5d15c9dc..9da097a4 100644 --- a/llms.txt +++ b/llms.txt @@ -7,7 +7,7 @@ Key facts an LLM should know up front: - Install: `npm install -g cue-ai`. Pin a profile to a repo: `echo > .cue.profile`. Then type `claude` or `codex` — the shim launches the real binary with the scoped runtime. - Architecture: resolve → materialize → exec. Hash-cached: warm start <5 ms. No daemon, no background process. -- Profiles inherit from a `core` baseline. 86 profiles ship by default (backend, frontend, marketing, cybersecurity, medusa-dev, …). +- Profiles inherit from a `core` baseline. 87 profiles ship by default (backend, frontend, marketing, cybersecurity, medusa-dev, …). - Ten agents supported via `cue materialize `: claude, codex, cursor, cline, gemini, copilot, windsurf, roo, amp, aider. - License: MIT. Repo: https://github.com/opencue/cuecards. Package: https://www.npmjs.com/package/cue-ai. diff --git a/resources/hooks/liedetector-tag-density.sh b/resources/hooks/liedetector-tag-density.sh index 41fa5517..58a99705 100755 --- a/resources/hooks/liedetector-tag-density.sh +++ b/resources/hooks/liedetector-tag-density.sh @@ -59,14 +59,18 @@ TAGS = ("VERIFIED", "KNOWN", "INFERRED", "ASSUMED", "GUESSED", "STALE", "UNKNOWN", "CORRECTION") TAG_RE = re.compile(r"\[(?:%s)[^\]]*\]" % "|".join(TAGS)) -# The protocol requires a ~N% on every yellow and orange tag, drawn from that -# tier's ladder. Yellow spans ~50-85%, orange ~20-45%, so the ladders don't -# overlap each other or green (>=90%). +# The protocol requires a ~N% on every yellow and orange tag, snapped to a +# 5-point raster. Yellow spans ~50-85%, orange ~20-45%, so the tiers don't +# overlap each other or green (>=90%). The raster is deliberately coarser than +# the model's apparent precision: self-reported LLM confidence is miscalibrated +# in absolute terms, so ~67% would read as a measurement where none happened. +# 14 steps is enough to ORDER claims against each other, which is all the number +# is for. LADDER = { - "INFERRED": {"50", "60", "70", "80"}, - "ASSUMED": {"50", "60", "70", "80"}, - "GUESSED": {"20", "30", "40"}, - "STALE": {"20", "30", "40"}, + "INFERRED": {"50", "55", "60", "65", "70", "75", "80", "85"}, + "ASSUMED": {"50", "55", "60", "65", "70", "75", "80", "85"}, + "GUESSED": {"20", "25", "30", "35", "40", "45"}, + "STALE": {"20", "25", "30", "35", "40", "45"}, } CAL_RE = re.compile(r"\[(%s)([^\]]*)\]" % "|".join(LADDER)) PCT_RE = re.compile(r"~\s*(\d+)\s*%") @@ -142,8 +146,8 @@ if missing or offladder: if offladder: parts.append("%d off-ladder (%s)" % (len(offladder), ", ".join(sorted(set(offladder))))) - print("liedetector: %s. Yellow ([INFERRED]/[ASSUMED]) takes ~50/60/70/80%%, " - "orange ([GUESSED]/[STALE]) takes ~20/30/40%% — nothing else. " + print("liedetector: %s. Yellow ([INFERRED]/[ASSUMED]) takes ~50-85%%, " + "orange ([GUESSED]/[STALE]) takes ~20-45%%, both in 5-point steps. " "Skip with [skip-tag-density]." % "; ".join(parts)) sys.exit(0) diff --git a/resources/personas/integrity-protocol-compact.md b/resources/personas/integrity-protocol-compact.md index 28494fe6..39f1d113 100644 --- a/resources/personas/integrity-protocol-compact.md +++ b/resources/personas/integrity-protocol-compact.md @@ -14,7 +14,7 @@ Applies to every response. Flag uncertainty *before* the claim, never bury hedge Pick the *most specific* tag and **downgrade when in doubt** — false confidence hurts more than false hedging. -**Every yellow and orange tag carries a `~N%`** drawn from its tier's ladder — yellow `~50/60/70/80%`, orange `~20/30/40%`, nothing else. A bare `[INFERRED]` or `[GUESSED]` is a protocol violation; so is `~67%` (false precision), `~90%` on yellow (green's range), or `~50%` on orange (yellow's). Skip the % on green and red — the tier already says it. Can't pick a value? You're in the wrong tier: downgrade. The number orders claims *within one response*; it is not a calibrated absolute probability. +**Every yellow and orange tag carries a `~N%`** on a 5-point raster — yellow `~50%` to `~85%`, orange `~20%` to `~45%`, nothing between the steps. A bare `[INFERRED]` or `[GUESSED]` is a protocol violation; so is `~67%` (false precision), `~90%` on yellow (green's range), or `~50%` on orange (yellow's). Skip the % on green and red — the tier already says it. Can't pick a value? You're in the wrong tier: downgrade. The number orders claims *within one response*; it is not a calibrated absolute probability, so don't reach past the raster for digits you didn't measure. **Confidence audit** when a response has 2+ yellow-or-worse claims, recommends an action, or summarizes external evidence: end with Evidence quality (Strong/Moderate/Weak/Insufficient), the biggest confidence limiter, and one thing to verify externally. diff --git a/resources/personas/integrity-protocol.md b/resources/personas/integrity-protocol.md index 72ae0561..63af819c 100644 --- a/resources/personas/integrity-protocol.md +++ b/resources/personas/integrity-protocol.md @@ -25,9 +25,10 @@ Rewritten by Claude (Opus 4.7) from your hallucination-reduction draft. Applies - 🔴 `[UNKNOWN]` — outside my reliable knowledge. I'm saying so instead of fabricating an answer. Hand off to a search or to the user. **Required percentage calibration on yellow/orange tags.** Every yellow and orange tag carries a `~N%` drawn from its tier's ladder, with a tilde to signal it's a rough self-calibration rather than a true probability: `🟡 [INFERRED ~80%]`, `🟠 [GUESSED ~30%]`. Rules: - - Yellow (`[INFERRED]`, `[ASSUMED]`) → one of `~50%` `~60%` `~70%` `~80%` - - Orange (`[GUESSED]`, `[STALE]`) → one of `~20%` `~30%` `~40%` - - Nothing else on the ladder. Never `~67%` or `~73%` (false precision), never `~90%` on yellow (that's green's range) or `~50%` on orange (that's yellow's) + - Yellow (`[INFERRED]`, `[ASSUMED]`) → `~50%` to `~85%` in 5-point steps: `~50%` `~55%` `~60%` `~65%` `~70%` `~75%` `~80%` `~85%` + - Orange (`[GUESSED]`, `[STALE]`) → `~20%` to `~45%` in 5-point steps: `~20%` `~25%` `~30%` `~35%` `~40%` `~45%` + - Nothing between the steps. Never `~67%` or `~73%` (false precision), never `~90%` on yellow (that's green's range) or `~50%` on orange (that's yellow's) + - The raster is coarser than your apparent precision on purpose. Self-reported confidence is miscalibrated in absolute terms, so a digit you didn't measure reads as a measurement. 14 steps is enough to *order* claims, which is all the number does - A bare `[INFERRED]` / `[ASSUMED]` / `[GUESSED]` / `[STALE]` is a protocol violation - Always prefix `~` so the reader knows it's an estimate - Skip the % on green and red — the tier already says it diff --git a/resources/skills b/resources/skills index e9e6657c..5a49e6c1 160000 --- a/resources/skills +++ b/resources/skills @@ -1 +1 @@ -Subproject commit e9e6657c3dbe9ee7ab16c3a5cb565a79c6559559 +Subproject commit 5a49e6c1164bb1828af4620125dece7ae15892e1 diff --git a/src/commands/summon.test.ts b/src/commands/summon.test.ts index 13e69e19..9eb91286 100644 --- a/src/commands/summon.test.ts +++ b/src/commands/summon.test.ts @@ -4,7 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { summon, detectActiveProfile, REEXEC_CMD } from "./summon"; -import { existsSync } from "node:fs"; +import { loadProfile } from "../lib/profile-loader"; +import { getSkillDependencies } from "../lib/skill-dependencies"; +import { existsSync, readdirSync } from "node:fs"; // `resources/skills` is a git submodule. The mcp_status assertion below reads a // skill's `requires_mcps` from disk; without it checked out (`git submodule @@ -12,6 +14,44 @@ import { existsSync } from "node:fs"; // rather than fail spuriously. const SKILLS_PRESENT = existsSync(join(import.meta.dir, "../../resources/skills/skills")); +/** + * Find a live (profile with an MCP-gated skill, other profile supplying that + * skill's MCPs) pairing. Returns null when no such pairing exists. + */ +async function findMcpGatedPair(): Promise< + { summonProfile: string; skillId: string; deps: string[]; provider: string } | null +> { + const names = readdirSync(join(import.meta.dir, "../../profiles"), { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + + const loaded = new Map>>(); + for (const n of names) { + try { + loaded.set(n, await loadProfile(n)); + } catch { + // Unloadable profile — not this test's problem; profile-loader has its own. + } + } + + for (const [name, profile] of loaded) { + for (const s of profile.skills.local) { + // Keep the raw ids: summon reports `missing:` with original casing. + const deps = [...new Set(getSkillDependencies(s.id).map((d) => d.mcpId))]; + if (deps.length === 0) continue; + for (const [providerName, provider] of loaded) { + if (providerName === name) continue; + const ids = new Set(provider.mcps.map((m) => m.id.toLowerCase())); + if (deps.every((d) => ids.has(d.toLowerCase()))) { + return { summonProfile: name, skillId: s.id, deps, provider: providerName }; + } + } + } + } + return null; +} + let dir: string; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "cue-summon-")); }); afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); @@ -110,14 +150,27 @@ describe("summon", () => { }); test.skipIf(!SKILLS_PRESENT)("mcp_status reflects the active session's loaded MCPs", async () => { - // browser/lightpanda needs the `lightpanda` MCP; core loads it. - const lp = (skills: { id: string; mcp_status: string }[]) => - skills.find((s) => s.id === "browser/lightpanda"); - - const noActive = await summon({ cwd: dir, profile: "vercel", active: null, noPin: true }); - const withCore = await summon({ cwd: dir, profile: "vercel", active: "core", noPin: true }); - - expect(lp(noActive.skills)?.mcp_status).toBe("missing:lightpanda"); - expect(lp(withCore.skills)?.mcp_status).toBe("ok"); + // The pairing is derived from live profile data, not hardcoded. This test + // used to pin browser/lightpanda + core; 18570880 (#121) dropped the + // lightpanda MCP from every profile that pinned it, so the assertion named + // a pairing that no longer existed and failed for a reason unrelated to + // what it tests. The behaviour under test is mcp_status resolution, so + // derive any still-valid pairing and assert against that. + const pair = await findMcpGatedPair(); + // Loud rather than a silent skip: if nothing is satisfiable, that is itself + // worth a human look, not a vacuous green. + expect(pair).not.toBeNull(); + const { summonProfile, skillId, deps, provider } = pair!; + + const find = (skills: { id: string; mcp_status: string }[]) => + skills.find((s) => s.id === skillId); + const missing = `missing:${deps.join(",")}`; + const opts = { cwd: dir, profile: summonProfile, noPin: true }; + + const noActive = await summon({ ...opts, active: null }); + const withProvider = await summon({ ...opts, active: provider }); + + expect(find(noActive.skills)?.mcp_status).toBe(missing); + expect(find(withProvider.skills)?.mcp_status).toBe("ok"); }); }); diff --git a/src/lib/integrity-ladder.test.ts b/src/lib/integrity-ladder.test.ts new file mode 100644 index 00000000..98d6a8de --- /dev/null +++ b/src/lib/integrity-ladder.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Drift guard for the integrity protocol's ~N% ladder. + * + * The ladder (yellow [INFERRED]/[ASSUMED] → ~50/60/70/80%, orange + * [GUESSED]/[STALE] → ~20/30/40%) is defined in two places that CANNOT import + * from each other: + * + * - resources/hooks/liedetector-tag-density.sh — the Stop hook, lives in the + * cue repo. + * - resources/skills/skills/meta/liedetector/scripts/run-evals.sh — the eval + * grader, lives in the resources/skills SUBMODULE and ships standalone via + * `npx agent-liedetector-skill` to agents that have no cue tree at all. + * + * Duplication is therefore the correct design, but silent divergence is not: + * the whole point of the ladder is that one rule governs every surface. This + * test reads both definitions and asserts they agree. It also asserts the + * prose sources still state the same values, so a doc edit can't drift from + * the code either. + */ + +const REPO = join(import.meta.dir, "../.."); +const HOOK = join(REPO, "resources/hooks/liedetector-tag-density.sh"); +const GRADER = join( + REPO, + "resources/skills/skills/meta/liedetector/scripts/run-evals.sh", +); + +type Ladder = Record; + +/** Parse the `LADDER = { "INFERRED": {"50", ...}, ... }` python literal. */ +function parseLadder(source: string, file: string): Ladder { + const block = source.match(/LADDER\s*=\s*\{([\s\S]*?)\n\}/); + if (!block) throw new Error(`no LADDER definition found in ${file}`); + + const ladder: Ladder = {}; + const entry = /"(\w+)"\s*:\s*\{([^}]*)\}/g; + for (const m of block[1].matchAll(entry)) { + const values = [...m[2].matchAll(/"(\d+)"/g)].map((v) => v[1]); + ladder[m[1]] = values.sort(); + } + if (Object.keys(ladder).length === 0) { + throw new Error(`LADDER in ${file} parsed to zero entries`); + } + return ladder; +} + +/** + * The one true ladder: a 5-point raster, yellow ~50-85%, orange ~20-45%. + * Changing it here means changing it in both scripts and all four prose files. + */ +const YELLOW = ["50", "55", "60", "65", "70", "75", "80", "85"]; +const ORANGE = ["20", "25", "30", "35", "40", "45"]; +const EXPECTED: Ladder = { + INFERRED: YELLOW, + ASSUMED: YELLOW, + GUESSED: ORANGE, + STALE: ORANGE, +}; + +describe("integrity protocol ~N% ladder", () => { + test("the hook and the eval grader define the same ladder", async () => { + const hook = parseLadder(await readFile(HOOK, "utf8"), HOOK); + const grader = parseLadder(await readFile(GRADER, "utf8"), GRADER); + expect(hook).toEqual(grader); + }); + + test("both match the canonical ladder", async () => { + expect(parseLadder(await readFile(HOOK, "utf8"), HOOK)).toEqual(EXPECTED); + expect(parseLadder(await readFile(GRADER, "utf8"), GRADER)).toEqual(EXPECTED); + }); + + test("tiers do not overlap each other or green", () => { + const yellow = EXPECTED.INFERRED.map(Number); + const orange = EXPECTED.GUESSED.map(Number); + // Yellow spans ~50-85%, orange ~20-45%, green starts at 90%. + expect(Math.max(...orange)).toBeLessThan(Math.min(...yellow)); + expect(Math.max(...yellow)).toBeLessThan(90); + expect(EXPECTED.ASSUMED).toEqual(EXPECTED.INFERRED); + expect(EXPECTED.STALE).toEqual(EXPECTED.GUESSED); + }); + + // The prose sources are what the model actually reads. If a doc says ~90% is + // legal on yellow while the hook flags it, the model gets nudged for obeying + // its own instructions — the exact failure this ladder was introduced to fix. + const PROSE = [ + "resources/personas/integrity-protocol.md", + "resources/personas/integrity-protocol-compact.md", + "resources/skills/skills/meta/liedetector/SKILL.md", + "resources/skills/skills/meta/integrity-tags/SKILL.md", + ]; + + // The compact persona states the raster as a range ("~50% to ~85% in 5-point + // steps") to stay short; the long-form docs also spell every step out. So + // assert the RULE — both tier boundaries plus the step size, which together + // pin all 14 values — rather than demanding 14 literals in every file. + test.each(PROSE)("%s states the 5-point raster, no stale values", async (rel) => { + const text = await readFile(join(REPO, rel), "utf8"); + + for (const v of ["50", "85"]) expect(text).toContain(`~${v}%`); // yellow bounds + for (const v of ["20", "45"]) expect(text).toContain(`~${v}%`); // orange bounds + expect(text).toMatch(/5-point/); // the step size fills in between + + // Both retired ladders are gone: the original decile list, and the 4-value + // yellow ladder that briefly replaced it. + expect(text).not.toContain("20 / 30 / 40 / 60 / 80 / 90"); + expect(text).not.toMatch(/~50%`?[,\s]*`?~60%`?[,\s]*`?~70%`?[,\s]*`?~80%/); + }); + + test("calibration is stated as required, not optional", async () => { + for (const rel of PROSE) { + const text = await readFile(join(REPO, rel), "utf8"); + expect(text).not.toMatch(/Optional\s+(decile\s+|percentage\s+)?calibration/i); + expect(text).not.toMatch(/##\s*Optional\s+`?~N%`?/i); + } + }); +}); diff --git a/src/lib/liedetector-hooks.test.ts b/src/lib/liedetector-hooks.test.ts new file mode 100644 index 00000000..a8f0a841 --- /dev/null +++ b/src/lib/liedetector-hooks.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, test, beforeAll, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, mkdir, writeFile, symlink, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** + * Behavior tests for the two liedetector Stop hooks. + * + * Both are gated shell scripts that parse a Claude Code transcript, so the only + * honest way to test them is to feed a real-shaped transcript through stdin and + * read what they emit. Nothing under resources/hooks/ had coverage before this. + * + * Transcript records MUST be compact JSON — tag-audit.sh matches the literal + * `"type":"user"` to find the turn boundary, and Claude Code writes compact + * JSONL. A pretty-printed fixture silently produces zero output. + */ + +const REPO = join(import.meta.dir, "../.."); +const TAG_AUDIT = join(REPO, "resources/hooks/tag-audit.sh"); +const DENSITY = join(REPO, "resources/hooks/liedetector-tag-density.sh"); + +type Tool = { name: string; command?: string }; + +let dir: string; +let seq = 0; + +/** + * Both hooks are gated on a state file under $HOME, so the tests must run with + * HOME pointed at a temp dir — otherwise they'd read the developer's real + * config and pass or fail for the wrong reason. + * + * That breaks `python3` on setups where it resolves to a wrapper script that + * execs `$HOME/.nix-profile/bin/python3` (nix, pyenv, mise, asdf all do shapes + * of this). The hook then fails open and emits nothing — which silently turns + * every "expects no output" assertion into a vacuous pass. So: resolve the real + * interpreter under the ambient HOME once, and front-load a PATH shim pointing + * straight at it. setup asserts the shim survives a foreign HOME, so a broken + * environment fails loudly instead of quietly greening the suite. + */ +let shimBin: string; +beforeAll(async () => { + shimBin = await mkdtemp(join(tmpdir(), "cue-hook-shim-")); + const resolved = Bun.spawnSync(["python3", "-c", "import sys; print(sys.executable)"]); + const real = resolved.stdout.toString().trim(); + if (!resolved.success || !real) { + throw new Error("cannot resolve a real python3 interpreter for hook tests"); + } + await symlink(real, join(shimBin, "python3")); + + const probe = Bun.spawnSync(["python3", "-c", "print('ok')"], { + env: { ...process.env, HOME: shimBin, PATH: `${shimBin}:${process.env.PATH}` }, + }); + if (probe.stdout.toString().trim() !== "ok") { + throw new Error("python3 shim does not survive a foreign HOME"); + } +}); + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "cue-liedetector-hooks-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +/** One user turn plus one assistant turn carrying `text` and `tools`. */ +async function transcript(text: string, tools: Tool[] = []): Promise { + const content: unknown[] = [{ type: "text", text }]; + for (const t of tools) { + content.push({ + type: "tool_use", + name: t.name, + input: t.command ? { command: t.command } : {}, + }); + } + const lines = [ + JSON.stringify({ type: "user", message: { content: "go" } }), + JSON.stringify({ type: "assistant", message: { content } }), + ]; + const path = join(dir, `t${seq++}.jsonl`); + await writeFile(path, lines.join("\n") + "\n"); + return path; +} + +/** Run a hook with the Stop payload on stdin; return everything it emitted. */ +async function runHook( + hook: string, + transcriptPath: string, + env: Record = {}, +): Promise<{ out: string; code: number }> { + const proc = Bun.spawn(["bash", hook], { + stdin: new TextEncoder().encode( + JSON.stringify({ + transcript_path: transcriptPath, + session_id: `test-${seq}`, + }), + ), + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: dir, + PATH: `${shimBin}:${process.env.PATH}`, + ...env, + }, + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { out: (stdout + stderr).trim(), code }; +} + +async function enableDensityGate() { + await mkdir(join(dir, ".config/cue"), { recursive: true }); + await writeFile(join(dir, ".config/cue/liedetector-tag-check"), ""); +} + +const PAD = " filler words to clear the length gate.".repeat(40); +const tag = (s: string) => s; // readability at call sites + +describe("tag-audit.sh", () => { + test("reports the tag mix on a turn with 3+ tags", async () => { + const t = await transcript( + tag( + "🟢 [VERIFIED] a. 🟢 [KNOWN] b. " + + "🟡 [INFERRED ~80%] c. 🟡 [INFERRED ~70%] d. 🟡 [ASSUMED ~60%] e. " + + "🟡 [ASSUMED ~50%] f. 🟡 [INFERRED ~60%] g. " + + "🟠 [GUESSED ~30%] h. 🟠 [GUESSED ~20%] i. 🟠 [STALE ~40%] j. " + + "🔴 [UNKNOWN] k.", + ), + [{ name: "Read" }, { name: "Grep" }], + ); + const { out, code } = await runHook(TAG_AUDIT, t); + expect(code).toBe(0); + expect(out).toContain("Tag mix (11 claims)"); + expect(out).toContain("🟢2 🟡5 🟠3 🔴1"); + expect(out).toContain("18% grounded"); + expect(out).toContain("36% guess-or-worse"); + // Evidence was present, so no violation warning. + expect(out).not.toContain("Tag audit:"); + }); + + test("counts [CORRECTION] separately in the mix line", async () => { + const t = await transcript( + "🟢 [VERIFIED] a. 🟡 [INFERRED ~80%] b. 🟠 [GUESSED ~30%] c. " + + "🟠 [CORRECTION] earlier I said d.", + [{ name: "Read" }], + ); + const { out } = await runHook(TAG_AUDIT, t); + expect(out).toContain("Tag mix (3 claims)"); + expect(out).toContain("1x [CORRECTION]"); + }); + + test("warns when [VERIFIED] appears with no verification action", async () => { + const t = await transcript( + "🟢 [VERIFIED] a. 🟢 [VERIFIED] b. 🟢 [VERIFIED] c. 🟡 [INFERRED ~80%] d.", + [{ name: "Write" }], + ); + const { out } = await runHook(TAG_AUDIT, t); + expect(out).toContain("zero observable verification action"); + expect(out).toContain("Tag mix (4 claims)"); + }); + + test("stays silent below the 3-tag threshold", async () => { + const t = await transcript("🟢 [VERIFIED] a. 🟡 [INFERRED ~80%] b.", [ + { name: "Read" }, + ]); + const { out } = await runHook(TAG_AUDIT, t); + expect(out).toBe(""); + }); + + test("CUE_TAG_MIX_OFF=1 suppresses the mix line", async () => { + const t = await transcript( + "🟢 [VERIFIED] a. 🟡 [INFERRED ~80%] b. 🟠 [GUESSED ~30%] c.", + [{ name: "Read" }], + ); + const { out } = await runHook(TAG_AUDIT, t, { CUE_TAG_MIX_OFF: "1" }); + expect(out).toBe(""); + }); + + test("[skip-tag-audit] suppresses the whole hook", async () => { + const t = await transcript( + "🟢 [VERIFIED] a. 🟢 [VERIFIED] b. 🟢 [VERIFIED] c. [skip-tag-audit]", + [{ name: "Write" }], + ); + const { out } = await runHook(TAG_AUDIT, t); + expect(out).toBe(""); + }); + + test("fails open on an unreadable transcript", async () => { + const { out, code } = await runHook(TAG_AUDIT, join(dir, "missing.jsonl")); + expect(code).toBe(0); + expect(out).toBe(""); + }); +}); + +describe("liedetector-tag-density.sh", () => { + test("flags a yellow tag with no ~N%", async () => { + await enableDensityGate(); + const t = await transcript( + "🟡 [INFERRED] the fix works. 🟠 [GUESSED ~30%] more bugs." + PAD, + ); + const { out, code } = await runHook(DENSITY, t); + expect(code).toBe(0); + expect(out).toContain("no ~N%"); + expect(out).toContain("INFERRED"); + }); + + test("flags off-ladder values on both tiers", async () => { + await enableDensityGate(); + const t = await transcript( + "🟡 [INFERRED ~90%] a. 🟠 [GUESSED ~50%] b." + PAD, + ); + const { out } = await runHook(DENSITY, t); + expect(out).toContain("off-ladder"); + expect(out).toContain("INFERRED ~90%"); + expect(out).toContain("GUESSED ~50%"); + }); + + test("flags false precision", async () => { + await enableDensityGate(); + const t = await transcript("🟡 [INFERRED ~67%] a." + PAD); + const { out } = await runHook(DENSITY, t); + expect(out).toContain("off-ladder"); + }); + + // One tag per transcript: packing all 28 into one response trips the + // tag-spam check instead, which is correct hook behavior but a different + // assertion. No PAD either — the zero-tag check can't fire when a tag is + // present, and 1 tag is well under the spam floor. + const YELLOW = ["50", "55", "60", "65", "70", "75", "80", "85"]; + const ORANGE = ["20", "25", "30", "35", "40", "45"]; + + test("accepts every step of the 5-point raster", async () => { + await enableDensityGate(); + const cases: Array<[string, string[]]> = [ + ["INFERRED", YELLOW], + ["ASSUMED", YELLOW], + ["GUESSED", ORANGE], + ["STALE", ORANGE], + ]; + for (const [name, ladder] of cases) { + for (const v of ladder) { + const t = await transcript(`[${name} ~${v}%] a claim.`); + const { out } = await runHook(DENSITY, t); + expect(`${name} ~${v}% -> ${out}`).toBe(`${name} ~${v}% -> `); + } + } + }); + + test("green and red carry no ~N% and stay legal", async () => { + await enableDensityGate(); + for (const name of ["VERIFIED", "KNOWN", "UNKNOWN"]) { + const t = await transcript(`[${name}] a claim.`); + const { out } = await runHook(DENSITY, t); + expect(out).toBe(""); + } + }); + + test("rejects values that fall between the 5-point steps", async () => { + await enableDensityGate(); + for (const bad of ["52", "63", "77", "22", "38"]) { + const t = await transcript(`🟡 [INFERRED ~${bad}%] a.` + PAD); + const { out } = await runHook(DENSITY, t); + expect(out).toContain("off-ladder"); + } + }); + + test("nudges a long response carrying zero tags", async () => { + await enableDensityGate(); + const t = await transcript("A long untagged answer." + PAD); + const { out } = await runHook(DENSITY, t); + expect(out).toContain("zero confidence tags"); + }); + + test("nudges tag-spam", async () => { + await enableDensityGate(); + const t = await transcript( + Array.from({ length: 12 }, (_, i) => `🟢 [KNOWN] w${i}.`).join(" "), + ); + const { out } = await runHook(DENSITY, t); + expect(out).toContain("Tag-spam"); + }); + + test("[skip-tag-density] suppresses the nudge", async () => { + await enableDensityGate(); + const t = await transcript( + "🟡 [INFERRED] no percent here. [skip-tag-density]" + PAD, + ); + const { out } = await runHook(DENSITY, t); + expect(out).toBe(""); + }); + + test("no-ops entirely when the opt-in gate is absent", async () => { + // gate deliberately not created + const t = await transcript("🟡 [INFERRED] no percent here." + PAD); + const { out, code } = await runHook(DENSITY, t); + expect(code).toBe(0); + expect(out).toBe(""); + }); +}); From 80a6b47df9ad95f811e9f0ddc0970194c39bef93 Mon Sep 17 00:00:00 2001 From: Viktor Nagy <137165288+NagyVikt@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:59:18 +0200 Subject: [PATCH 21/33] fix(codex): share AuthMux login with Cue runtimes (#141) Cause: Cue isolates CODEX_HOME per profile while AuthMux manages ~/.codex/auth.json. Tested: bun test src/lib/codex-auth-sync.test.ts Tested: bunx tsc --noEmit Co-authored-by: NagyVikt --- .../notes.md | 21 +++++++++++++ src/commands/launch.ts | 20 +++++++++++- src/lib/codex-auth-sync.test.ts | 31 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/agent-codex-sync-codex-auth-into-cue-runtimes-2026-08-10-11-56/notes.md create mode 100644 src/lib/codex-auth-sync.test.ts diff --git a/openspec/changes/agent-codex-sync-codex-auth-into-cue-runtimes-2026-08-10-11-56/notes.md b/openspec/changes/agent-codex-sync-codex-auth-into-cue-runtimes-2026-08-10-11-56/notes.md new file mode 100644 index 00000000..06269dd6 --- /dev/null +++ b/openspec/changes/agent-codex-sync-codex-auth-into-cue-runtimes-2026-08-10-11-56/notes.md @@ -0,0 +1,21 @@ +# agent-codex-sync-codex-auth-into-cue-runtimes-2026-08-10-11-56 (minimal / T1) + +Branch: `agent//` + +Describe the change in a sentence or two. Commit message is the spec of record. + +## Handoff + +- Handoff: change=`agent-codex-sync-codex-auth-into-cue-runtimes-2026-08-10-11-56`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-codex-sync-codex-auth-into-cue-runtimes-2026-08-10-11-56` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-codex-sync-codex-auth-into-cue-runtimes-2026-08-10-11-56/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). +# Codex auth sync + +- Cause: Cue launches Codex with a profile-isolated `CODEX_HOME`, while AuthMux manages `~/.codex/auth.json`. +- Fix: copy canonical auth into the selected runtime before launch, then copy refreshed runtime auth back after exit. +- Verification: `bun test src/lib/codex-auth-sync.test.ts`; `bunx tsc --noEmit`. diff --git a/src/commands/launch.ts b/src/commands/launch.ts index 58fa422f..9ba0fce6 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -14,7 +14,7 @@ */ import { spawn } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { copyFile, readFile } from "node:fs/promises"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { basename, dirname, join, resolve, sep } from "node:path"; import { homedir } from "node:os"; @@ -205,6 +205,16 @@ function execAgent(bin: string, args: string[], env: NodeJS.ProcessEnv): Promise }); } +/** Keep Cue's isolated CODEX_HOME in sync with Codex/AuthMux's canonical auth. */ +export async function syncCodexAuth(source: string, destination: string): Promise { + try { + await copyFile(source, destination); + return true; + } catch { + return false; + } +} + /** * Whether the interactive MCP toggle should open this launch. Only when stdin * is a TTY, AND either the user forced it (`--cue-pick-mcps`) or there's no @@ -2712,6 +2722,11 @@ export async function run(args: string[]): Promise { // re-login. const stopReconciler = agentKind === "claude-code" ? startCredentialReconciler(runtimeKey) : undefined; + const canonicalCodexAuth = join(homedir(), ".codex", "auth.json"); + const runtimeCodexAuth = join(runtime.runtimeDir, "auth.json"); + if (agentKind === "codex") { + await syncCodexAuth(canonicalCodexAuth, runtimeCodexAuth); + } let exitCode: number; try { exitCode = await execAgent(realBin, [...briefArgs, ...parsed.passthrough], childEnv); @@ -2721,6 +2736,9 @@ export async function run(args: string[]): Promise { // Persist any /login done inside the session to its account dir now — // don't leave the only live rotated token stranded in the per-account runtime. if (agentKind === "claude-code") await rescueRuntimeCredsToOwner(runtimeKey); + if (agentKind === "codex") { + await syncCodexAuth(runtimeCodexAuth, canonicalCodexAuth); + } // Post-session runtime GC: the child has exited, so this costs zero launch // latency. Throttled (~once/day) and never touches the runtime we just used. try { await maybeAutoGc(runtimeKey); } catch { /* GC is best-effort */ } diff --git a/src/lib/codex-auth-sync.test.ts b/src/lib/codex-auth-sync.test.ts new file mode 100644 index 00000000..ada9258b --- /dev/null +++ b/src/lib/codex-auth-sync.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { syncCodexAuth } from "../commands/launch"; + +describe("syncCodexAuth", () => { + const dirs: string[] = []; + + afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + test("copies canonical Codex auth into a Cue runtime", async () => { + const dir = await mkdtemp(join(tmpdir(), "cue-codex-auth-")); + dirs.push(dir); + const source = join(dir, "source.json"); + const destination = join(dir, "runtime.json"); + await writeFile(source, '{"tokens":{"access_token":"test"}}\n'); + + expect(await syncCodexAuth(source, destination)).toBe(true); + expect(await readFile(destination, "utf8")).toBe(await readFile(source, "utf8")); + }); + + test("fails open when no canonical login exists", async () => { + const dir = await mkdtemp(join(tmpdir(), "cue-codex-auth-")); + dirs.push(dir); + expect(await syncCodexAuth(join(dir, "missing.json"), join(dir, "runtime.json"))).toBe(false); + }); +}); From 9baf7132d0cd662ca55013aaf629e7774bef5773 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 10 Aug 2026 17:24:23 +0200 Subject: [PATCH 22/33] feat(profile): add safe AGV development profile Intent: route AGV work through typed MCP diagnostics and the guarded agvc backend. Constraint: motion and motor arming remain operator-only. Tested: cue validate agv; cue skills lint robotics/agvc; cue skills test robotics/agvc --- profiles/agv/profile.yaml | 21 +++++++++++++++++++++ resources/mcps | 2 +- resources/skills | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 profiles/agv/profile.yaml diff --git a/profiles/agv/profile.yaml b/profiles/agv/profile.yaml new file mode 100644 index 00000000..38fad35e --- /dev/null +++ b/profiles/agv/profile.yaml @@ -0,0 +1,21 @@ +name: agv +icon: "🏭" +description: "AGV3 robot development and safe operation through agv-mcp/agvc. Inherits ROS 2 tooling." +inherits: ros2 + +skills: + local: + - robotics/agvc + +mcps: + - agv-mcp + +persona: | + You operate the AGV3 ROS 2 robot through the project-owned agv-mcp and agvc + safety boundary. Start with read-only status, doctor, TF, topic, parameter, + and log evidence. Use staged bringup and preserve single-writer TF ownership. + + Obtain explicit operator confirmation before every state change. Never bypass + motor arming, deadman, command-timeout, E-stop, or cmd_vel protections through + raw ROS, SSH, Docker, ros-mcp, or ros-skill. Motor arming, velocity commands, + and navigation goals are intentionally outside agv-mcp. diff --git a/resources/mcps b/resources/mcps index d1b5d921..98df744b 160000 --- a/resources/mcps +++ b/resources/mcps @@ -1 +1 @@ -Subproject commit d1b5d921dfacc69f0390391416d5d2c67600c4f5 +Subproject commit 98df744b2b651e29f15a43d0d8d8352f1d0faafc diff --git a/resources/skills b/resources/skills index 752aef4d..11217bd6 160000 --- a/resources/skills +++ b/resources/skills @@ -1 +1 @@ -Subproject commit 752aef4d8b1d1b1db84be61026a222f47c3aa1a5 +Subproject commit 11217bd6d47b2eb8af4c4d3a2d3e86538416cc42 From 441906c02f5615ffaf66ec5e17065d7461fb83e2 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 10 Aug 2026 17:25:53 +0200 Subject: [PATCH 23/33] chore(profile): advance AGV skill contract Intent: point the AGV profile at the lint-clean, tested skill revision. Tested: cue validate agv --- resources/skills | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/skills b/resources/skills index 11217bd6..e1c830c9 160000 --- a/resources/skills +++ b/resources/skills @@ -1 +1 @@ -Subproject commit 11217bd6d47b2eb8af4c4d3a2d3e86538416cc42 +Subproject commit e1c830c9f1e007218780ad6c265d91e535e56117 From d40352c83f7678ef744d36e2aa68a16094b99808 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 10 Aug 2026 17:36:38 +0200 Subject: [PATCH 24/33] feat(profile): align AGV routing with v2 safety Intent: teach AGV agents stage-aware graph validation and two-step lifecycle confirmation. Constraint: inherited MCPs cannot be removed by Cue profile schema. Tested: cue skills lint robotics/agvc; cue skills test robotics/agvc; cue validate agv --- profiles/agv/profile.yaml | 15 ++++++++++++--- resources/mcps | 2 +- resources/skills | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/profiles/agv/profile.yaml b/profiles/agv/profile.yaml index 38fad35e..8c609cba 100644 --- a/profiles/agv/profile.yaml +++ b/profiles/agv/profile.yaml @@ -12,10 +12,19 @@ mcps: persona: | You operate the AGV3 ROS 2 robot through the project-owned agv-mcp and agvc - safety boundary. Start with read-only status, doctor, TF, topic, parameter, - and log evidence. Use staged bringup and preserve single-writer TF ownership. + safety boundary. Start with stage-aware status and graph validation. Preserve + duplicate publisher counts, structured topic payloads, rich TF freshness data, + audit evidence, and single-writer TF ownership. - Obtain explicit operator confirmation before every state change. Never bypass + State changes require prepare, explicit operator confirmation of the resolved + robot and impact, then execute with the returned short-lived one-use token. + Never treat a boolean as confirmation and never disclose tokens. Never bypass motor arming, deadman, command-timeout, E-stop, or cmd_vel protections through raw ROS, SSH, Docker, ros-mcp, or ros-skill. Motor arming, velocity commands, and navigation goals are intentionally outside agv-mcp. + + Robot selection comes only from runtime AGVC_ROBOT, AGVC_HOST, AGVC_IP, and + AGVC_CONTAINER configuration; never hardcode an address or credential. + + Cue inheritance is additive and cannot remove parent MCPs. Keep the valid ros2 + inheritance even if validation reports inherited MCP startup-cost warnings. diff --git a/resources/mcps b/resources/mcps index 98df744b..ede31e0e 160000 --- a/resources/mcps +++ b/resources/mcps @@ -1 +1 @@ -Subproject commit 98df744b2b651e29f15a43d0d8d8352f1d0faafc +Subproject commit ede31e0efef1fef08fdb368f5be118e6bbfde9e5 diff --git a/resources/skills b/resources/skills index e1c830c9..bcaecdcc 160000 --- a/resources/skills +++ b/resources/skills @@ -1 +1 @@ -Subproject commit e1c830c9f1e007218780ad6c265d91e535e56117 +Subproject commit bcaecdccbd1fbc530f142b736e7a08bd8a180a30 From 2d5b6225bd7b61654f6f368e42e9e5f1da83b4b4 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 12 Aug 2026 00:05:39 +0200 Subject: [PATCH 25/33] fix(skills): disambiguate project routing Explicit triggers keep Reddit umbrella and child skills from stealing each other's prompts while compact metadata stays within the discovery budget. Tested: bun test src/lib/project-skills-routing.test.ts src/lib/catalog-index.test.ts src/lib/skill-router.test.ts Tested: bun run typecheck Tested: cue lint-skill on all eight project skills --- .agents/skills/ai-slop-detector/SKILL.md | 96 +++++++++++-------- .agents/skills/hydra/SKILL.md | 57 +++++++---- .agents/skills/reddit-skills/SKILL.md | 29 ++++-- .../reddit-skills/skills/reddit-auth/SKILL.md | 13 ++- .../skills/reddit-content-ops/SKILL.md | 11 ++- .../skills/reddit-explore/SKILL.md | 11 ++- .../skills/reddit-interact/SKILL.md | 11 ++- .../skills/reddit-publish/SKILL.md | 11 ++- src/lib/project-skills-routing.test.ts | 92 ++++++++++++++++++ 9 files changed, 243 insertions(+), 88 deletions(-) create mode 100644 src/lib/project-skills-routing.test.ts diff --git a/.agents/skills/ai-slop-detector/SKILL.md b/.agents/skills/ai-slop-detector/SKILL.md index ce45bdc9..f9e99dd0 100644 --- a/.agents/skills/ai-slop-detector/SKILL.md +++ b/.agents/skills/ai-slop-detector/SKILL.md @@ -1,6 +1,12 @@ --- name: ai-slop-detector -description: Universal prose audit. Scores writing on TWO axes — AI-Slop (does this read like AI wrote it?) and Comprehension (can a fresh reader follow this?). Use whenever the user wants to audit, critique, score, or fix prose. Triggers on "audit this", "review this", "is this AI", "is this readable", "does this sound like AI", "humanize this", "make this less AI", "AI slop check", "score this", "detect AI writing", "slop check", "de-slop this", "is this readable", "would a fresh reader follow this", "comprehension check". Also use as a final pre-delivery pass inside other writing skills (cold-email, copywriting, sales-enablement, ad-creative, email-sequence, mahmouds-seo-writer, mahmouds-reddit-strategist, mahmouds-writing-voice). Catches 45 AI-slop patterns + ~150 vocab tells + ~33 formatting tells + 35 comprehension patterns + 8 readability metrics, with density-based scoring on both axes, audience calibration, model fingerprinting, and dual-verdict output. +description: Use when the user asks to audit, score, humanize, or fix prose for AI-writing signals or readability. Produces separate AI-slop and comprehension findings with actionable edits. +tags: [writing, editing, readability, ai-detection] +triggers: + - humanize this + - AI slop check + - does this sound AI-written + - readability audit --- # slop-cop @@ -11,8 +17,8 @@ A universal prose audit with two parallel axes. Built on ~135 published sources **Two axes, two verdicts. AI-Slop and Comprehension. A piece can pass one and fail the other.** -- **AI-Slop axis** — Does this read like AI wrote it? Patterns, vocabulary, formatting, rhythm. -- **Comprehension axis** — Can a fresh reader follow this? Acronyms, named-entity bombing, telegraphic compression, readability, structure. +- **AI-Slop axis**, Does this read like AI wrote it? Patterns, vocabulary, formatting, rhythm. +- **Comprehension axis**, Can a fresh reader follow this? Acronyms, named-entity bombing, telegraphic compression, readability, structure. Single instances aren't a signal. Density is. Both axes use density-based scoring (per 500 words, weighted by severity) with the same verdict tiers (PASS / LOW / MEDIUM / HIGH / CRITICAL). The audit reports both and combines them into a single recommendation based on whichever is worse. @@ -50,7 +56,7 @@ If ambiguous, ask one short question: "Audit only, or do you want a revised vers This skill governs prose meant for human readers. Skip it for: - Code, code comments, commit messages, or PR descriptions -- Technical API documentation or reference material (different audience target — see calibration §10) +- Technical API documentation or reference material (different audience target, see calibration §10) - Raw data, structured outputs (JSON, YAML, CSV) - Direct quotations from other people that should preserve their voice - Instructions to other agents or skills (system prompts, agent briefs) @@ -59,11 +65,11 @@ This skill governs prose meant for human readers. Skip it for: --- -## The audit workflow +## Example audit workflow Five steps. The scanner does the mechanical work on both axes; reading does the qualitative work; calibration converts findings into two verdicts plus a combined recommendation. -### Step 1 — Run the scanner +### Step 1, Run the scanner ```bash python3 scripts/scan.py /path/to/draft.md @@ -78,12 +84,20 @@ python3 scripts/scan.py --quick draft.md # Structured JSON for programmatic use: python3 scripts/scan.py --json draft.md +``` + +Audience calibration: +```bash # Set audience for comprehension-axis calibration: python3 scripts/scan.py --audience marketing draft.md python3 scripts/scan.py --audience academic draft.md python3 scripts/scan.py --audience technical draft.md +``` + +Advanced flags: +```bash # Override AI-slop genre detection: python3 scripts/scan.py --genre encyclopedic draft.md @@ -99,45 +113,45 @@ The scanner outputs: - Density signals (acronym density, named-entity density, numeric density per sentence) - Burstiness, contraction ratio, model fingerprint -### Step 2 — Read against both pattern catalogs +### Step 2, Read against both pattern catalogs **AI-Slop axis:** Load `references/patterns.md`. Walk through the 45 patterns by group. The scanner catches the mechanically-detectable subset; the rest requires reading. **Comprehension axis:** Load `references/comprehension.md`. Walk through the 35 patterns by group. Roughly 17 are mechanically detectable; the rest require reading. Particularly: -- **Buried lede / missing thesis** — does the first paragraph tell the reader the point? -- **No topic sentences** — does each paragraph open with its claim? -- **Curse of knowledge** — does the writer assume context the reader lacks? -- **No concrete examples** — does every abstract claim have a specific instance? -- **Definition by synonym** — are domain terms defined with concrete examples or just other jargon? +- **Buried lede / missing thesis**, does the first paragraph tell the reader the point? +- **No topic sentences**, does each paragraph open with its claim? +- **Curse of knowledge**, does the writer assume context the reader lacks? +- **No concrete examples**, does every abstract claim have a specific instance? +- **Definition by synonym**, are domain terms defined with concrete examples or just other jargon? For each pattern (both axes), flag with quote + severity. -### Step 3 — Apply calibration +### Step 3, Apply calibration Load `references/calibration.md`. Apply: 1. **Density-based scoring on both axes** (§1 for AI-Slop, §9 for Comprehension) 2. **Genre adjustment** (AI-Slop: §3) and **audience calibration** (Comprehension: §10) -3. **Compound triggers** — escalate when 3+ H tells in one paragraph (slop) or any 100w window has 3+ undefined acronyms / 5+ named entities (comp) -4. **Cross-axis recommendation** (§11) — single sentence based on whichever axis is worse +3. **Compound triggers**, escalate when 3+ H tells in one paragraph (slop) or any 100w window has 3+ undefined acronyms / 5+ named entities (comp) +4. **Cross-axis recommendation** (§11), single sentence based on whichever axis is worse The scanner does most of this automatically. The reader applies judgment to ambiguous cases. -### Step 4 — Output the audit report +### Step 4, Output the audit report Use the dual-verdict format in `references/audit-report-template.md`. The report has: - **Two verdicts** (AI-Slop and Comprehension) in a header table -- **Combined recommendation** — one sentence drawn from cross-axis matrix -- **Stats block** — word count, audience, burstiness, model fingerprint -- **AI-Slop axis section** — counts, top fixes, mechanical violations, qualitative violations, calibration notes -- **Comprehension axis section** — counts, top fixes, readability metrics panel, density signals, mechanical violations, qualitative violations, calibration notes -- **Combined top 3 fixes** — pulled from both axes by impact -- **Combined recommended action** — what to do next +- **Combined recommendation**, one sentence drawn from cross-axis matrix +- **Stats block**, word count, audience, burstiness, model fingerprint +- **AI-Slop axis section**, counts, top fixes, mechanical violations, qualitative violations, calibration notes +- **Comprehension axis section**, counts, top fixes, readability metrics panel, density signals, mechanical violations, qualitative violations, calibration notes +- **Combined top 3 fixes**, pulled from both axes by impact +- **Combined recommended action**, what to do next Don't soften findings on either axis. The point of the audit is to catch what the writer missed. -### Step 5 — If asked, deliver the revision +### Step 5, If asked, deliver the revision If the user wanted polish/edit (not just critique), produce the full revised version. The rewrite must address violations on **both axes**: @@ -156,26 +170,26 @@ If you only have time for a quick scan, look for these. Each appears in the high ### AI-Slop (20 most lethal) -**Vocabulary:** delve / delves, tapestry, underscore / underscores, leverage (verb), harness +**Vocabulary:** `delve` / `delves`, `tapestry`, `underscore` / `underscores`, `leverage` (verb), `harness` **Sentence-level:** "It's not X, it's Y" (negation reversal), "serves as a" / "stands as a" / "boasts" (copula avoidance), "X happened, demonstrating Y" (-ing tail), "From small startups to global enterprises" (false range), "Studies show..." with no citation -**Voice:** "Great question!" (opener sycophancy), "I hope this helps!" (closer sycophancy), "In today's fast-paced world..." (107x more in AI), "As a society, we must..." (royal we), "As of my last update..." (knowledge-cutoff leakage) +**Voice:** `Great question!` (opener sycophancy), `I hope this helps!` (closer sycophancy), `In today's fast-paced world...` (107x more in AI), `As a society, we must...` (royal we), `As of my last update...` (knowledge-cutoff leakage) -**Structural:** "In conclusion / Furthermore / Moreover" (listicle transitions), "It's worth noting that..." (throat-clearing), em dashes in clusters (3+ per 500w), bold-first bullets, "X: A Comprehensive Guide" titles +**Structural:** `In conclusion` / `Furthermore` / `Moreover` (listicle transitions), `It's worth noting that...` (throat-clearing), em dashes in clusters (3+ per 500w), bold-first bullets, `X: A Comprehensive Guide` titles ### Comprehension (10 most lethal) -1. **Undefined acronyms** — 3+ per 100 words = high cognitive load -2. **Named-entity bombing** — 5+ unfamiliar proper nouns per 100 words -3. **Stat bombing** — 3+ numerics in one sentence with no comparative anchor -4. **Telegraphic colon-labeling** — *Anchor case: X. Tools that win: Y.* Compresses topics into label-list rather than prose -5. **Coined insider terms** — "two-stack social management thesis" used as if known -6. **Long sentences** — over 30 words; comprehension drops sharply -7. **Buried lede** — main point arrives after 2+ paragraphs of setup -8. **Missing thesis** — reader can't summarize the central claim after reading -9. **Wall-of-text** — paragraphs over 100 words with no breathing room -10. **No concrete examples** — every abstract claim left abstract +1. **Undefined acronyms**, 3+ per 100 words = high cognitive load +2. **Named-entity bombing**, 5+ unfamiliar proper nouns per 100 words +3. **Stat bombing**, 3+ numerics in one sentence with no comparative anchor +4. **Telegraphic colon-labeling**, *Anchor case: X. Tools that win: Y.* Compresses topics into label-list rather than prose +5. **Coined insider terms**, "two-stack social management thesis" used as if known +6. **Long sentences**, over 30 words; comprehension drops sharply +7. **Buried lede**, main point arrives after 2+ paragraphs of setup +8. **Missing thesis**, reader can't summarize the central claim after reading +9. **Wall-of-text**, paragraphs over 100 words with no breathing room +10. **No concrete examples**, every abstract claim left abstract If a draft has 5+ AI-Slop lethal items in 500 words → AI-Slop HIGH/CRITICAL. If a draft has 3+ Comprehension lethal items in 500 words → Comprehension HIGH/CRITICAL. @@ -204,7 +218,7 @@ density = (H × 3) + (M × 1) + (L × 0.25) per 500 words - **Genre adjustments:** academic prose can use "studies show" with citations; marketing tolerates more intensifiers; encyclopedic prose triggers false positives (LLMs were trained on Wikipedia); fiction respects character voice - **Contested tells:** em dashes in clusters = H, alone = L (Mahmoud-mode = always H via `--strict-em-dash`); "actually" survives only contrasting reality with theory -- **Sanded-prose alert:** if famous vocabulary is clean but structural tells are heavy, flag — writer prompt-engineered around the v1 list +- **Sanded-prose alert:** if famous vocabulary is clean but structural tells are heavy, flag, writer prompt-engineered around the v1 list - **Uncanny valley:** if 8+ weak tells stack with burstiness below 0.5, escalate one tier ### Comprehension axis tuning @@ -216,7 +230,7 @@ density = (H × 3) + (M × 1) + (L × 0.25) per 500 words - 3+ telegraphic colon-labels in one paragraph - Any paragraph over 150 words with no subheading - Any sentence over 40 words -- **Readability metrics calibrate, patterns rule:** the metrics panel (FK Grade, lexical density, etc.) is diagnostic. The verdict comes from the catalog patterns. When metrics and patterns disagree, call it out (e.g., "patterns clean but FK Grade 16 — academic-density texture without specific failures"). +- **Readability metrics calibrate, patterns rule:** the metrics panel (FK Grade, lexical density, etc.) is diagnostic. The verdict comes from the catalog patterns. When metrics and patterns disagree, call it out (e.g., "patterns clean but FK Grade 16, academic-density texture without specific failures"). ### Cross-axis recommendation @@ -251,12 +265,12 @@ The `scripts/scan.py` scanner runs both axes in one pass and produces a report t ## A note on the detector itself -This is a shape-and-comprehension detector, not a classifier. The AI-Slop axis measures whether prose has the *shape* of AI writing — patterns, vocabulary, structure, rhythm. The Comprehension axis measures whether a *fresh reader* can follow it. Neither tells you whether AI wrote a given piece. +This is a shape-and-comprehension detector, not a classifier. The AI-Slop axis measures whether prose has the *shape* of AI writing, patterns, vocabulary, structure, rhythm. The Comprehension axis measures whether a *fresh reader* can follow it. Neither tells you whether AI wrote a given piece. A skilled writer using AI as a research tool can produce prose that scores PASS on both axes. A careful prompt can produce LOW on both. A human writing fast can produce HIGH on Comprehension while PASSing AI-Slop. A polished AI marketing post can score CRITICAL on AI-Slop while readable. Treat the AI-Slop verdict as "this prose has the shape of AI writing." Treat the Comprehension verdict as "a cold reader will struggle here." Both verdicts are actionable. -The catalog will need updating as models change. After "delve" went viral in early 2024, arXiv frequency dropped sharply. After GPT-5.1 added an em-dash opt-out (Nov 2025), em-dash density became less reliable. The skill stays useful by weighting newer patterns higher and surfacing density rather than individual hits. +The catalog will need updating as models change. After `delve` went viral in early 2024, arXiv frequency dropped sharply. After GPT-5.1 added an em-dash opt-out (Nov 2025), em-dash density became less reliable. The skill stays useful by weighting newer patterns higher and surfacing density rather than individual hits. -When the scanner says PASS but the prose still reads wrong, trust the reading — the qualitative patterns (parallel structure, force of metaphors, voice consistency, curse-of-knowledge moments) are what humans pick up first and what regex misses last. +When the scanner says PASS but the prose still reads wrong, trust the reading, the qualitative patterns (parallel structure, force of metaphors, voice consistency, curse-of-knowledge moments) are what humans pick up first and what regex misses last. diff --git a/.agents/skills/hydra/SKILL.md b/.agents/skills/hydra/SKILL.md index 9e4ae74b..7f03d7fb 100644 --- a/.agents/skills/hydra/SKILL.md +++ b/.agents/skills/hydra/SKILL.md @@ -1,19 +1,12 @@ --- name: hydra -description: > - Multi-perspective code review council: advisors analyze, reviewers - cross-examine, chairman synthesizes verdict. - USE for: architecture decisions, security audits, tradeoff analysis, - "what am I missing" questions, pre-merge deep reviews, iterative - re-reviews after fixes. - DO NOT USE for: simple code generation, syntax fixes, single-file - refactors, or factual lookups. - TRIGGERS: 'hydra', 'hydra this', 'hydra review', 'run hydra', - 'hydra deep', 'Hydra starten', - 'hydra iterate', 'hydra re-review', 'hydra follow-up', - 'hydra history', 'hydra pr', 'hydra branch', - 'hydra ?', 'hydra auto', 'fix #', 'verify', - 'hydra explain', 'hydra details', 'hydra tensions', 'hydra blind-spots'. +description: Use when the user asks for Hydra, a deep multi-perspective code review, architecture or security tradeoffs, blind spots, or iterative re-review. Avoid for simple fixes and lookups. +tags: [code-review, architecture, security, multi-perspective] +triggers: + - run Hydra + - Hydra review + - deep multi-perspective review + - review blind spots --- \n" + + content += + "\n\n" + "## ⚡ First-Time Setup\n\n" + "No `.cue.profile` is pinned to this directory. Before answering the user's first message, " + "summon the right profile into THIS session — no restart. Invoke the `meta/profile-summon` " + @@ -1417,7 +1615,12 @@ async function getProfileListForStamp(): Promise { const names = await listProfiles(); const lines: string[] = []; for (const name of names.slice(0, 15)) { - const yamlPath = join(process.env.CUE_PROFILES_DIR ?? join(resolve(import.meta.dirname, "..", ".."), "profiles"), name, "profile.yaml"); + const yamlPath = join( + process.env.CUE_PROFILES_DIR ?? + join(resolve(import.meta.dirname, "..", ".."), "profiles"), + name, + "profile.yaml", + ); try { const content = readFileSync(yamlPath, "utf8"); const iconMatch = content.match(/^icon:\s*["']?(.+?)["']?\s*$/m); @@ -1479,7 +1682,9 @@ function isInsideHome(cwd: string, home: string): boolean { return c === h || c.startsWith(h + sep); } -async function readUserClaudeMd(agent: "claude-code" | "codex"): Promise { +async function readUserClaudeMd( + agent: "claude-code" | "codex", +): Promise { const path = agent === "claude-code" ? join(homedir(), ".claude", "CLAUDE.md") @@ -1507,8 +1712,11 @@ async function readUserClaudeMd(agent: "claude-code" | "codex"): Promise * picker instead of shadowing it in the shell. `viaOverride` tells the caller to * hand that wrapper a sanitized env — see `wrapperEnv`. */ -async function findRealBinary(name: string): Promise<{ bin: string; viaOverride: boolean } | null> { - const { codexExecOverride, findRealAgentBin } = await import("../lib/claude-binary"); +async function findRealBinary( + name: string, +): Promise<{ bin: string; viaOverride: boolean } | null> { + const { codexExecOverride, findRealAgentBin } = + await import("../lib/claude-binary"); if (name === "codex") { const override = codexExecOverride(); if (override) return { bin: override, viaOverride: true }; @@ -1554,7 +1762,10 @@ function wrapperEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { async function resolveClaudeCredentialsSource( options: { runtimeDir?: string } = {}, ): Promise { - return resolveSharedClaudeCredentialsSource({ healFromRuntime: true, runtimeDir: options.runtimeDir }); + return resolveSharedClaudeCredentialsSource({ + healFromRuntime: true, + runtimeDir: options.runtimeDir, + }); } /** @@ -1588,7 +1799,12 @@ const CREDENTIAL_RECONCILE_FALLBACK_MS = 60_000; function startCredentialReconciler(runtimeKey: string): () => void { // basename() pins the path inside the runtime tree — this writes token // files, so a runtime key carrying a separator must not escape it. - const runtimeClaudeDir = join(configDir(), "runtime", basename(runtimeKey), "claude"); + const runtimeClaudeDir = join( + configDir(), + "runtime", + basename(runtimeKey), + "claude", + ); let stopped = false; let timer: ReturnType | undefined; @@ -1597,10 +1813,20 @@ function startCredentialReconciler(runtimeKey: string): () => void { const tick = async (): Promise => { let delayMs = CREDENTIAL_RECONCILE_FALLBACK_MS; try { - const { listKnownAccountDirs, reconcileCredentials, readExpiresAt, nextReconcileDelayMs } = - await import("../lib/credentials-sync"); - await reconcileCredentials(runtimeClaudeDir, await listKnownAccountDirs(homedir())); - delayMs = nextReconcileDelayMs(await readExpiresAt(runtimeClaudeDir), Date.now()); + const { + listKnownAccountDirs, + reconcileCredentials, + readExpiresAt, + nextReconcileDelayMs, + } = await import("../lib/credentials-sync"); + await reconcileCredentials( + runtimeClaudeDir, + await listKnownAccountDirs(homedir()), + ); + delayMs = nextReconcileDelayMs( + await readExpiresAt(runtimeClaudeDir), + Date.now(), + ); } catch (err) { debug("launch:cred-reconcile", err); } @@ -1630,13 +1856,24 @@ function startCredentialReconciler(runtimeKey: string): () => void { */ async function rescueRuntimeCredsToOwner(runtimeKey: string): Promise { try { - const { listKnownAccountDirs, rescueRuntimeCredentials } = await import("../lib/credentials-sync"); + const { listKnownAccountDirs, rescueRuntimeCredentials } = + await import("../lib/credentials-sync"); // basename() pins the path inside the runtime tree — this helper WRITES // token files, so a runtime key with a path separator must not escape. - const runtimeClaudeDir = join(configDir(), "runtime", basename(runtimeKey), "claude"); - const result = await rescueRuntimeCredentials(runtimeClaudeDir, await listKnownAccountDirs(homedir())); + const runtimeClaudeDir = join( + configDir(), + "runtime", + basename(runtimeKey), + "claude", + ); + const result = await rescueRuntimeCredentials( + runtimeClaudeDir, + await listKnownAccountDirs(homedir()), + ); if (result.rescued) { - process.stderr.write(`▸ cue: wrote login-fresh credentials back to ${result.to}\n`); + process.stderr.write( + `▸ cue: wrote login-fresh credentials back to ${result.to}\n`, + ); } } catch (err) { debug("launch:cred-rescue", err); @@ -1654,7 +1891,10 @@ async function rescueRuntimeCredsToOwner(runtimeKey: string): Promise { * undefined for the default `~/.claude` and any non-authmux config dir, which * preserves the plain per-profile runtime. */ -export function authmuxAccountTag(configDirEnv: string | undefined, homeDir: string): string | undefined { +export function authmuxAccountTag( + configDirEnv: string | undefined, + homeDir: string, +): string | undefined { if (!configDirEnv) return undefined; const resolved = resolve(configDirEnv); for (const root of [ @@ -1695,7 +1935,9 @@ export async function run(args: string[]): Promise { const parsed = parse(args); if (!parsed.agent) { - process.stderr.write("cue launch: missing agent (use 'claude' or 'codex')\n"); + process.stderr.write( + "cue launch: missing agent (use 'claude' or 'codex')\n", + ); return 1; } @@ -1778,23 +2020,29 @@ export async function run(args: string[]): Promise { isTTY, isBareLaunch: parsed.passthrough.length === 0, }); - const resolvedForCwd = forcePicker ? { source: "none" as const } : existingResolved; + const resolvedForCwd = forcePicker + ? { source: "none" as const } + : existingResolved; let inheritedProfile: string | null = null; - if (shouldInheritSessionProfile({ - resolvedNone: resolvedForCwd.source === "none", - forcePick: parsed.forcePick, - isTTY, - })) { + if ( + shouldInheritSessionProfile({ + resolvedNone: resolvedForCwd.source === "none", + forcePick: parsed.forcePick, + isTTY, + }) + ) { const { detectActiveProfile } = await import("./summon"); inheritedProfile = detectActiveProfile(); - if (inheritedProfile) debug("launch:inherited-session-profile", inheritedProfile); + if (inheritedProfile) + debug("launch:inherited-session-profile", inheritedProfile); } const resolved = inheritedProfile ? { source: "session" as const, profile: inheritedProfile } : resolvedForCwd; - const existingProfile = existingResolved.source !== "none" - ? (existingResolved as { source: string; profile: string }).profile - : undefined; + const existingProfile = + existingResolved.source !== "none" + ? (existingResolved as { source: string; profile: string }).profile + : undefined; let profileName: string; // The picker's `details` callback loads + expands the chosen profile so the @@ -1816,7 +2064,8 @@ export async function run(args: string[]): Promise { // file gates this so it only runs once. Failure is non-fatal; the picker // still opens after. try { - const { onboardedMarkerPath, runGlobalOnboarding } = await import("./init"); + const { onboardedMarkerPath, runGlobalOnboarding } = + await import("./init"); const { writeFileSync, mkdirSync, existsSync } = await import("node:fs"); const marker = onboardedMarkerPath(); if (!existsSync(marker)) { @@ -1826,11 +2075,15 @@ export async function run(args: string[]): Promise { try { mkdirSync(configDir(), { recursive: true }); writeFileSync(marker, new Date().toISOString() + "\n"); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } process.stdout.write("\n"); } } - } catch { /* never block launch on onboarding failure */ } + } catch { + /* never block launch on onboarding failure */ + } const optionSet = await listProfileOptions(existingProfile, parsed.agent); const options = optionSet.options; @@ -1842,7 +2095,8 @@ export async function run(args: string[]): Promise { // reused by the cross-profile frequency suggestions below. let affinity: Map = new Map(); try { - const { computeAffinityMap, suggestionsByProfile } = await import("../lib/pair-suggestions"); + const { computeAffinityMap, suggestionsByProfile } = + await import("../lib/pair-suggestions"); affinity = computeAffinityMap(); // Partners are scoped to this repository: `growStack` grafts one onto // every suggested stack with no reason line of its own, so a pairing @@ -1854,47 +2108,85 @@ export async function run(args: string[]): Promise { // unchecked + hinted ("you paired these before"), so a low bar is a gentle // recommendation, not an auto-pin. (The stricter defaults still apply to // `cue suggest-pairs`, which reports rather than pre-fills.) - const sug = suggestionsByProfile(localAffinity, { minCount: 1, minAffinity: 0, limit: 6 }); + const sug = suggestionsByProfile(localAffinity, { + minCount: 1, + minAffinity: 0, + limit: 6, + }); pairSuggestions = new Map(); for (const [name, partners] of sug) { - pairSuggestions.set(name, partners.map((p) => p.name)); + pairSuggestions.set( + name, + partners.map((p) => p.name), + ); } - } catch (err) { debug("launch:pair-suggestions", err); } + } catch (err) { + debug("launch:pair-suggestions", err); + } // Installed profile names, computed ONCE and shared by the autodetect + // companion passes below (both filter their detections to known profiles). // Previously each pass re-walked listProfiles() independently. let knownProfileNames = new Set(); try { knownProfileNames = new Set(await listProfiles()); - } catch (err) { debug("launch:list-profiles", err); } + } catch (err) { + debug("launch:list-profiles", err); + } // Cwd autodetect signals, forwarded to runPicker so it can offer a // "switch to ?" nudge when the user picks a profile that conflicts // with what the directory actually looks like (e.g. picking medusa-next // in a vite.config.ts project). - let detected: ReadonlyArray<{ name: string; reasons: string[]; confidence: number }> = []; + let detected: ReadonlyArray<{ + name: string; + reasons: string[]; + confidence: number; + }> = []; let rawDetections: DetectionResultV2[] = []; try { - rawDetections = detectProfileV2(cwd).filter((d) => knownProfileNames.has(d.profile)); - detected = rawDetections.map((d) => ({ name: d.profile, reasons: d.reasons, confidence: d.confidence })); - } catch (err) { debug("launch:autodetect", err); } + rawDetections = detectProfileV2(cwd).filter((d) => + knownProfileNames.has(d.profile), + ); + detected = rawDetections.map((d) => ({ + name: d.profile, + reasons: d.reasons, + confidence: d.confidence, + })); + } catch (err) { + debug("launch:autodetect", err); + } // Content-aware combine companions: scan the cwd for asset/draft/brand // signals and feed matching profiles into the combine multiselect — plus // dep-detected service profiles (stripe, @aws-sdk/*, …), which join as // pre-checked rows (see serviceCompanions). let companions: CompanionSignal[] = []; try { - companions = detectCompanions({ cwd, knownProfiles: knownProfileNames, brands: listPostizzBrands() }); - companions = companions.concat(serviceCompanions(rawDetections, knownProfileNames)); - } catch (err) { debug("launch:companions", err); } + companions = detectCompanions({ + cwd, + knownProfiles: knownProfileNames, + brands: listPostizzBrands(), + }); + companions = companions.concat( + serviceCompanions(rawDetections, knownProfileNames), + ); + } catch (err) { + debug("launch:companions", err); + } // Cross-profile combine suggestions offered under every primary: the curated // `_featured.yaml` set (improver, secops, builder, …) plus the profiles the // user picks most often (from the affinity map above). Offered unchecked. let universalSuggestions: UniversalSuggestion[] = []; try { - const { buildUniversalSuggestions } = await import("../lib/pair-suggestions"); + const { buildUniversalSuggestions } = + await import("../lib/pair-suggestions"); const featured = await listFeaturedProfiles(); - universalSuggestions = buildUniversalSuggestions({ featured, affinity, known: knownProfileNames }); - } catch (err) { debug("launch:universal-suggestions", err); } + universalSuggestions = buildUniversalSuggestions({ + featured, + affinity, + known: knownProfileNames, + }); + } catch (err) { + debug("launch:universal-suggestions", err); + } // Per-profile resource tally for the combine multiselect's live preview + // per-row hints. Memoized so each offered profile loads at most once. // A shared skill-token reader feeds the always-on estimate (frontmatter @@ -1902,8 +2194,11 @@ export async function run(args: string[]): Promise { // banner below, so the picker's heads-up and the banner agree. const { readFileSync: readSkillFile } = await import("node:fs"); const skillsRootForTally = join( - process.env.CUE_REPO_ROOT ?? resolve(new URL(import.meta.url).pathname, "..", "..", ".."), - "resources", "skills", "skills", + process.env.CUE_REPO_ROOT ?? + resolve(new URL(import.meta.url).pathname, "..", "..", ".."), + "resources", + "skills", + "skills", ); const skillTokenCache = new Map(); const tokensForSkill = (id: string): SkillTokens => { @@ -1911,9 +2206,16 @@ export async function run(args: string[]): Promise { if (c) return c; let result: SkillTokens = { frontmatter: 0, body: 0 }; try { - const { frontmatter, body } = splitSkillBytes(readSkillFile(join(skillsRootForTally, id, "SKILL.md"), "utf8")); - result = { frontmatter: Math.ceil(frontmatter / 4), body: Math.ceil(body / 4) }; - } catch { /* skill missing on disk → counts as 0 */ } + const { frontmatter, body } = splitSkillBytes( + readSkillFile(join(skillsRootForTally, id, "SKILL.md"), "utf8"), + ); + result = { + frontmatter: Math.ceil(frontmatter / 4), + body: Math.ceil(body / 4), + }; + } catch { + /* skill missing on disk → counts as 0 */ + } skillTokenCache.set(id, result); return result; }; @@ -1935,7 +2237,8 @@ export async function run(args: string[]): Promise { commands: (prof.commands ?? []).slice(), // This profile's own always-on frontmatter cost (parts=undefined → just // its own skills). The picker sums these across the selection. - alwaysOn: computeTokenBreakdown(prof, undefined, tokensForSkill).alwaysOn, + alwaysOn: computeTokenBreakdown(prof, undefined, tokensForSkill) + .alwaysOn, }; tallyCache.set(value, tally); return tally; @@ -1948,7 +2251,9 @@ export async function run(args: string[]): Promise { // Scoped to the launch directory: stacks confirmed in this repo lead, // stacks from other repos drop to a hint. combos = readCombos(undefined, { cwd }); - } catch (err) { debug("launch:combo-history", err); } + } catch (err) { + debug("launch:combo-history", err); + } const picked = await runPicker({ cwd, options, @@ -1990,13 +2295,18 @@ export async function run(args: string[]): Promise { const lastRun = readGateStatus(profileName); if (lastRun && lastRun.overall === "fail") { const failed = lastRun.results.filter((r) => !r.ok).map((r) => r.name); - const tail = failed.length > 2 ? `${failed.slice(0, 2).join(", ")} +${failed.length - 2}` : failed.join(", "); + const tail = + failed.length > 2 + ? `${failed.slice(0, 2).join(", ")} +${failed.length - 2}` + : failed.join(", "); process.stderr.write( `\x1b[33m⚠\x1b[0m cue: last gate run for "${profileName}" failed (${tail}). ` + - `Inspect: cue gates status\n`, + `Inspect: cue gates status\n`, ); } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } // Load + materialize. Reuse the picker-cached profile when available. let profile!: ResolvedProfile; @@ -2005,7 +2315,8 @@ export async function run(args: string[]): Promise { } else { // Try manifest cache first (skips YAML parse + inheritance resolution) const profilesDir = join( - process.env.CUE_REPO_ROOT ?? resolve(new URL(import.meta.url).pathname, "..", "..", ".."), + process.env.CUE_REPO_ROOT ?? + resolve(new URL(import.meta.url).pathname, "..", "..", ".."), "profiles", ); let fromCache = false; @@ -2016,7 +2327,9 @@ export async function run(args: string[]): Promise { profile = cached; fromCache = true; } - } catch { /* cache miss — fall through */ } + } catch { + /* cache miss — fall through */ + } if (!fromCache) { try { @@ -2031,7 +2344,9 @@ export async function run(args: string[]): Promise { try { const { putCachedManifest } = await import("../lib/manifest-cache"); putCachedManifest(profile, profilesDir); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } } @@ -2043,11 +2358,11 @@ export async function run(args: string[]): Promise { // // Resolved BEFORE the credentials source below, which needs to know the dir // this launch will write in order to refuse it as its own overlay source. - const accountTag = agentKind === "claude-code" - ? authmuxAccountTag(ccd, homedir()) - : undefined; + const accountTag = + agentKind === "claude-code" ? authmuxAccountTag(ccd, homedir()) : undefined; const runtimeKey = accountTag ? `${profileName}@${accountTag}` : profileName; - if (accountTag) debug("launch:account-runtime", { profileName, accountTag, runtimeKey }); + if (accountTag) + debug("launch:account-runtime", { profileName, accountTag, runtimeKey }); // Credentials source resolution (Claude only): // 1. Honor explicit CLAUDE_CONFIG_DIR (set by claude-account2 alias, etc.) @@ -2061,11 +2376,12 @@ export async function run(args: string[]): Promise { // cue profile. authmux's `parallel --list --json` returns each profile's // configDir; we pick the one whose .credentials.json was touched most // recently as a proxy for "the one you actually use." - const credentialsSource = agentKind === "claude-code" - ? await resolveClaudeCredentialsSource({ - runtimeDir: runtimeDirFor(runtimeKey, "claude-code"), - }) - : undefined; + const credentialsSource = + agentKind === "claude-code" + ? await resolveClaudeCredentialsSource({ + runtimeDir: runtimeDirFor(runtimeKey, "claude-code"), + }) + : undefined; // Pin dir: the directory holding the resolving `.cue.profile`, else cwd // (a freshly-picked profile was just pinned to cwd). Keys both the project @@ -2099,8 +2415,11 @@ export async function run(args: string[]): Promise { // fuzzy-resolved ids just yield empty metadata — classification then // falls back to id-token matching, and the index row omits the path. const skillsRoot = join( - process.env.CUE_REPO_ROOT ?? resolve(new URL(import.meta.url).pathname, "..", "..", ".."), - "resources", "skills", "skills", + process.env.CUE_REPO_ROOT ?? + resolve(new URL(import.meta.url).pathname, "..", "..", ".."), + "resources", + "skills", + "skills", ); const result = await applyProjectLoadout({ profile, @@ -2110,7 +2429,10 @@ export async function run(args: string[]): Promise { const path = join(skillsRoot, id, "SKILL.md"); const content = await readSkillFile(path, "utf8").catch(() => ""); if (!content) return { description: "", path: "" }; - return { description: parseMetadataFromContent(content).description, path }; + return { + description: parseMetadataFromContent(content).description, + path, + }; }, }); if (result) { @@ -2119,8 +2441,8 @@ export async function run(args: string[]): Promise { const top = result.signals.slice(0, 4).join(", "); process.stderr.write( `[cue] loadout: ${result.full.length} skills full · ${result.deferred.length} deferred` + - (top ? ` (${top}${result.signals.length > 4 ? ", …" : ""})` : "") + - ` · --cue-full loads all · cue loadout to edit\n`, + (top ? ` (${top}${result.signals.length > 4 ? ", …" : ""})` : "") + + ` · --cue-full loads all · cue loadout to edit\n`, ); } } catch (err) { @@ -2135,8 +2457,18 @@ export async function run(args: string[]): Promise { // --rematerialize: force rebuild by deleting the hash file first if (parsed.rematerialize) { const { rm: rmFile } = await import("node:fs/promises"); - const hashPath = join(configDir(), "runtime", runtimeKey, agentKind === "claude-code" ? "claude" : "codex", ".cue-hash"); - try { await rmFile(hashPath, { force: true }); } catch { /* ok */ } + const hashPath = join( + configDir(), + "runtime", + runtimeKey, + agentKind === "claude-code" ? "claude" : "codex", + ".cue-hash", + ); + try { + await rmFile(hashPath, { force: true }); + } catch { + /* ok */ + } } else { // Auto-rematerialize on staleness: if profile.yaml is newer than the stored // hash (doctor's D5 predicate), the user edited the profile after the last @@ -2146,13 +2478,32 @@ export async function run(args: string[]): Promise { // rebuild writes a fresh .cue-hash with a current mtime, so it won't loop. try { const { isRuntimeStale } = await import("../lib/runtime-materializer"); - if (await isRuntimeStale(profileName, agentKind, join(configDir(), "runtime"), runtimeKey)) { + if ( + await isRuntimeStale( + profileName, + agentKind, + join(configDir(), "runtime"), + runtimeKey, + ) + ) { const { rm: rmFile } = await import("node:fs/promises"); - const hashPath = join(configDir(), "runtime", runtimeKey, agentKind === "claude-code" ? "claude" : "codex", ".cue-hash"); - try { await rmFile(hashPath, { force: true }); } catch { /* ok */ } + const hashPath = join( + configDir(), + "runtime", + runtimeKey, + agentKind === "claude-code" ? "claude" : "codex", + ".cue-hash", + ); + try { + await rmFile(hashPath, { force: true }); + } catch { + /* ok */ + } process.stderr.write(`[cue] profile changed, rebuilding runtime...\n`); } - } catch (err) { debug("launch:staleness", err); /* fail-open — never blocks launch */ } + } catch (err) { + debug("launch:staleness", err); /* fail-open — never blocks launch */ + } } // Lazy-MCP: ids the user disabled, forwarded to the materializer so the stale @@ -2172,15 +2523,30 @@ export async function run(args: string[]): Promise { if (agentKind === "claude-code" && profile.mcps.length > 0) { try { const { getNeededMcps } = await import("../lib/skill-dependencies"); - const { readMcpOverride, writeMcpOverride, mcpFingerprint, reconcileDisabledWithNeeded, autoPrunableMcps, mcpPruneMode, isRecognizedPruneEnv, readRuntimeMcpServerIds } = await import("../lib/mcp-overrides"); + const { + readMcpOverride, + writeMcpOverride, + mcpFingerprint, + reconcileDisabledWithNeeded, + autoPrunableMcps, + mcpPruneMode, + isRecognizedPruneEnv, + readRuntimeMcpServerIds, + } = await import("../lib/mcp-overrides"); const allMcpIds = profile.mcps.map((m) => m.id); const fingerprint = mcpFingerprint(allMcpIds); - const pinned = new Set(profile.mcps.filter((m) => m.pin).map((m) => m.id.toLowerCase())); + const pinned = new Set( + profile.mcps.filter((m) => m.pin).map((m) => m.id.toLowerCase()), + ); const needed = getNeededMcps(profile.skills.local.map((s) => s.id)); const keepNonPinned = (drop: Set): Set => - new Set(allMcpIds.filter((id) => pinned.has(id.toLowerCase()) || !drop.has(id.toLowerCase()))); + new Set( + allMcpIds.filter( + (id) => pinned.has(id.toLowerCase()) || !drop.has(id.toLowerCase()), + ), + ); let kept: Set | null = null; let reviewed = false; @@ -2199,7 +2565,9 @@ export async function run(args: string[]): Promise { `[cue] CUE_PRUNE_MCPS="${pruneEnv}" not recognized (use off|profile|all) — using the profile default\n`, ); } - const pruneMode = pruneFromEnv ? mcpPruneMode(pruneEnv) : (profile.mcpPrune ?? "off"); + const pruneMode = pruneFromEnv + ? mcpPruneMode(pruneEnv) + : (profile.mcpPrune ?? "off"); const pruneSource = pruneFromEnv ? "CUE_PRUNE_MCPS" : "profile mcpPrune"; if (parsed.disableMcp.length > 0) { @@ -2208,10 +2576,13 @@ export async function run(args: string[]): Promise { // as a remembered per-dir override — a one-shot `--disable-mcp` in a CI // run or a debug session must not silently stick on later launches. Use // the interactive picker (or `--cue-pick-mcps`) to persist a choice. - kept = keepNonPinned(new Set(parsed.disableMcp.map((s) => s.toLowerCase()))); + kept = keepNonPinned( + new Set(parsed.disableMcp.map((s) => s.toLowerCase())), + ); } else { const override = readMcpOverride(pinDir); - const overrideValid = override !== undefined && override.fingerprint === fingerprint; + const overrideValid = + override !== undefined && override.fingerprint === fingerprint; const interactive = process.stdin.isTTY === true && !parsed.dryRun; // Replay a remembered disable-list, cross-checked against what active @@ -2220,7 +2591,10 @@ export async function run(args: string[]): Promise { // and say so, rather than silently starving the skill. The override is // keyed only by the MCP id set (fingerprint), so it won't re-prompt. const applyRememberedOverride = (): Set => { - const { keepDisabled, reEnabled } = reconcileDisabledWithNeeded(override!.disabled, needed.keys()); + const { keepDisabled, reEnabled } = reconcileDisabledWithNeeded( + override!.disabled, + needed.keys(), + ); if (reEnabled.length > 0) { process.stderr.write( `[cue] MCPs: re-enabled ${reEnabled.length} now needed by active skills (${reEnabled.join(", ")}) · --cue-pick-mcps to change\n`, @@ -2236,7 +2610,13 @@ export async function run(args: string[]): Promise { // choice is honored, so picking `core` from the startup menu no longer // forces an MCP dialog every time. Re-review explicitly with // --cue-pick-mcps. A valid override seeds the checkboxes when it does open. - if (shouldOpenMcpPicker({ interactive, forcePickMcps: parsed.forcePickMcps, overrideValid })) { + if ( + shouldOpenMcpPicker({ + interactive, + forcePickMcps: parsed.forcePickMcps, + overrideValid, + }) + ) { const { pickMcps } = await import("../lib/mcp-picker"); // Initial checkbox state, best first: the remembered override (this // is a re-review), else — when a loadout is active — the project- @@ -2274,15 +2654,34 @@ export async function run(args: string[]): Promise { // profile never calls). Removes config set globally. const universe = [...allMcpIds]; if (pruneMode === "all") { - const rtClaudeJson = join(configDir(), "runtime", runtimeKey, "claude", ".claude.json"); + const rtClaudeJson = join( + configDir(), + "runtime", + runtimeKey, + "claude", + ".claude.json", + ); for (const id of readRuntimeMcpServerIds(rtClaudeJson)) { - if (!universe.some((p) => p.toLowerCase() === id.toLowerCase())) universe.push(id); + if (!universe.some((p) => p.toLowerCase() === id.toLowerCase())) + universe.push(id); } } - const drop = new Set(autoPrunableMcps(universe, pinned, needed.keys())); - debug("launch:mcp-prune", { mode: pruneMode, source: pruneSource, universe, pinned: [...pinned], needed: [...needed.keys()], drop: [...drop] }); + const drop = new Set( + autoPrunableMcps(universe, pinned, needed.keys()), + ); + debug("launch:mcp-prune", { + mode: pruneMode, + source: pruneSource, + universe, + pinned: [...pinned], + needed: [...needed.keys()], + drop: [...drop], + }); if (drop.size > 0) { - profile = { ...profile, mcps: profile.mcps.filter((m) => !drop.has(m.id.toLowerCase())) }; + profile = { + ...profile, + mcps: profile.mcps.filter((m) => !drop.has(m.id.toLowerCase())), + }; mcpDisabledIds = [...drop]; process.stderr.write( `[cue] MCPs: auto-pruned ${drop.size} unused (${[...drop].join(", ")}) · ${pruneSource}=${pruneMode} · --cue-pick-mcps to keep\n`, @@ -2293,13 +2692,22 @@ export async function run(args: string[]): Promise { } debug("launch:mcp-prune", { - all: allMcpIds, pinned: [...pinned], needed: [...needed.keys()], reviewed, kept: kept ? [...kept] : null, + all: allMcpIds, + pinned: [...pinned], + needed: [...needed.keys()], + reviewed, + kept: kept ? [...kept] : null, }); if (kept !== null) { const keptSet = kept; - const disabled = allMcpIds.filter((id) => !keptSet.has(id)).map((id) => id.toLowerCase()); + const disabled = allMcpIds + .filter((id) => !keptSet.has(id)) + .map((id) => id.toLowerCase()); if (disabled.length > 0) { - profile = { ...profile, mcps: profile.mcps.filter((m) => keptSet.has(m.id)) }; + profile = { + ...profile, + mcps: profile.mcps.filter((m) => keptSet.has(m.id)), + }; mcpDisabledIds = disabled; process.stderr.write( `[cue] MCPs: ${keptSet.size} on · ${disabled.length} disabled (${disabled.join(", ")}) · --cue-pick-mcps to change\n`, @@ -2309,7 +2717,12 @@ export async function run(args: string[]): Promise { // fresh, and clears a stale override when they re-enable everything). // `disabled` may be [] here — that's intentional: it records "reviewed, // nothing disabled" so a later launch doesn't re-prompt. - if (reviewed) writeMcpOverride(pinDir, { profile: profileName, fingerprint, disabled }); + if (reviewed) + writeMcpOverride(pinDir, { + profile: profileName, + fingerprint, + disabled, + }); } } catch (err) { debug("launch:mcp-prune", err); // fail-open — never blocks a launch @@ -2330,10 +2743,13 @@ export async function run(args: string[]): Promise { parsed.dryRun || parsed.rematerialize ? null : startLoader({ - logoPath: agentKind === "claude-code" ? ensureClaudeLogoPath() ?? undefined : undefined, - message: agentLaunchMessage(agentKind), - accentColor: agentLaunchAccent(agentKind), - }); + logoPath: + agentKind === "claude-code" + ? (ensureClaudeLogoPath() ?? undefined) + : undefined, + message: agentLaunchMessage(agentKind), + accentColor: agentLaunchAccent(agentKind), + }); const progress = (active: string, fallback: string): void => { if (loader) loader.setMessage(active); else if (fallback) process.stderr.write(fallback); @@ -2362,27 +2778,52 @@ export async function run(args: string[]): Promise { // Explicit --subset bypasses the keep-set cache (the user is overriding // deliberately); an env-folded `-p` prompt uses the cache so repeat // launches don't re-call the classifier. - const result = await selectRelevantSkills(ids, subsetPrompt, { noCache: parsed.subsetExplicit }); - progress(`Skills: ${result.reason}`, ` 🎯 smart-subset: ${result.reason}\n`); + const result = await selectRelevantSkills(ids, subsetPrompt, { + noCache: parsed.subsetExplicit, + }); + progress( + `Skills: ${result.reason}`, + ` 🎯 smart-subset: ${result.reason}\n`, + ); if (result.classified && result.selected.length < ids.length) { const keep = new Set(result.selected); // Copy-on-write: never mutate the (possibly manifest-cached) profile // object in place — a shared reference would poison sibling reads. - profile = { ...profile, skills: { ...profile.skills, local: profile.skills.local.filter((s) => keep.has(s.id)) } }; + profile = { + ...profile, + skills: { + ...profile.skills, + local: profile.skills.local.filter((s) => keep.has(s.id)), + }, + }; // Force a rebuild so the smaller skill set actually lands on disk. const { rm: rmFile } = await import("node:fs/promises"); - const hashPath = join(configDir(), "runtime", runtimeKey, agentKind === "claude-code" ? "claude" : "codex", ".cue-hash"); - try { await rmFile(hashPath, { force: true }); } catch { /* ok */ } + const hashPath = join( + configDir(), + "runtime", + runtimeKey, + agentKind === "claude-code" ? "claude" : "codex", + ".cue-hash", + ); + try { + await rmFile(hashPath, { force: true }); + } catch { + /* ok */ + } } } catch (err) { - progress("Loading skills…", ` ⚠️ smart-subset failed (${(err as Error).message}) — kept full skill set\n`); + progress( + "Loading skills…", + ` ⚠️ smart-subset failed (${(err as Error).message}) — kept full skill set\n`, + ); } } // Rescue-before-wipe: if this runtime's credentials belong to a different // account than credentialsSource, the materializer's identity guard is // about to discard them — return them to their owning account dir first. - if (agentKind === "claude-code") await rescueRuntimeCredsToOwner(runtimeKey); + if (agentKind === "claude-code") + await rescueRuntimeCredsToOwner(runtimeKey); // Promote npx skills to local via installed plugin marketplaces. // The materializer only symlinks profile.skills.local; npx refs are @@ -2392,13 +2833,20 @@ export async function run(args: string[]): Promise { // lists skills.npx works without a network fetch on every launch. const npxSkillMap = new Map(); // skill id → dir path if (profile.skills.npx.length > 0) { - const pluginMarketplacesDir = join(homedir(), ".claude", "plugins", "marketplaces"); + const pluginMarketplacesDir = join( + homedir(), + ".claude", + "plugins", + "marketplaces", + ); if (existsSync(pluginMarketplacesDir)) { - const mpSkillsDirs = readdirSync(pluginMarketplacesDir, { withFileTypes: true }) + const mpSkillsDirs = readdirSync(pluginMarketplacesDir, { + withFileTypes: true, + }) .filter((d) => d.isDirectory()) .map((d) => join(pluginMarketplacesDir, d.name, "skills")); for (const npxRef of profile.skills.npx) { - for (const skillName of (npxRef.skills ?? [])) { + for (const skillName of npxRef.skills ?? []) { if (npxSkillMap.has(skillName)) continue; for (const skillsDir of mpSkillsDirs) { const skillDir = join(skillsDir, skillName); @@ -2416,7 +2864,13 @@ export async function run(args: string[]): Promise { .filter((id) => !existingIds.has(id)) .map((id) => ({ id })); if (newSkills.length > 0) { - profile = { ...profile, skills: { ...profile.skills, local: [...profile.skills.local, ...newSkills] } }; + profile = { + ...profile, + skills: { + ...profile.skills, + local: [...profile.skills.local, ...newSkills], + }, + }; } } } @@ -2462,7 +2916,12 @@ export async function run(args: string[]): Promise { if (runtime.rebuilt) { try { const { existsSync, writeFileSync } = await import("node:fs"); - const doctorFlag = join(configDir(), "runtime", runtimeKey, ".doctor-done"); + const doctorFlag = join( + configDir(), + "runtime", + runtimeKey, + ".doctor-done", + ); if (!existsSync(doctorFlag)) { const lines = formatDoctorWarnings(warnings); if (lines.length > 0) { @@ -2472,9 +2931,13 @@ export async function run(args: string[]): Promise { } writeFileSync(doctorFlag, new Date().toISOString()); } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } // W6/W7 description-lint surface — runs on rebuild only, so skill-writer // sees weak triggers/capability at the moment the profile materializes, @@ -2501,21 +2964,29 @@ export async function run(args: string[]): Promise { `${c.yellow(`⚠ ${n} skill description issue${n > 1 ? "s" : ""}`)} ${c.dim(`→ cue validate ${profileName}`)}\n`, ); } - } catch { /* non-fatal — lint is observability, not a gate */ } + } catch { + /* non-fatal — lint is observability, not a gate */ + } } // --rematerialize: report and exit (no exec) if (parsed.rematerialize) { process.stdout.write( - JSON.stringify({ - profile: profileName, - agent: agentKind, - runtimeDir: runtime.runtimeDir, - rebuilt: runtime.rebuilt, - hash: runtime.hash, - }, null, 2) + "\n", + JSON.stringify( + { + profile: profileName, + agent: agentKind, + runtimeDir: runtime.runtimeDir, + rebuilt: runtime.rebuilt, + hash: runtime.hash, + }, + null, + 2, + ) + "\n", + ); + process.stdout.write( + runtime.rebuilt ? "✅ Rematerialized.\n" : "ℹ️ Already up to date.\n", ); - process.stdout.write(runtime.rebuilt ? "✅ Rematerialized.\n" : "ℹ️ Already up to date.\n"); return 0; } @@ -2526,14 +2997,24 @@ export async function run(args: string[]): Promise { try { const { isRulerAutoEnabled, runAutoRuler } = await import("../lib/ruler"); if (isRulerAutoEnabled(process.env.CUE_RULER_AUTO)) { - const actions = runAutoRuler({ profile, targetDir: cwd, dryRun: parsed.dryRun }); + const actions = runAutoRuler({ + profile, + targetDir: cwd, + dryRun: parsed.dryRun, + }); // dry-run yields "dry-write"; a real run yields "write" (mutually exclusive). - const wrote = actions.filter((a) => a.kind === "write" || a.kind === "dry-write").length; - const skipped = actions.filter((a) => a.kind === "skip-foreign-safe").length; + const wrote = actions.filter( + (a) => a.kind === "write" || a.kind === "dry-write", + ).length; + const skipped = actions.filter( + (a) => a.kind === "skip-foreign-safe", + ).length; if (wrote || skipped) { process.stderr.write( `[cue] ruler: ${parsed.dryRun ? "would sync" : "synced"} rules → ${wrote} agent file(s)` + - (skipped ? `; left ${skipped} hand-written file(s) untouched` : "") + + (skipped + ? `; left ${skipped} hand-written file(s) untouched` + : "") + "\n", ); } @@ -2542,7 +3023,8 @@ export async function run(args: string[]): Promise { debug("launch:auto-ruler", err); /* fail-open — never blocks launch */ } - const envKey = agentKind === "claude-code" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const envKey = + agentKind === "claude-code" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; const childEnv: NodeJS.ProcessEnv = { ...process.env, [envKey]: runtime.runtimeDir, @@ -2566,9 +3048,13 @@ export async function run(args: string[]): Promise { // slot; making memory per-account is a separate follow-up. try { const { resolveClaudeMemEnv } = await import("../lib/claude-mem-env"); - const memEnv = resolveClaudeMemEnv(profileName, { existingEnv: process.env }); + const memEnv = resolveClaudeMemEnv(profileName, { + existingEnv: process.env, + }); if (memEnv) Object.assign(childEnv, memEnv); - } catch { /* non-fatal — memory isolation is an enhancement, not a gate */ } + } catch { + /* non-fatal — memory isolation is an enhancement, not a gate */ + } if (parsed.dryRun) { process.stdout.write( @@ -2609,18 +3095,21 @@ export async function run(args: string[]): Promise { // Skill → MCP dependency check (non-fatal) try { - const { detectMissingDependencies } = await import("../lib/skill-dependencies"); + const { detectMissingDependencies } = + await import("../lib/skill-dependencies"); const skillIds = profile.skills.local.map((s: any) => s.id); const mcpIds = profile.mcps.map((m: any) => m.id); const missing = detectMissingDependencies(profileName, skillIds, mcpIds); if (missing.length > 0) { - const unique = [...new Set(missing.map(m => m.mcpId))]; + const unique = [...new Set(missing.map((m) => m.mcpId))]; const c = colorFns(); process.stderr.write( `${c.yellow(`⚠ missing MCP${unique.length > 1 ? "s" : ""}: ${unique.join(", ")}`)} ${c.dim(`→ cue mcps add ${unique[0]} --profile ${profileName}`)}\n`, ); } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } // Tracks the breakdown so the tmux badge below can reuse what the CLI // banner already computed. Undefined when skillCount is too small to bother @@ -2630,8 +3119,11 @@ export async function run(args: string[]): Promise { try { const { readFileSync } = await import("node:fs"); const skillsRoot = join( - process.env.CUE_REPO_ROOT ?? resolve(new URL(import.meta.url).pathname, "..", "..", ".."), - "resources", "skills", "skills", + process.env.CUE_REPO_ROOT ?? + resolve(new URL(import.meta.url).pathname, "..", "..", ".."), + "resources", + "skills", + "skills", ); const tokenCache = new Map(); const tokensForSkill = (id: string): SkillTokens => { @@ -2645,7 +3137,9 @@ export async function run(args: string[]): Promise { frontmatter: Math.ceil(frontmatter / 4), body: Math.ceil(body / 4), }; - } catch { /* skill missing on disk; counts as 0 */ } + } catch { + /* skill missing on disk; counts as 0 */ + } tokenCache.set(id, result); return result; }; @@ -2656,7 +3150,9 @@ export async function run(args: string[]): Promise { if (partNames.length > 1) { try { parts = await Promise.all(partNames.map((p) => loadProfile(p))); - } catch { /* breakdown unavailable, total still shown */ } + } catch { + /* breakdown unavailable, total still shown */ + } } const breakdown = computeTokenBreakdown(profile, parts, tokensForSkill); @@ -2675,7 +3171,11 @@ export async function run(args: string[]): Promise { }); const bc = colorFns(); lines.push( - ...formatContextBudgetWarning(budget, { yellow: bc.yellow, bold: bc.bold, dim: bc.dim }), + ...formatContextBudgetWarning(budget, { + yellow: bc.yellow, + bold: bc.bold, + dim: bc.dim, + }), ); if (lines.length > 0) { @@ -2683,26 +3183,47 @@ export async function run(args: string[]): Promise { for (const l of lines) process.stderr.write(`${l}\n`); process.stderr.write("\n"); } - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } } // First-run: prompt to star the repo (once ever, non-blocking) try { const { maybePromptStar } = await import("../lib/star-prompt"); await maybePromptStar(); - } catch { /* non-fatal */ } + } catch { + /* non-fatal */ + } // Analytics: record session start try { const { recordEvent } = await import("../lib/analytics"); const startTs = new Date().toISOString(); - recordEvent({ ts: startTs, event: "start", profile: profileName, agent: agentKind, cwd: process.cwd() }); + recordEvent({ + ts: startTs, + event: "start", + profile: profileName, + agent: agentKind, + cwd: process.cwd(), + }); // Record end on exit process.on("exit", () => { try { - const duration_s = Math.round((Date.now() - new Date(startTs).getTime()) / 1000); - recordEvent({ ts: new Date().toISOString(), event: "end", profile: profileName, agent: agentKind, cwd: process.cwd(), duration_s }); - } catch { /* best-effort */ } + const duration_s = Math.round( + (Date.now() - new Date(startTs).getTime()) / 1000, + ); + recordEvent({ + ts: new Date().toISOString(), + event: "end", + profile: profileName, + agent: agentKind, + cwd: process.cwd(), + duration_s, + }); + } catch { + /* best-effort */ + } // Sync refreshed credentials back to source so next launch has valid tokens. // Freshness guard (mirrors the materializer's preserve step at // runtime-materializer.ts:704): write back ONLY when the runtime token is @@ -2713,22 +3234,35 @@ export async function run(args: string[]): Promise { // Must stay synchronous: process.on("exit") handlers cannot await. if (credentialsSource) { try { - const { copyFileSync, readFileSync: rf, existsSync: ex } = require("node:fs"); + const { + copyFileSync, + readFileSync: rf, + existsSync: ex, + } = require("node:fs"); const runtimeCreds = join(runtime.runtimeDir, ".credentials.json"); const sourceCreds = join(credentialsSource, ".credentials.json"); const expiresAt = (p: string): number => { try { const v = JSON.parse(rf(p, "utf8"))?.claudeAiOauth?.expiresAt; return typeof v === "number" ? v : 0; - } catch { return 0; } + } catch { + return 0; + } }; - if (ex(runtimeCreds) && expiresAt(runtimeCreds) > expiresAt(sourceCreds)) { + if ( + ex(runtimeCreds) && + expiresAt(runtimeCreds) > expiresAt(sourceCreds) + ) { copyFileSync(runtimeCreds, sourceCreds); } - } catch { /* best-effort */ } + } catch { + /* best-effort */ + } } }); - } catch { /* analytics non-fatal */ } + } catch { + /* analytics non-fatal */ + } // Resolve one icon per profile part for the tmux status line. Single-part // profiles use `profile.icon` directly; composites load each part so every @@ -2751,7 +3285,9 @@ export async function run(args: string[]): Promise { }), ); } - } catch { /* best-effort */ } + } catch { + /* best-effort */ + } // One-line startup identity banner (stderr). Always prints on a real launch // so you can see what you landed in — agent, profile (collapsed to `primary @@ -2769,12 +3305,13 @@ export async function run(args: string[]): Promise { ); } - const overhead = alwaysOnForBadge !== undefined && alwaysOnForBadge >= 2000 - ? { - dot: tokenLevelEmoji(alwaysOnForBadge), - size: `${Math.round(alwaysOnForBadge / 1000)}K`, - } - : undefined; + const overhead = + alwaysOnForBadge !== undefined && alwaysOnForBadge >= 2000 + ? { + dot: tokenLevelEmoji(alwaysOnForBadge), + size: `${Math.round(alwaysOnForBadge / 1000)}K`, + } + : undefined; announceTmuxProfile(profileName, agentKind, profileIcons, childEnv, { overhead, @@ -2790,7 +3327,8 @@ export async function run(args: string[]): Promise { let briefArgs: string[] = []; if (process.env.CUE_BRIEF !== "0") { try { - const { scanBrief, renderBrief, buildBriefInjection } = await import("../lib/project-brief"); + const { scanBrief, renderBrief, buildBriefInjection } = + await import("../lib/project-brief"); const scanned = scanBrief(cwd); const rendered = scanned ? renderBrief(scanned) : ""; if (rendered) { @@ -2808,14 +3346,18 @@ export async function run(args: string[]): Promise { Object.assign(childEnv, injection.env); briefArgs = injection.args; } - } catch (err) { debug("launch:brief", err); } + } catch (err) { + debug("launch:brief", err); + } } // Keep tokens in step with sibling sessions *while* this one runs — a // rotation elsewhere would otherwise revoke ours and force a mid-session // re-login. const stopReconciler = - agentKind === "claude-code" ? startCredentialReconciler(runtimeKey) : undefined; + agentKind === "claude-code" + ? startCredentialReconciler(runtimeKey) + : undefined; const canonicalCodexAuth = join(homedir(), ".codex", "auth.json"); const runtimeCodexAuth = join(runtime.runtimeDir, "auth.json"); if (agentKind === "codex") { @@ -2839,6 +3381,10 @@ export async function run(args: string[]): Promise { } // Post-session runtime GC: the child has exited, so this costs zero launch // latency. Throttled (~once/day) and never touches the runtime we just used. - try { await maybeAutoGc(runtimeKey); } catch { /* GC is best-effort */ } + try { + await maybeAutoGc(runtimeKey); + } catch { + /* GC is best-effort */ + } return exitCode; } diff --git a/src/commands/shell.test.ts b/src/commands/shell.test.ts index 72e5a7ca..c0938182 100644 --- a/src/commands/shell.test.ts +++ b/src/commands/shell.test.ts @@ -1,9 +1,23 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtemp, readFile, writeFile, rm, mkdir, stat, chmod } from "node:fs/promises"; +import { + mkdtemp, + readFile, + writeFile, + rm, + mkdir, + stat, + chmod, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runInstall, runUninstall, shimInstalled, resolveCueInvocation, resolveHookShell } from "./shell"; +import { + runInstall, + runUninstall, + shimInstalled, + resolveCueInvocation, + resolveHookShell, +} from "./shell"; import { shimDir, fishDropInPath } from "../lib/shim-dir"; let fakeHome: string; @@ -12,7 +26,9 @@ let err: string; /** Silence install output and capture it for assertions. */ const sinks = () => ({ out: (_s: string) => {}, - err: (s: string) => { err += s; }, + err: (s: string) => { + err += s; + }, }); const localBin = (agent: string) => join(fakeHome, ".local", "bin", agent); @@ -26,7 +42,9 @@ beforeEach(async () => { await mkdir(join(fakeHome, ".local", "bin"), { recursive: true }); err = ""; }); -afterEach(async () => { await rm(fakeHome, { recursive: true, force: true }); }); +afterEach(async () => { + await rm(fakeHome, { recursive: true, force: true }); +}); describe("shell install", () => { test("writes claude and codex shims into cue's own shim dir", async () => { @@ -151,14 +169,15 @@ describe("shell install", () => { }); describe("shell install — PATH configuration", () => { - const installFish = () => runInstall({ - homeDir: fakeHome, - pathDirs: ["/usr/bin"], - realClaude: "/usr/bin/claude", - realCodex: null, - shell: "fish", - ...sinks(), - }); + const installFish = () => + runInstall({ + homeDir: fakeHome, + pathDirs: ["/usr/bin"], + realClaude: "/usr/bin/claude", + realCodex: null, + shell: "fish", + ...sinks(), + }); test("creates the fish drop-in without asking", async () => { await installFish(); @@ -175,7 +194,9 @@ describe("shell install — PATH configuration", () => { test("leaves a foreign fish drop-in alone", async () => { const target = fishDropInPath(fakeHome); - await mkdir(join(fakeHome, ".config", "fish", "conf.d"), { recursive: true }); + await mkdir(join(fakeHome, ".config", "fish", "conf.d"), { + recursive: true, + }); await writeFile(target, "# hand-written, not ours\n"); await installFish(); @@ -184,15 +205,18 @@ describe("shell install — PATH configuration", () => { expect(err).toContain("leaving it alone"); }); - const installBash = (opts: { yes?: boolean; confirm?: () => Promise } = {}) => runInstall({ - homeDir: fakeHome, - pathDirs: ["/usr/bin"], - realClaude: "/usr/bin/claude", - realCodex: null, - shell: "bash", - ...opts, - ...sinks(), - }); + const installBash = ( + opts: { yes?: boolean; confirm?: () => Promise } = {}, + ) => + runInstall({ + homeDir: fakeHome, + pathDirs: ["/usr/bin"], + realClaude: "/usr/bin/claude", + realCodex: null, + shell: "bash", + ...opts, + ...sinks(), + }); test("does not touch .bashrc without confirmation", async () => { await writeFile(join(fakeHome, ".bashrc"), "# mine\n"); @@ -255,7 +279,9 @@ describe("shell uninstall", () => { test("leaves a foreign fish drop-in alone", async () => { const target = fishDropInPath(fakeHome); - await mkdir(join(fakeHome, ".config", "fish", "conf.d"), { recursive: true }); + await mkdir(join(fakeHome, ".config", "fish", "conf.d"), { + recursive: true, + }); await writeFile(target, "# hand-written, not ours\n"); await runUninstall({ homeDir: fakeHome, ...sinks() }); expect(await readFile(target, "utf8")).toBe("# hand-written, not ours\n"); @@ -275,7 +301,10 @@ describe("shimInstalled", () => { test("true for the `cue shell install` absolute-path format", async () => { await mkdir(shimDir(fakeHome), { recursive: true }); - await writeFile(shim("claude"), '#!/usr/bin/env bash\nexec "/home/u/Documents/cue/bin/cue" launch claude "$@"\n'); + await writeFile( + shim("claude"), + '#!/usr/bin/env bash\nexec "/home/u/Documents/cue/bin/cue" launch claude "$@"\n', + ); expect(shimInstalled(fakeHome)).toBe(true); }); @@ -292,7 +321,10 @@ describe("shimInstalled", () => { test("is agent-aware", async () => { await mkdir(shimDir(fakeHome), { recursive: true }); - await writeFile(shim("codex"), '#!/usr/bin/env bash\nexec cue launch codex "$@"\n'); + await writeFile( + shim("codex"), + '#!/usr/bin/env bash\nexec cue launch codex "$@"\n', + ); expect(shimInstalled(fakeHome, "codex")).toBe(true); expect(shimInstalled(fakeHome, "claude")).toBe(false); }); diff --git a/src/lib/profile-loader.ts b/src/lib/profile-loader.ts index ce7d9cb6..5a8f3780 100644 --- a/src/lib/profile-loader.ts +++ b/src/lib/profile-loader.ts @@ -106,10 +106,8 @@ function profileYamlPath(name: string): string { const fs = require("node:fs") as typeof import("node:fs"); if (fs.existsSync(main)) return main; if (!name.includes("-")) return main; - const sharedBase = process.env.XDG_CONFIG_HOME ?? join( - process.env.HOME ?? "", - ".config", - ); + const sharedBase = + process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? "", ".config"); const sharedRoot = join(sharedBase, "cue", "shared"); // Walk shared///profile.yaml looking for a name match. if (!fs.existsSync(sharedRoot)) return main; @@ -118,18 +116,26 @@ function profileYamlPath(name: string): string { try { const stats = fs.statSync(userDir); if (!stats.isDirectory()) continue; - } catch { continue; } + } catch { + continue; + } for (const repo of fs.readdirSync(userDir)) { const candidate = join(userDir, repo, "profile.yaml"); if (!fs.existsSync(candidate)) continue; // Reuse the same slugifier as shared-profiles.ts so we recognize // the installed namespaced name without depending on that module. const slug = (s: string) => - s.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + s + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); if (`${slug(user)}-${slug(repo)}` === name) return candidate; } } - } catch { /* fall through to main */ } + } catch { + /* fall through to main */ + } return main; } @@ -205,7 +211,12 @@ async function readRawProfile(name: string): Promise { // message is friendlier than Ajv's pattern mismatch. if (Array.isArray(rawRecord.plugins)) { for (const ref of rawRecord.plugins as unknown[]) { - const id = typeof ref === "string" ? ref : (typeof ref === "object" && ref !== null ? (ref as Record).id : null); + const id = + typeof ref === "string" + ? ref + : typeof ref === "object" && ref !== null + ? (ref as Record).id + : null; if (typeof id === "string" && !PLUGIN_PATTERN.test(id)) { throw new ProfileError( "INVALID_PLUGIN_REF", @@ -218,10 +229,7 @@ async function readRawProfile(name: string): Promise { const validate = await getValidator(); if (!validate(parsed)) { - throw new SchemaViolation( - name, - (validate.errors ?? []) as ErrorObject[], - ); + throw new SchemaViolation(name, (validate.errors ?? []) as ErrorObject[]); } const profile = parsed as Profile; @@ -286,7 +294,9 @@ const PRUNE_RANK: Record = { off: 0, profile: 1, all: 2 }; * Returns undefined when no part declares one, so the resolved profile keeps * `mcpPrune` unset (launcher treats unset as "off" unless env overrides). */ -function mostAggressivePrune(modes: (McpPruneMode | undefined)[]): McpPruneMode | undefined { +function mostAggressivePrune( + modes: (McpPruneMode | undefined)[], +): McpPruneMode | undefined { let best: McpPruneMode | undefined; for (const m of modes) { if (m && (best === undefined || PRUNE_RANK[m] > PRUNE_RANK[best])) best = m; @@ -366,7 +376,26 @@ function mergeEnv( } /** - * Merge Codex config blocks, child winning on collision. + * Merge two `codex:` blocks, child wins key by key. `features` merges one level + * deeper so a child flipping one flag doesn't drop the parent's other flags. + * Returns undefined when neither side declares anything, keeping the field off + * the materializer hash for the profiles that don't use it. + */ +function mergeCodexConfig( + parent: Profile["codex"], + child: Profile["codex"], +): Profile["codex"] { + if (!parent) return child ? { ...child } : undefined; + if (!child) return { ...parent }; + const merged: NonNullable = { ...parent, ...child }; + if (parent.features || child.features) { + merged.features = { ...(parent.features ?? {}), ...(child.features ?? {}) }; + } + return merged; +} + +/** + * Merge legacy `codex_config:` blocks, child winning on collision. * * Two levels deep, unlike `mergeEnv`. Codex config is tables, not flat strings: * a plain shallow merge would let a child that sets only @@ -376,18 +405,21 @@ function mergeEnv( * * Nesting stops at two levels, a table's table is replaced wholesale, which * keeps the rule easy to state and matches how flat Codex's own config is. + * + * Superseded by `codex:`; `effectiveCodexOverrides` folds whatever lands here + * into the current block before the runtime `config.toml` is built. */ -function mergeCodexConfig( +function mergeLegacyCodexConfig( parent: Record | undefined, child: Record | undefined, -): Record | undefined { - if (!parent && !child) return undefined; +): Record { const out: Record = { ...(parent ?? {}) }; for (const [key, childVal] of Object.entries(child ?? {})) { const parentVal = out[key]; - out[key] = isPlainObject(parentVal) && isPlainObject(childVal) - ? { ...parentVal, ...childVal } - : childVal; + out[key] = + isPlainObject(parentVal) && isPlainObject(childVal) + ? { ...parentVal, ...childVal } + : childVal; } return out; } @@ -453,7 +485,7 @@ async function buildInheritanceChain(name: string): Promise { // Total chain: parents (in order) + self const totalChain = [...allParents, profile]; if (totalChain.length - 1 > MAX_INHERITANCE_DEPTH + 2) { - throw new InheritanceDepthExceeded(totalChain.map(p => p.name)); + throw new InheritanceDepthExceeded(totalChain.map((p) => p.name)); } return totalChain; } @@ -519,7 +551,7 @@ function foldChain(chain: Profile[]): ResolvedProfile { child.plugins?.map(normalizePluginRef), ), env: mergeEnv(acc.env, child.env), - codexConfig: mergeCodexConfig(acc.codexConfig, child.codex_config), + codexConfig: mergeLegacyCodexConfig(acc.codexConfig, child.codex_config), rules: dedupePrimitiveArray(acc.rules, child.rules), commands: dedupePrimitiveArray(acc.commands, child.commands), hooks: dedupePrimitiveArray(acc.hooks, child.hooks), @@ -530,7 +562,10 @@ function foldChain(chain: Profile[]): ResolvedProfile { // persona_includes IS additive (concat+dedupe). Lets cross-profile // policy snippets (Integrity Protocol, voice rules) fan out via core // without forcing children to give up their own persona block. - personaIncludes: dedupePrimitiveArray(acc.personaIncludes, child.persona_includes), + personaIncludes: dedupePrimitiveArray( + acc.personaIncludes, + child.persona_includes, + ), playbooks: dedupePrimitiveArray(acc.playbooks, child.playbooks), qualityGates: dedupePrimitiveArray(acc.qualityGates, child.qualityGates), evals: dedupePrimitiveArray(acc.evals, child.evals), @@ -539,7 +574,10 @@ function foldChain(chain: Profile[]): ResolvedProfile { conflicts: dedupePrimitiveArray(acc.conflicts, child.conflicts), // bundles is a display hint, leaf-wins: a child that declares its own // list overrides the parent; a child that omits it inherits the parent's. - bundles: child.bundles && child.bundles.length > 0 ? [...child.bundles] : acc.bundles, + bundles: + child.bundles && child.bundles.length > 0 + ? [...child.bundles] + : acc.bundles, personaRouting: [...acc.personaRouting, ...(child.persona_routing ?? [])], inheritanceChain: [...acc.inheritanceChain, child.name], }; @@ -604,7 +642,10 @@ function normalizeToResolved(p: Profile, chain: string[]): ResolvedProfile { * part is trimmed and empty parts are rejected. */ export function parseProfileSelector(selector: string): string[] { - const parts = selector.split("+").map((p) => p.trim()).filter((p) => p.length > 0); + const parts = selector + .split("+") + .map((p) => p.trim()) + .filter((p) => p.length > 0); if (parts.length === 0) { throw new ProfileError( "INVALID_SELECTOR", @@ -649,9 +690,15 @@ export function isCompositeSelector(selector: string): boolean { * personas stay legible. Empty personas are skipped. * - `inheritanceChain`: each part's chain joined with `+` */ -function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProfile { +function foldComposite( + selector: string, + parts: ResolvedProfile[], +): ResolvedProfile { if (parts.length === 0) { - throw new ProfileError("EMPTY_COMPOSITE", `Composite selector "${selector}" resolved to zero profiles`); + throw new ProfileError( + "EMPTY_COMPOSITE", + `Composite selector "${selector}" resolved to zero profiles`, + ); } if (parts.length === 1) return parts[0]!; @@ -682,9 +729,10 @@ function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProf commands: [...head.commands], hooks: [...head.hooks], subagents: [...head.subagents], - persona: head.persona && head.persona.trim().length > 0 - ? `## ${head.name}\n\n${head.persona.trim()}` - : "", + persona: + head.persona && head.persona.trim().length > 0 + ? `## ${head.name}\n\n${head.persona.trim()}` + : "", playbooks: [...head.playbooks], qualityGates: [...head.qualityGates], evals: [...head.evals], @@ -700,9 +748,10 @@ function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProf for (let i = 1; i < parts.length; i++) { const next = parts[i]!; - const nextPersona = next.persona && next.persona.trim().length > 0 - ? `## ${next.name}\n\n${next.persona.trim()}` - : ""; + const nextPersona = + next.persona && next.persona.trim().length > 0 + ? `## ${next.name}\n\n${next.persona.trim()}` + : ""; acc = { name: selector, description: acc.description, @@ -714,30 +763,44 @@ function foldComposite(selector: string, parts: ResolvedProfile[]): ResolvedProf // acc); preserve it rather than recomputing per fold step. mcpPrune: acc.mcpPrune, codex: mergeCodexConfig(acc.codex, next.codex), - agents: dedupePrimitiveArray(acc.agents, next.agents) as ResolvedProfile["agents"], + agents: dedupePrimitiveArray( + acc.agents, + next.agents, + ) as ResolvedProfile["agents"], inherits: undefined, skills: { - local: mergeObjectRefs(acc.skills.local, next.skills.local), + local: mergeObjectRefs( + acc.skills.local, + next.skills.local, + ), npx: mergeNpxRefs(acc.skills.npx, next.skills.npx), }, mcps: mergeObjectRefs(acc.mcps, next.mcps), plugins: mergeObjectRefs(acc.plugins, next.plugins), env: mergeEnv(acc.env, next.env), - codexConfig: mergeCodexConfig(acc.codexConfig, next.codexConfig), + codexConfig: mergeLegacyCodexConfig(acc.codexConfig, next.codexConfig), rules: dedupePrimitiveArray(acc.rules, next.rules), commands: dedupePrimitiveArray(acc.commands, next.commands), hooks: dedupePrimitiveArray(acc.hooks, next.hooks), subagents: dedupePrimitiveArray(acc.subagents, next.subagents), - persona: [acc.persona, nextPersona].filter((s) => s.length > 0).join("\n\n"), + persona: [acc.persona, nextPersona] + .filter((s) => s.length > 0) + .join("\n\n"), playbooks: dedupePrimitiveArray(acc.playbooks, next.playbooks), qualityGates: dedupePrimitiveArray(acc.qualityGates, next.qualityGates), evals: dedupePrimitiveArray(acc.evals, next.evals), recommends: dedupePrimitiveArray(acc.recommends, next.recommends), autoSelect: dedupePrimitiveArray(acc.autoSelect, next.autoSelect), conflicts: dedupePrimitiveArray(acc.conflicts, next.conflicts), - personaIncludes: dedupePrimitiveArray(acc.personaIncludes, next.personaIncludes), + personaIncludes: dedupePrimitiveArray( + acc.personaIncludes, + next.personaIncludes, + ), personaRouting: [...acc.personaRouting, ...next.personaRouting], - inheritanceChain: [...acc.inheritanceChain, next.inheritanceChain.join("+")], + inheritanceChain: [ + ...acc.inheritanceChain, + next.inheritanceChain.join("+"), + ], }; } @@ -803,12 +866,17 @@ export async function listProfiles(): Promise { // Also surface profiles installed via `cue share install` so the picker, // `cue list`, etc. see them alongside builtins. Namespaced as // `-` to dodge collisions with the builtins above. - const sharedBase = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? "", ".config"); + const sharedBase = + process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? "", ".config"); const sharedRoot = join(sharedBase, "cue", "shared"); try { const users = await readdir(sharedRoot, { withFileTypes: true }); const slug = (s: string) => - s.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + s + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); for (const userEntry of users) { if (!userEntry.isDirectory()) continue; const userDir = join(sharedRoot, userEntry.name); @@ -821,7 +889,9 @@ export async function listProfiles(): Promise { if (!names.includes(namespaced)) names.push(namespaced); } } - } catch { /* shared dir missing — fine */ } + } catch { + /* shared dir missing — fine */ + } names.sort(); return names; @@ -840,7 +910,9 @@ export async function listFeaturedProfiles(): Promise { const parsed = parseYaml(raw) as { featured?: unknown } | null | undefined; const list = parsed?.featured; if (!Array.isArray(list)) return []; - return list.filter((s): s is string => typeof s === "string" && s.length > 0); + return list.filter( + (s): s is string => typeof s === "string" && s.length > 0, + ); } catch { return []; } diff --git a/src/lib/runtime-install.ts b/src/lib/runtime-install.ts index acc99d7a..981d99e2 100644 --- a/src/lib/runtime-install.ts +++ b/src/lib/runtime-install.ts @@ -16,13 +16,19 @@ import { canonicalCodexConfigPath } from "./codex-config"; import { configDir } from "./config-paths"; import { debug } from "./debug-log"; import { listAllSkillIds, resolveLocalSkill } from "./resolver-local"; -import { materializeRuntime, type McpServerConfig, type MaterializeOutput } from "./runtime-materializer"; +import { + materializeRuntime, + type McpServerConfig, + type MaterializeOutput, +} from "./runtime-materializer"; export type RuntimeAgent = Extract; export const RUNTIME_AGENTS: RuntimeAgent[] = ["claude-code", "codex"]; -export function isRuntimeAgent(agent: AgentKind | string): agent is RuntimeAgent { +export function isRuntimeAgent( + agent: AgentKind | string, +): agent is RuntimeAgent { return agent === "claude-code" || agent === "codex"; } @@ -30,39 +36,63 @@ export function runtimeAgentSubdir(agent: RuntimeAgent): "claude" | "codex" { return agent === "claude-code" ? "claude" : "codex"; } -export function runtimeDirFor(profileName: string, agent: RuntimeAgent, runtimeRoot = join(configDir(), "runtime")): string { +export function runtimeDirFor( + profileName: string, + agent: RuntimeAgent, + runtimeRoot = join(configDir(), "runtime"), +): string { return join(runtimeRoot, profileName, runtimeAgentSubdir(agent)); } -export function isCueManagedClaudeRuntimeDir(dir: string | undefined, runtimeRoot = join(configDir(), "runtime")): boolean { +export function isCueManagedClaudeRuntimeDir( + dir: string | undefined, + runtimeRoot = join(configDir(), "runtime"), +): boolean { if (!dir) return false; const resolved = resolve(dir); const root = resolve(runtimeRoot); return basename(resolved) === "claude" && resolved.startsWith(root + sep); } -export async function expandSkillWildcards(profile: ResolvedProfile): Promise { +export async function expandSkillWildcards( + profile: ResolvedProfile, +): Promise { if (!profile.skills.local.some((s) => s.id === "*/*")) return; const allIds = await listAllSkillIds(); const wildcard = profile.skills.local.find((s) => s.id === "*/*")!; - const existing = new Set(profile.skills.local.filter((s) => s.id !== "*/*").map((s) => s.id)); + const existing = new Set( + profile.skills.local.filter((s) => s.id !== "*/*").map((s) => s.id), + ); profile.skills.local = [ ...profile.skills.local.filter((s) => s.id !== "*/*"), - ...allIds.filter((id) => !existing.has(id)).map((id) => ({ ...wildcard, id })), + ...allIds + .filter((id) => !existing.has(id)) + .map((id) => ({ ...wildcard, id })), ]; } -export async function loadMcpRegistry(agent: RuntimeAgent): Promise> { - const root = process.env.CUE_REPO_ROOT ?? process.env.SOUL_REPO_ROOT ?? resolve(import.meta.dirname, "..", ".."); - const files = agent === "claude-code" - ? ["claude_runtime.sanitized.json", "claude.sanitized.json"] - : ["codex.sanitized.json"]; +export async function loadMcpRegistry( + agent: RuntimeAgent, +): Promise> { + const root = + process.env.CUE_REPO_ROOT ?? + process.env.SOUL_REPO_ROOT ?? + resolve(import.meta.dirname, "..", ".."); + const files = + agent === "claude-code" + ? ["claude_runtime.sanitized.json", "claude.sanitized.json"] + : ["codex.sanitized.json"]; const merged: Record = {}; for (const file of files) { try { - const text = await readFile(join(root, "resources", "mcps", "configs", file), "utf8"); - const raw = JSON.parse(text) as { servers?: Record }; + const text = await readFile( + join(root, "resources", "mcps", "configs", file), + "utf8", + ); + const raw = JSON.parse(text) as { + servers?: Record; + }; for (const [id, config] of Object.entries(raw.servers ?? {})) { if (!(id in merged)) merged[id] = config; } @@ -72,10 +102,16 @@ export async function loadMcpRegistry(agent: RuntimeAgent): Promise }; + const text = await readFile( + join(root, "resources", "mcps", "configs", master), + "utf8", + ); + const raw = JSON.parse(text) as { + servers?: Record; + }; for (const [id, config] of Object.entries(raw.servers ?? {})) { merged[id] = config; } @@ -86,10 +122,13 @@ export async function loadMcpRegistry(agent: RuntimeAgent): Promise { - const path = agent === "claude-code" - ? join(homedir(), ".claude", "CLAUDE.md") - : join(homedir(), ".codex", "AGENTS.md"); +export async function readUserAgentMemory( + agent: RuntimeAgent, +): Promise { + const path = + agent === "claude-code" + ? join(homedir(), ".claude", "CLAUDE.md") + : join(homedir(), ".codex", "AGENTS.md"); try { return await readFile(path, "utf8"); } catch { @@ -118,7 +157,10 @@ export async function readUserAgentMemory(agent: RuntimeAgent): Promise * carrying account2's credentials into the child. Rejecting it would silently * hand the nested agent account1's token. */ -export function isSelfOverlaySource(dir: string, runtimeDir: string | undefined): boolean { +export function isSelfOverlaySource( + dir: string, + runtimeDir: string | undefined, +): boolean { if (!runtimeDir) return false; return resolve(dir) === resolve(runtimeDir); } @@ -139,7 +181,8 @@ export async function pickClaudeCredentialsSource( // per-account config — unless it names the exact dir this launch is about to // rebuild. Falling through then reaches a source outside that dir. const envConfigDir = process.env.CLAUDE_CONFIG_DIR; - if (envConfigDir && !isSelfOverlaySource(envConfigDir, options.runtimeDir)) return envConfigDir; + if (envConfigDir && !isSelfOverlaySource(envConfigDir, options.runtimeDir)) + return envConfigDir; const homeClaude = join(homedir(), ".claude"); if (existsSync(join(homeClaude, ".credentials.json"))) return homeClaude; @@ -153,20 +196,28 @@ export async function pickClaudeCredentialsSource( stdio: ["ignore", "pipe", "pipe"], }); if (res.status === 0 && res.stdout) { - const parsed = JSON.parse(res.stdout) as { data?: { profiles?: Array<{ name: string; configDir: string }> } }; + const parsed = JSON.parse(res.stdout) as { + data?: { profiles?: Array<{ name: string; configDir: string }> }; + }; const profiles = parsed?.data?.profiles ?? []; const withMtime = profiles .map((p) => { const credsPath = join(p.configDir, ".credentials.json"); let mtime = 0; - try { mtime = statSync(credsPath).mtimeMs; } catch { /* missing */ } + try { + mtime = statSync(credsPath).mtimeMs; + } catch { + /* missing */ + } return { ...p, mtime }; }) .filter((p) => p.mtime > 0) .sort((a, b) => b.mtime - a.mtime); const pick = withMtime[0]; if (pick) { - process.stderr.write(`▸ cue: inheriting auth from authmux profile "${pick.name}"\n`); + process.stderr.write( + `▸ cue: inheriting auth from authmux profile "${pick.name}"\n`, + ); return pick.configDir; } } @@ -180,12 +231,17 @@ export async function pickClaudeCredentialsSource( export async function resolveClaudeCredentialsSource( options: { healFromRuntime?: boolean; runtimeDir?: string } = {}, ): Promise { - const picked = await pickClaudeCredentialsSource({ runtimeDir: options.runtimeDir }); + const picked = await pickClaudeCredentialsSource({ + runtimeDir: options.runtimeDir, + }); if (!options.healFromRuntime) return picked; try { const { syncFreshestToSource } = await import("./credentials-sync"); - const result = await syncFreshestToSource(picked, join(configDir(), "runtime")); + const result = await syncFreshestToSource( + picked, + join(configDir(), "runtime"), + ); if (result.synced) { process.stderr.write( `▸ cue: refreshed source credentials from a sibling runtime (rotated refresh-token healed)\n`, @@ -210,7 +266,9 @@ export interface PrepareRuntimeOptions { runtimeKey?: string; } -export async function prepareRuntime(options: PrepareRuntimeOptions): Promise { +export async function prepareRuntime( + options: PrepareRuntimeOptions, +): Promise { return materializeRuntime({ profile: options.profile, agent: options.agent, @@ -218,7 +276,8 @@ export async function prepareRuntime(options: PrepareRuntimeOptions): Promise resolveLocalSkill(id), mcpRegistry: await loadMcpRegistry(options.agent), - userClaudeMd: options.userMemory ?? await readUserAgentMemory(options.agent), + userClaudeMd: + options.userMemory ?? (await readUserAgentMemory(options.agent)), credentialsSource: options.credentialsSource, codexBaseConfig: canonicalCodexConfigPath(), }); diff --git a/src/lib/runtime-materializer.ts b/src/lib/runtime-materializer.ts index 11dc9113..0fa3a2a1 100644 --- a/src/lib/runtime-materializer.ts +++ b/src/lib/runtime-materializer.ts @@ -10,19 +10,51 @@ import { createHash } from "node:crypto"; import http from "node:http"; import https from "node:https"; -import { mkdir, rename, rm, symlink, writeFile, readFile, mkdtemp, readdir, lstat } from "node:fs/promises"; -import { dirname, join, resolve as resolvePath, basename, isAbsolute } from "node:path"; +import { + mkdir, + rename, + rm, + symlink, + writeFile, + readFile, + mkdtemp, + readdir, + lstat, +} from "node:fs/promises"; +import { + dirname, + join, + resolve as resolvePath, + basename, + isAbsolute, +} from "node:path"; import { fileURLToPath } from "node:url"; -import type { AgentKind, CodexProfileConfig, ResolvedProfile } from "../../profiles/_types"; +import type { + AgentKind, + CodexProfileConfig, + ResolvedProfile, +} from "../../profiles/_types"; import { buildCodexConfigToml } from "./codex-config"; import { normalizeUvxGitServers } from "./uvx-installer"; import { evaluateCondition } from "./conditional-skills"; -import { hasWorkspaces, getActiveWorkspace, computeOverrides } from "./workspaces"; -import { parseSkillFromDir, renderRouter, type ParsedSkill } from "./skill-router"; +import { + hasWorkspaces, + getActiveWorkspace, + computeOverrides, +} from "./workspaces"; +import { + parseSkillFromDir, + renderRouter, + type ParsedSkill, +} from "./skill-router"; import { CODEX_BRIEF_POINTER } from "./project-brief"; -const REPO_ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const REPO_ROOT = resolvePath( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); const RESOURCES_RULES = join(REPO_ROOT, "resources", "rules"); const RESOURCES_COMMANDS = join(REPO_ROOT, "resources", "commands"); const RESOURCES_SUBAGENTS = join(REPO_ROOT, "resources", "subagents"); @@ -129,10 +161,15 @@ export async function isRuntimeStale( // (a) Source profile.yaml newer than the hash. Its own try/catch so a missing // yaml doesn't short-circuit the skill check below. try { - if ((await lstat(join(profilesDir(), profileName, "profile.yaml"))).mtimeMs > hashMtime) { + if ( + (await lstat(join(profilesDir(), profileName, "profile.yaml"))).mtimeMs > + hashMtime + ) { return true; } - } catch { /* no source yaml — fall through to the skill check */ } + } catch { + /* no source yaml — fall through to the skill check */ + } // (b) Any resolved SKILL.md newer than the hash. const skillsDir = join(runtimeDir, "skills"); @@ -144,13 +181,19 @@ export async function isRuntimeStale( } for (const slug of slugs) { try { - if ((await lstat(join(skillsDir, slug, "SKILL.md"))).mtimeMs > hashMtime) return true; - } catch { /* broken symlink / no SKILL.md under this slug — skip */ } + if ((await lstat(join(skillsDir, slug, "SKILL.md"))).mtimeMs > hashMtime) + return true; + } catch { + /* broken symlink / no SKILL.md under this slug — skip */ + } } return false; } -function appliesToAgent(scoped: { agents?: AgentKind[] }, agent: AgentKind): boolean { +function appliesToAgent( + scoped: { agents?: AgentKind[] }, + agent: AgentKind, +): boolean { if (!scoped.agents || scoped.agents.length === 0) return true; return scoped.agents.includes(agent); } @@ -160,7 +203,11 @@ function sortedJson(value: unknown): string { if (Array.isArray(value)) return "[" + value.map(sortedJson).join(",") + "]"; const obj = value as Record; const keys = Object.keys(obj).sort(); - return "{" + keys.map((k) => JSON.stringify(k) + ":" + sortedJson(obj[k])).join(",") + "}"; + return ( + "{" + + keys.map((k) => JSON.stringify(k) + ":" + sortedJson(obj[k])).join(",") + + "}" + ); } // Bump when the on-disk runtime layout changes in a way the profile content @@ -177,11 +224,45 @@ function sortedJson(value: unknown): string { // situation — generated content changed, no profile field did. const MATERIALIZER_VERSION = 4; -function computeHash(profile: ResolvedProfile, agent: AgentKind, extra = ""): string { - const canonical = sortedJson({ v: MATERIALIZER_VERSION, agent, profile, extra }); +function computeHash( + profile: ResolvedProfile, + agent: AgentKind, + extra = "", +): string { + const canonical = sortedJson({ + v: MATERIALIZER_VERSION, + agent, + profile, + extra, + }); return createHash("sha256").update(canonical).digest("hex"); } +function effectiveCodexOverrides( + profile: ResolvedProfile, +): CodexProfileConfig | undefined { + const legacy = + (profile as { codexConfig?: Record }).codexConfig ?? {}; + const current = (profile as { codex?: CodexProfileConfig }).codex ?? {}; + if (Object.keys(legacy).length === 0 && Object.keys(current).length === 0) { + return undefined; + } + const merged: Record = { ...legacy, ...current }; + const legacyFeatures = isRecord(legacy.features) ? legacy.features : {}; + const currentFeatures = isRecord(current.features) ? current.features : {}; + if ( + Object.keys(legacyFeatures).length > 0 || + Object.keys(currentFeatures).length > 0 + ) { + merged.features = { ...legacyFeatures, ...currentFeatures }; + } + return merged as CodexProfileConfig; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Whether to emit the per-session telemetry sections — `## Skill Usage`, * `## Last Session`, `## Common Workflows` — into the materialized memory file. @@ -195,14 +276,21 @@ function computeHash(profile: ResolvedProfile, agent: AgentKind, extra = ""): st * Opt back in with `CUE_SESSION_TELEMETRY=1|true` (mirrors the file's other * default-off knobs like `CUE_TRIGGER_PHRASES`). */ -export function shouldIncludeSessionTelemetry(env: Record): boolean { - return env.CUE_SESSION_TELEMETRY === "1" || env.CUE_SESSION_TELEMETRY === "true"; +export function shouldIncludeSessionTelemetry( + env: Record, +): boolean { + return ( + env.CUE_SESSION_TELEMETRY === "1" || env.CUE_SESSION_TELEMETRY === "true" + ); } const MATERIALIZE_LOCK_STALE_MS = 600_000; const MATERIALIZE_LOCK_WAIT_MS = 30_000; -async function withMaterializeLock(runtimeDir: string, action: () => Promise): Promise { +async function withMaterializeLock( + runtimeDir: string, + action: () => Promise, +): Promise { const lockDir = `${runtimeDir}.lock`; const ownerFile = join(lockDir, "owner.json"); const token = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; @@ -224,10 +312,14 @@ async function withMaterializeLock(runtimeDir: string, action: () => Promise< if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; try { const age = Date.now() - (await lstat(lockDir)).mtimeMs; - const owner = JSON.parse(await readFile(ownerFile, "utf8")) as { pid?: number }; + const owner = JSON.parse(await readFile(ownerFile, "utf8")) as { + pid?: number; + }; let ownerAlive = typeof owner.pid === "number"; if (ownerAlive) { - try { process.kill(owner.pid!, 0); } catch (probeError) { + try { + process.kill(owner.pid!, 0); + } catch (probeError) { ownerAlive = (probeError as NodeJS.ErrnoException).code === "EPERM"; } } @@ -242,10 +334,14 @@ async function withMaterializeLock(runtimeDir: string, action: () => Promise< await rm(lockDir, { recursive: true, force: true }); continue; } - } catch { /* lock disappeared between checks */ } + } catch { + /* lock disappeared between checks */ + } } if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for runtime materialization lock: ${lockDir}`); + throw new Error( + `Timed out waiting for runtime materialization lock: ${lockDir}`, + ); } await new Promise((resolve) => setTimeout(resolve, 25)); } @@ -255,24 +351,39 @@ async function withMaterializeLock(runtimeDir: string, action: () => Promise< return await action(); } finally { try { - const owner = JSON.parse(await readFile(ownerFile, "utf8")) as { token?: string }; - if (owner.token === token) await rm(lockDir, { recursive: true, force: true }); - } catch { /* a stale-lock recovery already replaced or removed this lock */ } + const owner = JSON.parse(await readFile(ownerFile, "utf8")) as { + token?: string; + }; + if (owner.token === token) + await rm(lockDir, { recursive: true, force: true }); + } catch { + /* a stale-lock recovery already replaced or removed this lock */ + } } } -export async function materializeRuntime(input: MaterializeInput): Promise { +export async function materializeRuntime( + input: MaterializeInput, +): Promise { const runtimeDir = join( input.runtimeRoot, input.runtimeKey ?? input.profile.name, agentSubdir(input.agent), ); - return withMaterializeLock(runtimeDir, () => materializeRuntimeUnlocked(input)); + return withMaterializeLock(runtimeDir, () => + materializeRuntimeUnlocked(input), + ); } -async function materializeRuntimeUnlocked(input: MaterializeInput): Promise { +async function materializeRuntimeUnlocked( + input: MaterializeInput, +): Promise { const { profile, agent, runtimeRoot } = input; - const runtimeDir = join(runtimeRoot, input.runtimeKey ?? profile.name, agentSubdir(agent)); + const runtimeDir = join( + runtimeRoot, + input.runtimeKey ?? profile.name, + agentSubdir(agent), + ); // Normalize any `uvx --from git+ ` MCP entries: install the // package locally with `uv tool install` and rewrite the entry to call the @@ -281,7 +392,10 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise 0) { process.stderr.write( `[cue] installed uvx MCPs: ${uvxReport.installed.join(", ")}\n`, @@ -291,25 +405,36 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise "") - : ""; + const codexBaseText = + agent === "codex" && effectiveInput.codexBaseConfig + ? await readFile(effectiveInput.codexBaseConfig, "utf8").catch(() => "") + : ""; const hash = computeHash(profile, agent, codexBaseText); // Collect profile MCP entries once — used by both cache-hit and rebuild paths // for the .claude.json sync. - const mcpServers = collectProfileMcps(profile, agent, effectiveInput.mcpRegistry); + const mcpServers = collectProfileMcps( + profile, + agent, + effectiveInput.mcpRegistry, + ); // Short-circuit if hash matches. try { - const existing = (await readFile(join(runtimeDir, ".cue-hash"), "utf8")).trim(); + const existing = ( + await readFile(join(runtimeDir, ".cue-hash"), "utf8") + ).trim(); if (existing === hash) { // Refresh state from credentialsSource even on cache hit so account // switches and newly-added source entries are reflected. if (effectiveInput.credentialsSource) { // Re-merge settings.json from current credentialsSource. if (agent === "claude-code") { - const merged = await buildClaudeSettings(profile, agent, effectiveInput); + const merged = await buildClaudeSettings( + profile, + agent, + effectiveInput, + ); await writeFile(join(runtimeDir, "settings.json"), merged + "\n"); } // Re-overlay any source entries that aren't already present (e.g. @@ -322,11 +447,17 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise 0) { - const { DEFERRED_INDEX_SLUG, generateDeferredIndexSkill } = await import("./lazy-skills"); + const { DEFERRED_INDEX_SLUG, generateDeferredIndexSkill } = + await import("./lazy-skills"); const indexDir = join(skillsDir, DEFERRED_INDEX_SLUG); await mkdir(indexDir, { recursive: true }); - await writeFile(join(indexDir, "SKILL.md"), generateDeferredIndexSkill(deferredSkills)); + await writeFile( + join(indexDir, "SKILL.md"), + generateDeferredIndexSkill(deferredSkills), + ); } if (overridden.length > 0) { process.stderr.write( `[cue] ${overridden.length} skill slug collision(s) resolved last-wins ` + - `(loser still smart-loadable): ${overridden.join("; ")}\n`, + `(loser still smart-loadable): ${overridden.join("; ")}\n`, ); } // Manifest for smart-loader's --exclude-loaded: the resolved / @@ -407,8 +542,10 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise 0) { process.stderr.write( `[cue] skipped ${skippedSkills.length} missing skill(s): ${skippedSkills.slice(0, 5).join(", ")}` + - (skippedSkills.length > 5 ? `, +${skippedSkills.length - 5} more` : "") + - ` — run \`cue debug ${profile.name}\` for details\n`, + (skippedSkills.length > 5 + ? `, +${skippedSkills.length - 5} more` + : "") + + ` — run \`cue debug ${profile.name}\` for details\n`, ); } // Fail-loud guard: a single broken ref in a 20-skill profile is tolerable @@ -429,11 +566,11 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise typeof s === "string" ? s : s.id) + .map((s) => (typeof s === "string" ? s : s.id)) .filter((s) => !s.includes("*")); - const mcpsList = (profile.mcps ?? []) - .map((m) => typeof m === "string" ? m : m.id); + const mcpsList = (profile.mcps ?? []).map((m) => + typeof m === "string" ? m : m.id, + ); - let stamp = `\n` + - `# Active Profile: ${iconStr ? iconStr + " " : ""}${profile.name}\n\n` + - `> ${profile.description}\n\n`; + let stamp = + `\n` + + `# Active Profile: ${iconStr ? iconStr + " " : ""}${profile.name}\n\n` + + `> ${profile.description}\n\n`; // Phase 1: Persona — multi-line role-priming defining who the agent IS. // Goes above the mechanical "Your Role" block so it primes interpretation @@ -647,13 +815,30 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise u.zombie).map((u) => u.id); - } catch { /* render full router on any failure */ } + } catch { + /* render full router on any failure */ + } const lean = process.env.CUE_LEAN === "1" || process.env.CUE_LEAN === "true"; // Default-on: the trigger-phrases table duplicates each SKILL.md's own // frontmatter, and on heavy profiles it pushes the materialized CLAUDE.md @@ -680,7 +867,8 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise= 0 ? maxCapEnv : 50; + const maxCapabilityRows = + Number.isFinite(maxCapEnv) && maxCapEnv >= 0 ? maxCapEnv : 50; const routerBlock = renderRouter(routerParsed, { overrides: routerOverrides, zombies: zombieIds, @@ -691,15 +879,17 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise 0) { stamp += `## Available Skills (${skillsList.length})\n\n`; if (skillsList.length <= 20) { - stamp += skillsList.map((s) => `- \`${s.split("/").pop()}\``).join("\n") + "\n"; + stamp += + skillsList.map((s) => `- \`${s.split("/").pop()}\``).join("\n") + "\n"; } else { // Group by category const groups = new Map(); @@ -740,7 +930,9 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise 0) { - stamp += `## Rules (${profileRules.length})\n\n` + + stamp += + `## Rules (${profileRules.length})\n\n` + `Read on demand from \`rules/\`:\n` + - profileRules.map((r) => `- \`rules/${basename(r.endsWith(".md") ? r : `${r}.md`)}\``).join("\n") + "\n\n"; + profileRules + .map( + (r) => `- \`rules/${basename(r.endsWith(".md") ? r : `${r}.md`)}\``, + ) + .join("\n") + + "\n\n"; } // Commands — list as a quick reference if (profileCommands.length > 0) { - stamp += `## Available Commands\n\n` + - profileCommands.map((c) => `- /${basename(c, ".md")}`).join("\n") + "\n\n"; + stamp += + `## Available Commands\n\n` + + profileCommands.map((c) => `- /${basename(c, ".md")}`).join("\n") + + "\n\n"; } // Subagents — a grouped roster of the delegatable specialists in agents/. @@ -788,7 +988,8 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise 0) { - stamp += `## Playbooks (${profilePlaybooks.length})\n\n` + + stamp += + `## Playbooks (${profilePlaybooks.length})\n\n` + `Read on demand from \`playbooks/\` when the user's request matches:\n` + - profilePlaybooks.map((p: string) => { - const stem = basename(p, ".md"); - return `- \`playbooks/${stem}.md\` — use when ${stem.replace(/-/g, " ")}`; - }).join("\n") + "\n\n" + + profilePlaybooks + .map((p: string) => { + const stem = basename(p, ".md"); + return `- \`playbooks/${stem}.md\` — use when ${stem.replace(/-/g, " ")}`; + }) + .join("\n") + + "\n\n" + `**Following a playbook beats freestyling.** If a relevant playbook exists, read it first and step through it.\n\n`; } // Quality gates (Phase 3) — mention so Claude knows what'll be checked at Stop. const profileGatesForStamp = (profile as any).qualityGates ?? []; if (profileGatesForStamp.length > 0) { - stamp += `## Quality Gates\n\nBefore claiming this session complete, these checks run at Stop:\n` + - profileGatesForStamp.map((g: string) => `- \`${basename(g)}\``).join("\n") + "\n\n" + + stamp += + `## Quality Gates\n\nBefore claiming this session complete, these checks run at Stop:\n` + + profileGatesForStamp + .map((g: string) => `- \`${basename(g)}\``) + .join("\n") + + "\n\n" + `Don't claim "done" if you haven't met them — they'll fail you publicly.\n\n`; } @@ -836,13 +1045,16 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise MEMORY_FILE_WARN_CHARS) { + if ( + agent === "claude-code" && + memoryFileContent.length > MEMORY_FILE_WARN_CHARS + ) { const kb = (memoryFileContent.length / 1000).toFixed(1); process.stderr.write( `[cue] ${memoryFileName} for profile "${profile.name}" is ${kb}k chars ` + - `(> ${(MEMORY_FILE_WARN_CHARS / 1000).toFixed(0)}k) — large memory files slow the agent ` + - `and trigger its perf warning. Trim the profile (fewer skills/rules) or the ` + - `appended user instructions.\n`, + `(> ${(MEMORY_FILE_WARN_CHARS / 1000).toFixed(0)}k) — large memory files slow the agent ` + + `and trigger its perf warning. Trim the profile (fewer skills/rules) or the ` + + `appended user instructions.\n`, ); } await writeFile(join(tmpDir, memoryFileName), memoryFileContent); @@ -883,7 +1095,11 @@ async function materializeRuntimeUnlocked(input: MaterializeInput): Promise { await Promise.all( names .filter((name) => name.startsWith(prefix)) - .map((name) => rm(join(parent, name), { recursive: true, force: true }).catch(() => {})), + .map((name) => + rm(join(parent, name), { recursive: true, force: true }).catch( + () => {}, + ), + ), ); } catch { /* parent unreadable — nothing to sweep */ @@ -1054,7 +1298,9 @@ async function sweepStaleSwapDirs(runtimeDir: string): Promise { async function credentialsExpiresAt(path: string): Promise { try { const raw = await readFile(path, "utf8"); - const parsed = JSON.parse(raw) as { claudeAiOauth?: { expiresAt?: number } }; + const parsed = JSON.parse(raw) as { + claudeAiOauth?: { expiresAt?: number }; + }; const exp = parsed?.claudeAiOauth?.expiresAt; return typeof exp === "number" ? exp : 0; } catch { @@ -1073,7 +1319,9 @@ async function credentialsExpiresAt(path: string): Promise { async function accountUuidAt(path: string): Promise { try { const raw = await readFile(path, "utf8"); - const parsed = JSON.parse(raw) as { oauthAccount?: { accountUuid?: string } }; + const parsed = JSON.parse(raw) as { + oauthAccount?: { accountUuid?: string }; + }; return parsed?.oauthAccount?.accountUuid; } catch { return undefined; @@ -1124,7 +1372,8 @@ async function syncMcpsIntoClaudeJson( const code = (err as NodeJS.ErrnoException)?.code; if (code !== undefined && code !== "ENOENT") return; } - const existing = (parsed.mcpServers as Record | undefined) ?? {}; + const existing = + (parsed.mcpServers as Record | undefined) ?? {}; const merged: Record = { ...existing, ...mcpServers }; // Lazy-MCP removal: the rebuild preserves the OLD runtime's .claude.json @@ -1202,7 +1451,11 @@ function sameResolvedPath(a: string, b: string): boolean { * refresh. Credential staggering must key off the runtime's final location or * the same runtime would land in a different slot on every rebuild. */ -async function overlaySourceState(targetDir: string, sourceDir: string, staggerKey: string = targetDir): Promise { +async function overlaySourceState( + targetDir: string, + sourceDir: string, + staggerKey: string = targetDir, +): Promise { const sourceResolved = resolvePath(sourceDir); const targetResolved = resolvePath(targetDir); const finalResolved = resolvePath(staggerKey); @@ -1223,12 +1476,17 @@ async function overlaySourceState(targetDir: string, sourceDir: string, staggerK // surface it so the runtime looks fully onboarded — otherwise claude // boots into the OAuth flow even with a valid .credentials.json present. // Only kicks in when sourceDir is the user's ~/.claude. - if (!entries.includes(".claude.json") && sourceDir === join(homedir(), ".claude")) { + if ( + !entries.includes(".claude.json") && + sourceDir === join(homedir(), ".claude") + ) { const legacy = join(homedir(), ".claude.json"); try { const { existsSync } = await import("node:fs"); if (existsSync(legacy)) entries.push(".claude.json"); - } catch { /* skip */ } + } catch { + /* skip */ + } } // Account identity of both sides, resolved ONCE before the loop below can @@ -1236,8 +1494,10 @@ async function overlaySourceState(targetDir: string, sourceDir: string, staggerK // as the entry list above: with no CLAUDE_CONFIG_DIR, Claude Code keeps // `oauthAccount` in `~/.claude.json`, not in `~/.claude/.claude.json`. const identityOf = async (dir: string): Promise => - (await accountUuidAt(join(dir, ".claude.json"))) - ?? (basename(dir) === ".claude" ? await accountUuidAt(join(dirname(dir), ".claude.json")) : undefined); + (await accountUuidAt(join(dir, ".claude.json"))) ?? + (basename(dir) === ".claude" + ? await accountUuidAt(join(dirname(dir), ".claude.json")) + : undefined); const srcAccount = await identityOf(sourceDir); const dstAccount = await identityOf(targetDir); // Unknown on either side → treat as the same account: the expiry comparison @@ -1251,8 +1511,7 @@ async function overlaySourceState(targetDir: string, sourceDir: string, staggerK // Special-case the legacy ~/.claude.json fallback above: source is at the // home-root path, not inside sourceDir. const isLegacyClaudeJson = - name === ".claude.json" && - sourceDir === join(homedir(), ".claude"); + name === ".claude.json" && sourceDir === join(homedir(), ".claude"); const sourcePath = isLegacyClaudeJson ? join(homedir(), ".claude.json") : join(sourceDir, name); @@ -1261,7 +1520,9 @@ async function overlaySourceState(targetDir: string, sourceDir: string, staggerK try { const st = await lstat(targetPath); existingType = st.isSymbolicLink() ? "symlink" : "other"; - } catch { /* missing */ } + } catch { + /* missing */ + } // .claude.json gets the same copy-not-symlink treatment as .credentials.json: // claude rewrites it atomically and we want per-profile session state, not @@ -1293,7 +1554,9 @@ async function overlaySourceState(targetDir: string, sourceDir: string, staggerK const tmp = `${targetPath}.cue-swap.${process.pid}`; await copyFile(sourcePath, tmp); await rename(tmp, targetPath); - } catch { /* non-fatal — keep existing file */ } + } catch { + /* non-fatal — keep existing file */ + } } } continue; // cue override — don't touch @@ -1316,11 +1579,16 @@ async function overlaySourceState(targetDir: string, sourceDir: string, staggerK if (dstExpiresAt > srcExpiresAt) continue; // runtime holds the live token } - if (existingType === "symlink" || (existingType === "other" && isCopyFile)) { + if ( + existingType === "symlink" || + (existingType === "other" && isCopyFile) + ) { // Replace if it points elsewhere (e.g. previous account on cache hit). try { await rm(targetPath, { force: true }); - } catch { continue; } + } catch { + continue; + } } if (isCopyFile) { @@ -1335,11 +1603,15 @@ async function overlaySourceState(targetDir: string, sourceDir: string, staggerK } else { await copyFile(sourcePath, targetPath); } - } catch { /* skip */ } + } catch { + /* skip */ + } } else { try { await symlink(sourcePath, targetPath); - } catch { /* race or permission — skip silently */ } + } catch { + /* race or permission — skip silently */ + } } } } @@ -1366,7 +1638,10 @@ async function overlaySourceState(targetDir: string, sourceDir: string, staggerK * it risks clobbering the real registry with an empty `{plugins:{}}`. * - `data`: per-plugin writable state; the self-referential ELOOP source. */ -export async function linkPluginCache(targetDir: string, sourceDir: string): Promise { +export async function linkPluginCache( + targetDir: string, + sourceDir: string, +): Promise { if (sameResolvedPath(targetDir, sourceDir)) return; const srcPlugins = join(sourceDir, "plugins"); @@ -1429,7 +1704,8 @@ function parseLoopbackProxyUrl(rawUrl: string): URL | null { try { const u = new URL(rawUrl); const host = u.hostname; - if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") return null; + if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") + return null; if (u.protocol !== "http:" && u.protocol !== "https:") return null; return u; } catch { @@ -1444,7 +1720,10 @@ function parseLoopbackProxyUrl(rawUrl: string): URL | null { * within `timeoutMs`. A raw TCP connect is not enough: a saturated proxy may * accept sockets while timing out or returning 503 to Claude traffic. */ -async function isProxyReachable(rawUrl: string, timeoutMs = 400): Promise { +async function isProxyReachable( + rawUrl: string, + timeoutMs = 400, +): Promise { const baseUrl = parseLoopbackProxyUrl(rawUrl); if (!baseUrl) return true; const healthUrl = new URL("/health", baseUrl); @@ -1482,16 +1761,22 @@ async function buildClaudeSettings( let baseSettings: Record = {}; if (input.credentialsSource) { try { - const raw = await readFile(join(input.credentialsSource, "settings.json"), "utf8"); + const raw = await readFile( + join(input.credentialsSource, "settings.json"), + "utf8", + ); baseSettings = JSON.parse(raw); - } catch { /* no existing settings — start fresh */ } + } catch { + /* no existing settings — start fresh */ + } } // Merge profile hooks. A hook ref points to a JSON file with shape // { hooks: { PreToolUse: [...], ... } } — same shape Claude Code expects. // Multiple hook files concat their event arrays under each lifecycle key. let mergedHooks: Record = {}; - const baseHooks = (baseSettings.hooks as Record | undefined) ?? {}; + const baseHooks = + (baseSettings.hooks as Record | undefined) ?? {}; for (const [k, v] of Object.entries(baseHooks)) { mergedHooks[k] = Array.isArray(v) ? [...v] : []; } @@ -1517,7 +1802,9 @@ async function buildClaudeSettings( if (!Array.isArray(entries)) continue; mergedHooks[event] = [...(mergedHooks[event] ?? []), ...entries]; } - } catch { /* missing or malformed — skip */ } + } catch { + /* missing or malformed — skip */ + } } // Dedupe entries per event by JSON signature — keeps the first occurrence. @@ -1615,7 +1902,9 @@ import { homedir } from "node:os"; import { readdirSync, existsSync, statSync } from "node:fs"; import { spawnSync } from "node:child_process"; -async function getLastSessionSummary(profileName: string): Promise { +async function getLastSessionSummary( + profileName: string, +): Promise { try { const projectsDir = join(homedir(), ".claude", "projects"); if (!existsSync(projectsDir)) return null; @@ -1630,7 +1919,9 @@ async function getLastSessionSummary(profileName: string): Promise f.endsWith(".jsonl")); + const allFiles = readdirSync(projectDir).filter((f) => + f.endsWith(".jsonl"), + ); if (allFiles.length === 0) return null; // Sort by name (includes timestamp) — take last 3 only @@ -1644,7 +1935,11 @@ async function getLastSessionSummary(profileName: string): Promise c.type === "text")?.text ?? "" - : typeof msg.message.content === "string" ? msg.message.content : ""; + ? (msg.message.content.find((c: any) => c.type === "text")?.text ?? + "") + : typeof msg.message.content === "string" + ? msg.message.content + : ""; if (text.length > 20) { // Take first sentence const sentence = text.split(/[.!?\n]/)[0]?.trim(); @@ -1697,11 +1995,15 @@ async function getSkillChains(skillsList: string[]): Promise { // Scan recent sessions for skill co-occurrence const slugs = new Set(skillsList.map((s) => s.split("/").pop() ?? s)); - const res = spawnSync("grep", ["-roh", "skills/[a-z][a-z0-9-]*/SKILL.md", projectsDir], { - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf8", - timeout: 2000, - }); + const res = spawnSync( + "grep", + ["-roh", "skills/[a-z][a-z0-9-]*/SKILL.md", projectsDir], + { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + timeout: 2000, + }, + ); if (!res.stdout) return null; @@ -1724,9 +2026,11 @@ async function getSkillChains(skillsList: string[]): Promise { if (topSkills.length < 2) return null; // Build a simple chain from the top skills - return `Based on your usage patterns, common skill sequences:\n` + + return ( + `Based on your usage patterns, common skill sequences:\n` + `- ${topSkills.slice(0, 3).join(" → ")}\n` + - (topSkills.length > 3 ? `- ${topSkills.slice(2, 5).join(" → ")}\n` : ""); + (topSkills.length > 3 ? `- ${topSkills.slice(2, 5).join(" → ")}\n` : "") + ); } catch { return null; } From 50bb26480e4f45bdfa6c2e21a247ac0e6ea94887 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 13 Aug 2026 16:59:28 +0200 Subject: [PATCH 33/33] fix(profiles): point design skill refs at their canonical ids The skills submodule bump pulls in opencue/skills#23, which consolidated byte-identical duplicates behind canonical names. Five profiles still referenced the retired ids, so `cue validate` failed E3 on every one. Renames follow catalog/aliases.json: design/gpt-tasteskill -> design/gpt-taste design/image-to-code-skill -> design/image-to-code design/redesign-skill -> design/redesign-existing-projects design/soft-skill -> design/high-end-visual-design design/output-skill -> meta/full-output-enforcement design/minimalist-skill -> design/minimalist-ui design/brutalist-skill -> design/industrial-brutalist-ui design/stitch-skill -> design/stitch-design-taste cue's resolver does not read catalog/aliases.json, so the profiles carry canonical ids directly rather than relying on alias indirection. --- profiles/commerce/profile.yaml | 16 ++++++++-------- profiles/designer/llms.txt | 14 +++++++------- profiles/designer/profile.yaml | 16 ++++++++-------- profiles/ego-lite-stack/profile.yaml | 16 ++++++++-------- profiles/studio/profile.yaml | 16 ++++++++-------- profiles/webshop/profile.yaml | 16 ++++++++-------- 6 files changed, 47 insertions(+), 47 deletions(-) diff --git a/profiles/commerce/profile.yaml b/profiles/commerce/profile.yaml index 7f2586f1..62142c6b 100644 --- a/profiles/commerce/profile.yaml +++ b/profiles/commerce/profile.yaml @@ -53,17 +53,17 @@ skills: - browser/agent-browser # browser automation (vercel-labs/agent-browser CLI) - design/taste-skill - design/taste-skill-v1 - - design/gpt-tasteskill - - design/image-to-code-skill + - design/gpt-taste + - design/image-to-code - design/imagegen-frontend-web - design/imagegen-frontend-mobile - design/brandkit - - design/redesign-skill - - design/soft-skill - - design/output-skill - - design/minimalist-skill - - design/brutalist-skill - - design/stitch-skill + - design/redesign-existing-projects + - design/high-end-visual-design + - meta/full-output-enforcement + - design/minimalist-ui + - design/industrial-brutalist-ui + - design/stitch-design-taste - design/industrial-brutalist-ui - design/redesign-existing-projects - gstack/design-consultation diff --git a/profiles/designer/llms.txt b/profiles/designer/llms.txt index 74a7527f..463f7e9b 100644 --- a/profiles/designer/llms.txt +++ b/profiles/designer/llms.txt @@ -1,13 +1,13 @@ taste-skill: The default design skill (v2 experimental). Read the brief, infer the design language, tune three dials (VARIANCE / MOTION / DENSITY), and ship landing pages, portfolios, and redesigns that do not look templated. Brief inference, design-system map, em-dash ban, GSAP code skeletons, hard-rules pre-flight check. Actively iterating toward v2.0.0 stable. taste-skill-v1: The original v1 of taste-skill, preserved for projects depending on its exact behavior. The current default is `design-taste-frontend` (v2 experimental). gpt-taste: Elite Awwwards-level frontend design and GSAP motion skill for premium, deterministic, anti-slop UI generation. -image-to-code-skill: Image-first frontend skill for generating premium website references, deeply analyzing them, and implementing code to match. +image-to-code: Image-first frontend skill for generating premium website references, deeply analyzing them, and implementing code to match. imagegen-frontend-web: Image-generation-only skill for creating premium website design reference images. Does not write code. imagegen-frontend-mobile: Image-generation-only skill for creating premium mobile app screen concepts and flows. Does not write code. brandkit: Image-generation-only skill for creating premium brand-kit overview images with logo concepts, identity systems, color palettes, typography, and mockups. Does not write code. -redesign-skill: For upgrading existing projects by auditing and fixing design problems. -soft-skill: Focuses on an expensive, soft UI look with premium fonts, whitespace, depth, and smooth animations. -output-skill: Prevents AI from being lazy, skipping code blocks, or using placeholder comments. -minimalist-skill: Enforces clean, editorial-style interfaces (Notion/Linear style) with strict monochrome palettes. -brutalist-skill: Raw mechanical interfaces, Swiss typography, extreme scale contrast. (Beta) -stitch-skill: Google Stitch-compatible semantic design rules for premium AI UI generation. +redesign-existing-projects: For upgrading existing projects by auditing and fixing design problems. +high-end-visual-design: Focuses on an expensive, soft UI look with premium fonts, whitespace, depth, and smooth animations. +full-output-enforcement: Prevents AI from being lazy, skipping code blocks, or using placeholder comments. +minimalist-ui: Enforces clean, editorial-style interfaces (Notion/Linear style) with strict monochrome palettes. +industrial-brutalist-ui: Raw mechanical interfaces, Swiss typography, extreme scale contrast. (Beta) +stitch-design-taste: Google Stitch-compatible semantic design rules for premium AI UI generation. diff --git a/profiles/designer/profile.yaml b/profiles/designer/profile.yaml index 9a16d1eb..49d48c54 100644 --- a/profiles/designer/profile.yaml +++ b/profiles/designer/profile.yaml @@ -40,17 +40,17 @@ skills: local: - design/taste-skill - design/taste-skill-v1 - - design/gpt-tasteskill - - design/image-to-code-skill + - design/gpt-taste + - design/image-to-code - design/imagegen-frontend-web - design/imagegen-frontend-mobile - design/brandkit - - design/redesign-skill - - design/soft-skill - - design/output-skill - - design/minimalist-skill - - design/brutalist-skill - - design/stitch-skill + - design/redesign-existing-projects + - design/high-end-visual-design + - meta/full-output-enforcement + - design/minimalist-ui + - design/industrial-brutalist-ui + - design/stitch-design-taste - design/industrial-brutalist-ui - design/redesign-existing-projects # gstack diff --git a/profiles/ego-lite-stack/profile.yaml b/profiles/ego-lite-stack/profile.yaml index 217ff25c..c20df0d4 100644 --- a/profiles/ego-lite-stack/profile.yaml +++ b/profiles/ego-lite-stack/profile.yaml @@ -102,17 +102,17 @@ skills: - browser/agent-browser - design/taste-skill - design/taste-skill-v1 - - design/gpt-tasteskill - - design/image-to-code-skill + - design/gpt-taste + - design/image-to-code - design/imagegen-frontend-web - design/imagegen-frontend-mobile - design/brandkit - - design/redesign-skill - - design/soft-skill - - design/output-skill - - design/minimalist-skill - - design/brutalist-skill - - design/stitch-skill + - design/redesign-existing-projects + - design/high-end-visual-design + - meta/full-output-enforcement + - design/minimalist-ui + - design/industrial-brutalist-ui + - design/stitch-design-taste - design/industrial-brutalist-ui - design/redesign-existing-projects - higgsfield/higgsfield-generate diff --git a/profiles/studio/profile.yaml b/profiles/studio/profile.yaml index 1dc6026d..96053b44 100644 --- a/profiles/studio/profile.yaml +++ b/profiles/studio/profile.yaml @@ -15,17 +15,17 @@ skills: local: - design/taste-skill - design/taste-skill-v1 - - design/gpt-tasteskill - - design/image-to-code-skill + - design/gpt-taste + - design/image-to-code - design/imagegen-frontend-web - design/imagegen-frontend-mobile - design/brandkit - - design/redesign-skill - - design/soft-skill - - design/output-skill - - design/minimalist-skill - - design/brutalist-skill - - design/stitch-skill + - design/redesign-existing-projects + - design/high-end-visual-design + - meta/full-output-enforcement + - design/minimalist-ui + - design/industrial-brutalist-ui + - design/stitch-design-taste - design/industrial-brutalist-ui - design/redesign-existing-projects - gstack/design-consultation diff --git a/profiles/webshop/profile.yaml b/profiles/webshop/profile.yaml index d0f3aaeb..b59dd0ff 100644 --- a/profiles/webshop/profile.yaml +++ b/profiles/webshop/profile.yaml @@ -62,17 +62,17 @@ skills: # --- designer: premium UI + brand kit + redesign --- - design/taste-skill - design/taste-skill-v1 - - design/gpt-tasteskill - - design/image-to-code-skill + - design/gpt-taste + - design/image-to-code - design/imagegen-frontend-web - design/imagegen-frontend-mobile - design/brandkit - - design/redesign-skill - - design/soft-skill - - design/output-skill - - design/minimalist-skill - - design/brutalist-skill - - design/stitch-skill + - design/redesign-existing-projects + - design/high-end-visual-design + - meta/full-output-enforcement + - design/minimalist-ui + - design/industrial-brutalist-ui + - design/stitch-design-taste - design/industrial-brutalist-ui - design/redesign-existing-projects - gstack/design-consultation