**Taking: `engine/tool-availability.ts`, `engine/skill-resolver.ts`, `cli/commands/task.ts`** — the three census hits that are not board columns. ## Census | file | before | after | |---|---:|---:| | `packages/engine/src/tool-availability.ts` | 1 | **0** | | `packages/engine/src/skill-resolver.ts` | 1 | **0** | | `packages/cli/src/commands/task.ts` | 1 | **0** | | **repo total (comment-stripped)** | **48** | **45** | ## These are not lifecycle guards — converting them would have been wrong - **`tool-availability`** — `surface: "triage" | "executor"` is an **agent lane**. The lane that writes specs keeps its name whatever the board calls its planning column. Resolving it from a workflow IR would make an agent's prompt depend on board configuration. - **`skill-resolver`** — `sessionPurpose === "triage"` is an **agent role**. Same argument: a role doesn't move when a board renames a column. - **`cli task list`** — the glyph chain distinguished **active** columns from the rest and nothing else; all four active ids mapped to the same `●`. Each is now named (`AgentResearchSurface`, `ROLE_FALLBACK_SESSION_PURPOSES`, `ACTIVE_COLUMN_GLYPH_IDS`) so the next person working the census sees at a glance that they're out of scope, rather than re-deriving it as I had to. ## A real divergence my own equivalence test caught I first wrote the glyph as the tempting inverse: ```ts const dot = col === "done" || col === "archived" ? "○" : "●"; ``` That is equivalent across all six lifecycle ids and **not** equivalent for anything else — the original chain fell through to `"○"` for an unrecognised id, while the inverse renders it as **active**. The loop only walks the six `COLUMNS` today, so nothing would have caught it in practice; a renamed workflow reaching this code later would have silently changed how its columns render. Shipped as an explicit ACTIVE set that mirrors the fallthrough exactly. The test asserts equivalence over the six ids **and** over unknown ids, which is where the difference lives. That's the point of testing a "pure rename" at its edges rather than only where it's currently exercised. ## Verification - 71 tests green across skill-resolver / heartbeat-skills / tool-availability / the new equivalence suite - merge gate green (482 + 132 + 10), engine + CLI tsc clean, lint clean ## Note on the remaining count Of the 45 left, `replan-target.ts` (2), `board-workflows.ts` (2) and `archive-planning.ts` (1) show up in a **raw** grep but are **0** real — every hit is inside a comment. A raw grep reports 53; comment-stripped is 45. Real remaining work concentrates in `self-healing.ts` (11), `register-task-workflow-routes.ts` (6), and the parked `moves.ts` / `default-workflow-hooks.ts` (9). No changeset: `@fusion/engine` is private; the CLI change is display-identical. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## ⚠️ Read before merging — these are ROLE renames, not column conversions The coordinator's hand classification says several literals in this PR "must be left exactly as they are" because they compare an **agent role**, not a task column, and resolving them to a column trait would be a bug. **I agree, and this PR does not do that.** What it does: replaces a bare `=== "triage"` with a **named role predicate** — `isPlanningAgentLane`, `AgentResearchSurface`, `ROLE_FALLBACK_SESSION_PURPOSES`. Behaviour is **byte-identical** for every input. No IR is consulted, no trait is resolved, no column is involved. The reason to keep it rather than revert: the danger isn't the literal, it's that nothing at the call site tells the next person `"triage"` here means a *lane*. A list of exceptions maintained elsewhere only helps someone who finds the list; a call named `isPlanningAgentLane` helps whoever is reading the line. It also shrinks what the #2630 ratchet's ignore list has to carry. Reversible: if the preference is to leave the literals untouched, say so and I'll strip these hunks — but then the ratchet's ignore list must carry **all twelve** role sites or it can never reach zero, because those six are correct code. ## Classification finding Bucketing by the **receiver** of the comparison (not the literal) mechanically separates guards from roles, and it found **six role sites currently listed as "real column guards"**: | site | receiver | what it actually is | |---|---|---| | `usage-limit-detector.ts:144, 207` | `agentType` | agent type — the column test one line above is *already* trait-driven | | `skill-resolver.ts:432` | `sessionPurpose` | session purpose | | `tool-availability.ts:32` | `surface` | agent surface (`"triage" \| "executor"`) | | `effective-model-resolution.ts:148` | `entry.agent` | agent-log lane | | `useTasks.ts:162` | `entry.agent` | agent-log lane | So the real bar is roughly **39**, not 45. The rule that found all twelve without judgement calls: `column`/`toColumn`/`taskColumn`/`c` are guards; `role`/`agent`/`agentType`/`surface`/`sessionPurpose` are not. Worth teaching #2630's ratchet directly.
578 lines
21 KiB
TypeScript
578 lines
21 KiB
TypeScript
/**
|
|
* Skill selection resolver for deterministic session skill sets.
|
|
*
|
|
* Computes which skills should be available in agent sessions based on:
|
|
* 1. Project execution-enabled skill patterns from settings
|
|
* 2. Optional caller-requested skill names (for per-task overrides)
|
|
*
|
|
* The resolver reads project settings files directly (read-only) and produces
|
|
* a filter set used by createFnAgent's DefaultResourceLoader.skillsOverride.
|
|
*/
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import type { ResourceDiagnostic, Skill } from "@earendil-works/pi-coding-agent";
|
|
import { getProjectRootFromWorktree, normalizeStoredSkillPath } from "@fusion/core";
|
|
import { piLog } from "./logger.js";
|
|
|
|
// ── Project Root Resolution ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the project root directory by preferring the parent repo when
|
|
* `cwd` is inside a `.worktrees/<name>/...` path, then falling back to the
|
|
* legacy `.fusion` ancestor walk.
|
|
*
|
|
* Falls back to `cwd` if no `.fusion/` directory is found (mirrors
|
|
* `resolvePiExtensionProjectRoot` from `@fusion/core`).
|
|
*/
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-30-13:20 (U11 census hygiene):
|
|
`"triage"` HERE IS A SESSION PURPOSE — which agent role is running — NOT a board
|
|
column. It matched the `=== "triage"` census only because it is the same word, and
|
|
resolving it from a workflow's IR would be wrong: an agent role does not move when
|
|
a board renames its planning column.
|
|
|
|
Hoisted to a named set so the four role purposes read as one concept and the
|
|
literal stops looking like a lifecycle guard.
|
|
*/
|
|
const ROLE_FALLBACK_SESSION_PURPOSES: ReadonlySet<string> = new Set([
|
|
"triage",
|
|
"executor",
|
|
"reviewer",
|
|
"merger",
|
|
]);
|
|
|
|
export function resolveProjectRoot(cwd: string): string {
|
|
const worktreeProjectRoot = getProjectRootFromWorktree(cwd);
|
|
if (worktreeProjectRoot && existsSync(join(worktreeProjectRoot, ".fusion"))) {
|
|
return worktreeProjectRoot;
|
|
}
|
|
|
|
let current = resolve(cwd);
|
|
while (true) {
|
|
if (existsSync(join(current, ".fusion"))) {
|
|
return current;
|
|
}
|
|
const parent = dirname(current);
|
|
if (parent === current) {
|
|
return resolve(cwd);
|
|
}
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
// ── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Context for skill selection resolution.
|
|
*/
|
|
export interface SkillSelectionContext {
|
|
/**
|
|
* Absolute path to the project root for reading settings.
|
|
*/
|
|
projectRootDir: string;
|
|
|
|
/**
|
|
* Optional explicit skill names the caller wants (e.g., from task config).
|
|
* These are skill names (not IDs), matched case-insensitively against Skill.name.
|
|
*/
|
|
requestedSkillNames?: string[];
|
|
|
|
/**
|
|
* Diagnostic label for log messages (e.g., "executor", "triage", "reviewer").
|
|
*/
|
|
sessionPurpose?: string;
|
|
}
|
|
|
|
/**
|
|
* Diagnostic about a configured or requested skill.
|
|
*/
|
|
export interface SkillDiagnostic {
|
|
type: "info" | "warning" | "error";
|
|
message: string;
|
|
skillName?: string;
|
|
skillPath?: string;
|
|
}
|
|
|
|
/**
|
|
* Result of skill selection resolution.
|
|
*/
|
|
export interface SkillSelectionResult {
|
|
/**
|
|
* Set of skill file paths to include in the session.
|
|
* Used by skillsOverride to filter discovered skills.
|
|
*/
|
|
allowedSkillPaths: Set<string>;
|
|
|
|
/**
|
|
* Set of skill file paths that were explicitly excluded by project patterns.
|
|
* These paths were disabled via -prefix patterns.
|
|
* Used by skillsOverride to distinguish "disabled" (exists but excluded) from "missing" (doesn't exist).
|
|
*/
|
|
excludedSkillPaths: Set<string>;
|
|
|
|
/**
|
|
* Diagnostics about configured/requested skills.
|
|
*/
|
|
diagnostics: SkillDiagnostic[];
|
|
|
|
/**
|
|
* Whether filtering should be applied.
|
|
* false = all discovered skills pass through (no patterns configured, no requested names)
|
|
* true = skills are filtered according to allowedSkillPaths
|
|
*/
|
|
filterActive: boolean;
|
|
}
|
|
|
|
/**
|
|
* Project settings structure relevant to skill selection.
|
|
*/
|
|
export interface ProjectSkillSettings {
|
|
skills?: string[];
|
|
packages?: Array<string | { source: string; skills?: string[] }>;
|
|
}
|
|
|
|
// ── Settings Reading ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Read a JSON object from a file path.
|
|
* Returns empty object if file doesn't exist or is invalid.
|
|
*/
|
|
function readJsonObject(path: string): Record<string, unknown> {
|
|
if (!existsSync(path)) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read project settings from .fusion/settings.json.
|
|
*/
|
|
export function readProjectSettings(projectRootDir: string): ProjectSkillSettings {
|
|
const fusionSettings = join(projectRootDir, ".fusion", "settings.json");
|
|
|
|
if (existsSync(fusionSettings)) {
|
|
const parsed = readJsonObject(fusionSettings);
|
|
// Only return skill-relevant fields
|
|
return {
|
|
skills: Array.isArray(parsed.skills) ? (parsed.skills as string[]) : undefined,
|
|
packages: Array.isArray(parsed.packages) ? (parsed.packages as Array<string | { source: string; skills?: string[] }>) : undefined,
|
|
};
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
// ── Pattern Normalization ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Normalize a skill pattern by removing the + prefix (enabled by default).
|
|
* Returns the path portion of the pattern.
|
|
*/
|
|
function normalizePattern(pattern: string): string {
|
|
if (pattern.startsWith("+") || pattern.startsWith("-")) {
|
|
return pattern.slice(1);
|
|
}
|
|
return pattern;
|
|
}
|
|
|
|
/**
|
|
* Check if a pattern is an exclusion pattern (-prefixed).
|
|
*/
|
|
function isExclusionPattern(pattern: string): boolean {
|
|
return pattern.startsWith("-");
|
|
}
|
|
|
|
/**
|
|
* Return the canonical body path beneath a discovered skill's `skills/` root.
|
|
* A path without that root is only eligible for exact filePath matching.
|
|
*/
|
|
function skillBodyRelativePath(filePath: string): string | undefined {
|
|
const normalizedFilePath = filePath.replaceAll("\\", "/");
|
|
const lowerCasePath = normalizedFilePath.toLowerCase();
|
|
const segmentIndex = lowerCasePath.lastIndexOf("/skills/");
|
|
|
|
if (segmentIndex >= 0) {
|
|
return normalizedFilePath.slice(segmentIndex + "/skills/".length);
|
|
}
|
|
if (lowerCasePath.startsWith("skills/")) {
|
|
return normalizedFilePath.slice("skills/".length);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* FNXC:SkillResolution 2026-07-21-00:00:
|
|
* GitHub #2385 / FN-8465 requires session filtering to use the same skills-relative body-path identity as the Skills view. Legacy `-name/SKILL.md` entries must not suppress a re-categorized `skills/category/name/SKILL.md` body merely because pi exposes the same bare Skill.name.
|
|
*/
|
|
function skillMatchesExecutionPattern(skill: Skill, pattern: string): boolean {
|
|
if (skill.filePath === pattern) {
|
|
return true;
|
|
}
|
|
|
|
const bodyRelativePath = skillBodyRelativePath(skill.filePath);
|
|
return bodyRelativePath !== undefined
|
|
&& normalizeStoredSkillPath(bodyRelativePath).toLowerCase()
|
|
=== normalizeStoredSkillPath(pattern).toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* FNXC:SkillResolution 2026-07-21-00:00:
|
|
* Legacy flat `+name/SKILL.md` keys must be ignored as well as legacy disables.
|
|
* Otherwise an unmatched stale allow key activates the session allow-list and
|
|
* suppresses re-categorized skills even though the Skills view shows them enabled.
|
|
*/
|
|
function isLegacyFlatPatternForNestedSkill(skill: Skill, pattern: string): boolean {
|
|
const bodyRelativePath = skillBodyRelativePath(skill.filePath);
|
|
if (!bodyRelativePath) return false;
|
|
|
|
const bodySegments = normalizeStoredSkillPath(bodyRelativePath)
|
|
.toLowerCase()
|
|
.split("/");
|
|
const patternSegments = normalizeStoredSkillPath(pattern)
|
|
.toLowerCase()
|
|
.split("/");
|
|
|
|
return bodySegments.length > 2
|
|
&& patternSegments.length === 2
|
|
&& bodySegments.at(-2) === patternSegments[0]
|
|
&& bodySegments.at(-1) === patternSegments[1];
|
|
}
|
|
|
|
/**
|
|
* FNXC:SkillResolution 2026-06-26-00:00:
|
|
* Requested skills can arrive from chat and agent metadata as `gamma`, `review/pr`, `review/pr/SKILL.md`, or `source::skills/review/pr/SKILL.md` while discovered Skill.name entries are keyed by the bare token.
|
|
* Reduce only requested-name comparisons to the dashboard bareSkillName convention so slash/namespaced requests load without changing allow/exclude path matching, which still depends on bareSkillName plus filePath equality.
|
|
*/
|
|
function requestedSkillMatchKey(name: string): string {
|
|
if (!name) return "";
|
|
const withoutSkillMd = name.replace(/\/SKILL\.md$/i, "");
|
|
const lastPathSegment = withoutSkillMd.split("/").pop() ?? withoutSkillMd;
|
|
const afterNamespace = lastPathSegment.split(":").pop() ?? lastPathSegment;
|
|
return afterNamespace.toLowerCase();
|
|
}
|
|
|
|
// ── Main Resolution Logic ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Compute deterministic skill selection from project settings and optional requested names.
|
|
*
|
|
* Resolution rules:
|
|
* 1. If NO skill patterns exist AND no requestedSkillNames → filterActive: false (all pass through)
|
|
* 2. If skill patterns exist:
|
|
* - + prefix or no prefix = add to allowed set
|
|
* - - prefix = exclude from allowed set
|
|
* - Last entry wins for duplicate paths
|
|
* 3. If requestedSkillNames provided:
|
|
* - Acts as additional intersection filter (skills must match name AND be in allowed set)
|
|
* - Case-insensitive matching against Skill.name
|
|
* 4. Diagnostics produced for:
|
|
* - Patterns that don't match discovered skills (warning)
|
|
* - Requested names not matching any discovered skill (warning)
|
|
*/
|
|
export function resolveSessionSkills(context: SkillSelectionContext): SkillSelectionResult {
|
|
const { requestedSkillNames } = context;
|
|
|
|
// Resolve project root from the given projectRootDir — it may be a
|
|
// worktree path (e.g., /project/.worktrees/task-branch) which doesn't
|
|
// contain .fusion/settings.json. Walk up to find the real project root.
|
|
const projectRootDir = resolveProjectRoot(context.projectRootDir);
|
|
|
|
// Read project settings
|
|
const settings = readProjectSettings(projectRootDir);
|
|
|
|
// Collect all skill patterns from settings
|
|
const skillPatterns: string[] = [];
|
|
|
|
// Top-level skills patterns
|
|
if (settings.skills) {
|
|
for (const pattern of settings.skills) {
|
|
if (typeof pattern === "string") {
|
|
skillPatterns.push(pattern);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Package-scoped skill patterns
|
|
if (settings.packages) {
|
|
for (const pkg of settings.packages) {
|
|
if (typeof pkg === "object" && pkg !== null && "skills" in pkg && Array.isArray(pkg.skills)) {
|
|
for (const pattern of pkg.skills) {
|
|
if (typeof pattern === "string") {
|
|
skillPatterns.push(pattern);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const hasPatterns = skillPatterns.length > 0;
|
|
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
|
|
|
|
// If no patterns and no requested names, no filtering needed
|
|
if (!hasPatterns && !hasRequestedNames) {
|
|
return {
|
|
allowedSkillPaths: new Set<string>(),
|
|
excludedSkillPaths: new Set<string>(),
|
|
diagnostics: [],
|
|
filterActive: false,
|
|
};
|
|
}
|
|
|
|
// Build allowed and excluded sets from patterns
|
|
// Last entry wins for duplicate paths: we track the "final decision" per path
|
|
const finalDecisions = new Map<string, boolean>(); // true = allowed, false = excluded
|
|
|
|
for (const pattern of skillPatterns) {
|
|
const path = normalizePattern(pattern);
|
|
const isExclusion = isExclusionPattern(pattern);
|
|
finalDecisions.set(path, !isExclusion);
|
|
}
|
|
|
|
// Build allowed and excluded sets from final decisions
|
|
const allowedSet = new Set<string>();
|
|
const excludedSet = new Set<string>();
|
|
for (const [path, allowed] of finalDecisions) {
|
|
if (allowed) {
|
|
allowedSet.add(path);
|
|
} else {
|
|
excludedSet.add(path);
|
|
}
|
|
}
|
|
|
|
// Determine if filtering is active
|
|
// filterActive is true when:
|
|
// - Patterns exist (some skills are explicitly configured)
|
|
// - OR only requested names are provided (filter to those names)
|
|
const filterActive = hasPatterns || hasRequestedNames;
|
|
|
|
// Produce diagnostics for patterns (we can't check against actual discovered skills here,
|
|
// so we note which patterns are configured)
|
|
const diagnostics: SkillDiagnostic[] = [];
|
|
|
|
if (hasPatterns) {
|
|
for (const pattern of skillPatterns) {
|
|
if (!isExclusionPattern(pattern)) {
|
|
// Note: We don't have access to discovered skills here to check if pattern matches
|
|
// The actual validation happens in createSkillsOverrideFromSelection when base.skills is available
|
|
const path = normalizePattern(pattern);
|
|
diagnostics.push({
|
|
type: "info",
|
|
message: `Configured skill pattern: ${pattern}`,
|
|
skillPath: path,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasRequestedNames) {
|
|
for (const name of requestedSkillNames!) {
|
|
diagnostics.push({
|
|
type: "info",
|
|
message: `Requested skill: ${name}`,
|
|
skillName: name,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
allowedSkillPaths: allowedSet,
|
|
excludedSkillPaths: excludedSet,
|
|
diagnostics,
|
|
filterActive,
|
|
};
|
|
}
|
|
|
|
// ── Skills Override Factory ─────────────────────────────────────────────────
|
|
|
|
/*
|
|
FNXC:SkillResolution 2026-06-29-12:30:
|
|
Configured allow-list misses such as ce-optimize/SKILL.md are common when optional skills are absent from a checkout.
|
|
Keep the ResourceDiagnostic for programmatic visibility, but classify it separately so override application never mirrors this non-actionable info into piLog or console output.
|
|
*/
|
|
function isMissingConfiguredPatternDiagnostic(diag: ResourceDiagnostic): boolean {
|
|
const diagnosticType = diag.type as string;
|
|
return diagnosticType === "info"
|
|
&& diag.message.startsWith("Configured skill pattern '")
|
|
&& diag.message.includes("' not found in discovered skills");
|
|
}
|
|
|
|
/*
|
|
FNXC:EngineDiagnostics 2026-07-26-08:01:
|
|
"Requested skill: <name>" is a per-session listing diagnostic (not a miss). Emitting it at info filled the TUI with one line per skill on every session start. Keep the ResourceDiagnostic for programmatic consumers; mirror only to piLog.debug (FUSION_DEBUG=pi).
|
|
|
|
FNXC:EngineDiagnostics 2026-07-26-09:40:
|
|
Also demote `Requested skill '…' not found…` and every other type=info skill diagnostic to debug. Operators still see warnings/errors; the TUI no longer fills with `[skills] info: Requested skill…` on every session start.
|
|
*/
|
|
function isSkillInfoDiagnostic(diag: ResourceDiagnostic): boolean {
|
|
return (diag.type as string) === "info";
|
|
}
|
|
|
|
/**
|
|
* Options for skills override filtering.
|
|
* We track requested names here so we can validate against base.skills.
|
|
*/
|
|
export interface SkillsOverrideOptions {
|
|
/** Set of allowed skill paths */
|
|
allowedSkillPaths: Set<string>;
|
|
/** Set of explicitly excluded skill paths (from -patterns). If not provided, defaults to empty set. */
|
|
excludedSkillPaths?: Set<string>;
|
|
/** Whether filtering is active */
|
|
filterActive: boolean;
|
|
/** Requested skill names for diagnostic purposes */
|
|
requestedSkillNames?: string[];
|
|
/** Session purpose for log messages */
|
|
sessionPurpose?: string;
|
|
}
|
|
|
|
/**
|
|
* Create a skillsOverride callback compatible with DefaultResourceLoaderOptions.skillsOverride.
|
|
*
|
|
* @param selection - The skill selection result from resolveSessionSkills
|
|
* @param options - Additional options for the override
|
|
* @returns A skillsOverride callback for DefaultResourceLoader
|
|
*/
|
|
export function createSkillsOverrideFromSelection(
|
|
selection: SkillSelectionResult,
|
|
options: Omit<SkillsOverrideOptions, "allowedSkillPaths" | "filterActive"> = {},
|
|
): (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {
|
|
const { allowedSkillPaths, excludedSkillPaths, filterActive } = selection;
|
|
const { requestedSkillNames, sessionPurpose } = options;
|
|
|
|
const isBuiltInFallbackRequest = (name: string): boolean => {
|
|
const purposeUsesRoleFallback = ROLE_FALLBACK_SESSION_PURPOSES.has(sessionPurpose ?? "");
|
|
return purposeUsesRoleFallback
|
|
&& requestedSkillNames?.length === 1
|
|
&& name.toLowerCase() === "fusion";
|
|
};
|
|
|
|
return (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
|
|
// If filtering is not active, return base unchanged
|
|
if (!filterActive) {
|
|
return base;
|
|
}
|
|
|
|
// Determine the effective filter criteria
|
|
// When requestedSkillNames is provided without patterns, filter by name
|
|
// When patterns are provided, filter by file path
|
|
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
|
|
|
|
// A stale flat enable must not turn into an empty allow-list after a body
|
|
// moved to a category. Keep truly missing configured patterns intact so
|
|
// their existing missing-pattern diagnostic and filtering semantics remain.
|
|
const effectiveAllowedSkillPaths = new Set(
|
|
[...allowedSkillPaths].filter((pattern) => !base.skills.some(
|
|
(skill) => isLegacyFlatPatternForNestedSkill(skill, pattern),
|
|
)),
|
|
);
|
|
const hasPatterns = effectiveAllowedSkillPaths.size > 0;
|
|
|
|
// Filter skills
|
|
// Skills must match the inclusion criteria AND not be in the exclusion list
|
|
const hasExcluded = excludedSkillPaths.size > 0;
|
|
let filteredSkills: Skill[];
|
|
const isExcluded = (skill: Skill): boolean => {
|
|
for (const excludedPath of excludedSkillPaths) {
|
|
if (skillMatchesExecutionPattern(skill, excludedPath)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
const isAllowed = (skill: Skill): boolean => {
|
|
for (const allowedPath of effectiveAllowedSkillPaths) {
|
|
if (skillMatchesExecutionPattern(skill, allowedPath)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (hasRequestedNames) {
|
|
// Filter by requested names using the chat/dashboard bare-token convention only for requested-name matching.
|
|
const requestedMatchKeys = new Set(requestedSkillNames!.map(requestedSkillMatchKey));
|
|
filteredSkills = base.skills.filter(
|
|
(skill) => requestedMatchKeys.has(requestedSkillMatchKey(skill.name)) && !isExcluded(skill)
|
|
);
|
|
} else if (hasPatterns) {
|
|
// Filter by pattern (allowed AND not excluded)
|
|
filteredSkills = base.skills.filter(
|
|
(skill) => isAllowed(skill) && !isExcluded(skill)
|
|
);
|
|
} else if (hasExcluded) {
|
|
// Only exclusions set - filter out excluded skills
|
|
filteredSkills = base.skills.filter((skill) => !isExcluded(skill));
|
|
} else {
|
|
// No filter criteria - this shouldn't happen if filterActive is true
|
|
filteredSkills = base.skills;
|
|
}
|
|
|
|
// Build diagnostics for missing and disabled skills
|
|
const newDiagnostics: ResourceDiagnostic[] = [];
|
|
|
|
// Check for excluded paths that DO match a discovered skill (disabled)
|
|
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
|
|
const hasDiscoveredMatch = (pattern: string): boolean =>
|
|
base.skills.some((skill) => skillMatchesExecutionPattern(skill, pattern));
|
|
|
|
for (const excludedPath of excludedSkillPaths) {
|
|
if (hasDiscoveredMatch(excludedPath)) {
|
|
newDiagnostics.push({
|
|
type: "warning",
|
|
message: `Skill at '${excludedPath}' exists but is disabled by project execution settings${purpose}`,
|
|
path: excludedPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for configured patterns (allowed paths) that don't match any discovered skill
|
|
for (const allowedPath of effectiveAllowedSkillPaths) {
|
|
if (!hasDiscoveredMatch(allowedPath)) {
|
|
newDiagnostics.push({
|
|
type: "info" as ResourceDiagnostic["type"],
|
|
message: `Configured skill pattern '${allowedPath}' not found in discovered skills${purpose}`,
|
|
path: allowedPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for requested names that don't match any discovered skill
|
|
if (requestedSkillNames) {
|
|
const discoveredRequestedMatchKeys = new Set(base.skills.map((s) => requestedSkillMatchKey(s.name)));
|
|
for (const requestedName of requestedSkillNames) {
|
|
if (
|
|
!discoveredRequestedMatchKeys.has(requestedSkillMatchKey(requestedName))
|
|
&& !isBuiltInFallbackRequest(requestedName)
|
|
) {
|
|
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
|
|
newDiagnostics.push({
|
|
type: "info" as ResourceDiagnostic["type"],
|
|
message: `Requested skill '${requestedName}' not found in discovered skills${purpose}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log diagnostics if any
|
|
if (newDiagnostics.length > 0) {
|
|
const _purpose = sessionPurpose ? `[${sessionPurpose}]` : "skills";
|
|
for (const diag of newDiagnostics) {
|
|
if (isMissingConfiguredPatternDiagnostic(diag)) continue;
|
|
const msg = `[skills] ${diag.type}: ${diag.message}`;
|
|
if (diag.type === "error") piLog.error(msg);
|
|
else if (diag.type === "warning") piLog.warn(msg);
|
|
else if (isSkillInfoDiagnostic(diag)) piLog.debug(msg);
|
|
else piLog.log(msg);
|
|
}
|
|
}
|
|
|
|
return {
|
|
skills: filteredSkills,
|
|
diagnostics: [...base.diagnostics, ...newDiagnostics],
|
|
};
|
|
};
|
|
}
|
|
|