fix(consult): read the live skill tree instead of a hash-pinned snapshot
Manual SKILL.md updates were blocked by registry sha256 and a byte-equal versions/ gate. Consult now loads the operator-maintained tree; rectification and personal-report stay hashed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
consultationDomainIds,
|
||||
type ConsultationDomain,
|
||||
} from "./consultation-domain-registry.ts";
|
||||
import { resolveActiveSkillPackage } from "./skill-package-registry.ts";
|
||||
import { resolveLiveJyotishSkill } from "./skill-package-registry.ts";
|
||||
|
||||
/**
|
||||
* The strict checklist each consultation domain must be read against, named in
|
||||
@@ -66,9 +66,9 @@ export const consultationMethodologySchema = z.object({
|
||||
|
||||
export type ConsultationMethodology = z.infer<typeof consultationMethodologySchema>;
|
||||
|
||||
const skillPackage = resolveActiveSkillPackage("jyotish-vedic-astrology");
|
||||
const skill = resolveLiveJyotishSkill();
|
||||
|
||||
/** A published version's files never change, so each one is read from disk once per process. */
|
||||
/** Live skill files are reread only after process restart; consult requests share one cache. */
|
||||
const fileCache = new Map<string, string | null>();
|
||||
|
||||
function packageFile(relativePath: string): string | null {
|
||||
@@ -76,7 +76,7 @@ function packageFile(relativePath: string): string | null {
|
||||
if (hit !== undefined) return hit;
|
||||
let content: string | null = null;
|
||||
try {
|
||||
content = readFileSync(resolve(skillPackage.resolvedPath, relativePath), "utf8");
|
||||
content = readFileSync(resolve(skill.resolvedPath, relativePath), "utf8");
|
||||
} catch {
|
||||
content = null;
|
||||
}
|
||||
@@ -127,11 +127,7 @@ function namedReferences(instructions: string): string[] {
|
||||
const planCache = new Map<string, ConsultationMethodology>();
|
||||
|
||||
/**
|
||||
* The strict method for one domain plan, read from the hash-pinned package.
|
||||
*
|
||||
* Reading the pinned version rather than the live skill directory means the
|
||||
* method that shaped an answer is the method a published version states, and
|
||||
* the answer stays reproducible from the registry entry alone.
|
||||
* The strict method for one domain plan, read from the live skill tree.
|
||||
*/
|
||||
export function consultationMethodologyForDomains(
|
||||
domains: readonly ConsultationDomain[],
|
||||
@@ -183,8 +179,8 @@ export function consultationMethodologyForDomains(
|
||||
}
|
||||
|
||||
const methodology = consultationMethodologySchema.parse({
|
||||
skill: skillPackage.name,
|
||||
version: skillPackage.version,
|
||||
skill: skill.name,
|
||||
version: skill.version,
|
||||
domains_without_strict_checklist: withoutChecklist,
|
||||
sections,
|
||||
further_reading: namedReferences(instructions),
|
||||
|
||||
@@ -589,12 +589,179 @@ export class SkillPackageRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
export const LIVE_JYOTISH_SKILL_NAME = "jyotish-vedic-astrology";
|
||||
|
||||
export type LiveJyotishSkill = {
|
||||
name: typeof LIVE_JYOTISH_SKILL_NAME;
|
||||
version: string;
|
||||
resolvedPath: string;
|
||||
};
|
||||
|
||||
function parseSkillFrontmatter(raw: string): { name?: string; version?: string } {
|
||||
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
|
||||
if (!block) return {};
|
||||
const name = /^name:\s*["']?([a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)["']?\s*$/im.exec(block[1]);
|
||||
const version = /^version:\s*["']?([^\s"']+)["']?\s*$/im.exec(block[1]);
|
||||
return {
|
||||
...(name ? { name: name[1] } : {}),
|
||||
...(version ? { version: version[1] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSkillProjectRoot(options: SkillPackageRegistryOptions = {}): string {
|
||||
const requestedProjectRoot = resolve(
|
||||
options.projectRoot ?? process.env.JYOTISHA_PROJECT_ROOT ?? SOURCE_TREE_PROJECT_ROOT,
|
||||
);
|
||||
const projectRoot = resolveExistingPath(
|
||||
requestedProjectRoot,
|
||||
"Skill registry project root",
|
||||
);
|
||||
assertDirectory(projectRoot, "Skill registry project root");
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
const LIVE_SKILL_RUNTIME_ENTRIES = [
|
||||
"SKILL.md",
|
||||
"references",
|
||||
"scripts",
|
||||
"assets",
|
||||
] as const;
|
||||
|
||||
export type LiveJyotishSkillOptions = SkillPackageRegistryOptions & {
|
||||
/** Overrides `JYOTISH_SKILL_PATH` when tests or callers pass an explicit tree. */
|
||||
skillPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The natal consult skill is the live tree the operator updates by hand.
|
||||
* It is not a hashed registry package: copy SKILL.md / references / scripts
|
||||
* and restart, without bumping a version or recomputing sha256.
|
||||
*/
|
||||
export function resolveLiveJyotishSkill(
|
||||
options: LiveJyotishSkillOptions = {},
|
||||
): LiveJyotishSkill {
|
||||
const projectRoot = resolveSkillProjectRoot(options);
|
||||
const configured = options.skillPath?.trim() || process.env.JYOTISH_SKILL_PATH?.trim();
|
||||
const requested = configured
|
||||
? (isAbsolute(configured) ? resolve(configured) : resolve(projectRoot, configured))
|
||||
: resolve(projectRoot, "skills", LIVE_JYOTISH_SKILL_NAME);
|
||||
|
||||
if (!isInside(projectRoot, requested)) {
|
||||
let realRequested: string;
|
||||
try {
|
||||
realRequested = realpathSync(requested);
|
||||
} catch {
|
||||
fail("Live Jyotish skill path escapes the project root");
|
||||
}
|
||||
if (!isInside(projectRoot, realRequested)) {
|
||||
fail("Live Jyotish skill path escapes the project root");
|
||||
}
|
||||
}
|
||||
const resolvedPath = resolveExistingPath(requested, "Live Jyotish skill directory");
|
||||
if (!isInside(projectRoot, resolvedPath)) {
|
||||
fail("Live Jyotish skill directory resolves outside the project root");
|
||||
}
|
||||
assertDirectory(resolvedPath, "Live Jyotish skill directory");
|
||||
if (basename(resolvedPath) !== LIVE_JYOTISH_SKILL_NAME) {
|
||||
fail(`Live Jyotish skill directory must be named ${LIVE_JYOTISH_SKILL_NAME}`);
|
||||
}
|
||||
|
||||
const skillPath = resolve(resolvedPath, "SKILL.md");
|
||||
const skillFile = resolveExistingPath(skillPath, `SKILL.md for ${LIVE_JYOTISH_SKILL_NAME}`);
|
||||
if (!isInside(projectRoot, skillFile)) {
|
||||
fail(`SKILL.md for ${LIVE_JYOTISH_SKILL_NAME} resolves outside the project root`);
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(skillFile, "utf8");
|
||||
} catch (error) {
|
||||
fail(
|
||||
`SKILL.md for ${LIVE_JYOTISH_SKILL_NAME} cannot be read: ${
|
||||
error instanceof Error ? error.message : "unknown filesystem error"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const frontmatter = parseSkillFrontmatter(raw);
|
||||
if (frontmatter.name && frontmatter.name !== LIVE_JYOTISH_SKILL_NAME) {
|
||||
fail(
|
||||
`Live Jyotish skill frontmatter name must be ${LIVE_JYOTISH_SKILL_NAME}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const name of LIVE_SKILL_RUNTIME_ENTRIES) {
|
||||
const entry = resolve(resolvedPath, name);
|
||||
if (!existsSync(entry)) continue;
|
||||
const real = resolveExistingPath(entry, `Live Jyotish skill ${name}`);
|
||||
if (!isInside(projectRoot, real)) {
|
||||
fail(`Live Jyotish skill ${name} resolves outside the project root`);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
name: LIVE_JYOTISH_SKILL_NAME,
|
||||
version: frontmatter.version || "live",
|
||||
resolvedPath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mastra-compatible view of the live consult skill.
|
||||
*
|
||||
* The git-tracked live directory also contains leftover hashed
|
||||
* `versions/` snapshots. Those must not be on the loader path: Mastra walks
|
||||
* every real subdirectory, and the operator's updates live in the four
|
||||
* top-level entries (often git-tracked symlinks into the repo root).
|
||||
*/
|
||||
export function resolveLiveJyotishSkillRuntimePath(
|
||||
skill: LiveJyotishSkill,
|
||||
): string {
|
||||
const token = createHash("sha256").update(skill.resolvedPath).digest("hex").slice(0, 12);
|
||||
const runtimeParent = join(RUNTIME_SKILL_ROOT, "live", token);
|
||||
mkdirSync(runtimeParent, { recursive: true });
|
||||
const runtimePath = join(runtimeParent, skill.name);
|
||||
if (basename(runtimePath) !== skill.name) {
|
||||
fail(`Live runtime skill path must end with ${skill.name}`);
|
||||
}
|
||||
mkdirSync(runtimePath, { recursive: true });
|
||||
|
||||
for (const name of LIVE_SKILL_RUNTIME_ENTRIES) {
|
||||
const source = resolve(skill.resolvedPath, name);
|
||||
if (!existsSync(source)) continue;
|
||||
const target = resolveExistingPath(source, `Live Jyotish skill ${name}`);
|
||||
const dest = join(runtimePath, name);
|
||||
try {
|
||||
const existing = lstatSync(dest);
|
||||
if (!existing.isSymbolicLink()) {
|
||||
fail(`Live runtime entry ${name} is not a symbolic-link alias`);
|
||||
}
|
||||
const linked = resolveExistingPath(dest, `Live runtime alias for ${name}`);
|
||||
if (linked !== target) {
|
||||
fail(`Live runtime entry ${name} points at an unexpected path`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SkillPackageRegistryError) throw error;
|
||||
try {
|
||||
const linkType = statSync(target).isDirectory() ? "dir" : "file";
|
||||
symlinkSync(target, dest, linkType);
|
||||
} catch (linkError) {
|
||||
fail(
|
||||
`Live runtime alias for ${name} could not be created: ${
|
||||
linkError instanceof Error ? linkError.message : "unknown filesystem error"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return runtimePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a Mastra-compatible read-only alias for a verified package.
|
||||
*
|
||||
* Mastra validates that the configured skill directory basename matches the
|
||||
* frontmatter name. Versioned registry packages intentionally live under a
|
||||
* numeric directory (for example, `versions/6.9.14`), so loading that
|
||||
* numeric directory (for example, `versions/10.0.2`), so loading that
|
||||
* directory directly fails validation. The alias keeps the verified package
|
||||
* bytes and identity unchanged while exposing the canonical skill name at the
|
||||
* loader boundary.
|
||||
|
||||
Reference in New Issue
Block a user