Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-panthers-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@design-intelligence/ghost": minor
---

Accept `https:`, `mcp:`, `figma:`, and `github:` external material locators and annotated `{ locator, note }` declarations. This widens public material arrays from `string[]` to `GhostMaterial[]`; TypeScript consumers of `/core` and `/embed` can use `materialLocator()` or `normalizeMaterial()` to read either shape, and `externalLocatorScheme()` to read an external locator's scheme. Pull JSON now includes optional `note`, export audit JSON includes optional `access`, the export audit names the external provider (for example `mcp`, `figma`, or `github`), and external-material omission messages use the new locator terminology.
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ hierarchy, no inheritance, no edges; nesting into folders is a browsing
convenience only.

`materials` is the single locator field for concrete materials the guidance is
about. It accepts repo-relative paths/globs and absolute HTTPS URLs. Components,
patterns, logos, motion files, illustrations, and external asset libraries all
use the same field. Guidance stays in prose; `materials` only says where the
material is.
about. It accepts repo-relative paths/globs and supported external locators as
bare strings or `{ locator, note }` objects. Components, patterns, logos, motion
files, illustrations, and external asset libraries all use the same field.
Guidance stays in prose; `materials` only says where the material is. See
`packages/ghost/src/skill-bundle/references/schema.md` for the supported
external locator schemes.

While drafting a body, ask three questions of every node (drafting prompts,
never fields): **why** (the stance), **with what** (the materials, and pointers
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,9 @@ Use the full lockup when recognition matters. Use the glyph only when space is
constrained or when brand presence should recede.
```

`materials` is a list of paths or URLs pointing at the concrete stuff the
guidance is about: repo-relative paths/globs or absolute HTTPS URLs. Components,
`materials` points at the concrete stuff the guidance is about through
repo-relative paths/globs or supported external references. An opaque entry may
include a short note describing what the agent will find there. Components,
patterns, logos, motion files, illustrations, and external asset libraries all
use the same field. Guidance stays in prose; `materials` only says where the
material is.
Expand Down
5 changes: 3 additions & 2 deletions docs/purposes.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ into folders is a browsing convenience only.
| `manifest.yml` | Schema version and package id; the package's anchor. |
| `glossary.md` | The author's dictionary: every term with defined meaning in the corpus. ghost ships no fixed vocabulary. |
| Prose nodes (`<kind>.<slug>.md`, `<slug>.md`) | Durable brand guidance; each body answers why (the stance), with what (the materials), or how it is assembled (the patterns). Altitude lives in prose; narrower guidance names its condition. |
| Node frontmatter | `description` (retrieval payload) and optional `materials` (repo-relative paths/globs or HTTPS URLs for concrete materials the prose governs). |
| Node frontmatter | `description` (retrieval payload) and optional `materials` (repo-relative paths/globs or supported external locators, with optional retrieval notes; see the schema reference for the supported schemes). |
| `checks/` | Optional review assertions binding to nodes with `references`. Never a node source and never generation input. |

One resolution mechanism, read-only:
Expand Down Expand Up @@ -87,7 +87,8 @@ Two rules keep the reservation honest:

3. **Guidance smuggled into `materials`.** A material locator list that starts
carrying roles, rules, or semantic metadata becomes a second schema.
*Fix: keep `materials` as strings only; write meaning in the node body.*
*Fix: allow a short retrieval note when a locator is opaque, but keep roles,
rules, and meaning in the node body.*

4. **Checks becoming generation input.** Checks are feedback assertions. If they
appear in `gather`, the model starts writing to the test and the review
Expand Down
31 changes: 27 additions & 4 deletions packages/ghost/src/commands/export-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { dirname, resolve } from "node:path";
import type { CAC } from "cac";
import {
classifyMaterialLocator,
externalLocatorScheme,
type GhostCatalogNode,
materialLocator,
resolveLocalMaterialLocator,
type TransportedMaterialTier,
} from "#ghost-core";
Expand All @@ -26,6 +28,7 @@ interface ExportAuditTravelingLocator {
nodeId: string;
locator: string;
tier: Extract<TransportedMaterialTier, "bundled" | "url">;
access?: "https" | "connector";
}

interface ExportAuditStrandedLocator {
Expand Down Expand Up @@ -161,10 +164,16 @@ function auditNodeMaterials(
travels: ExportAuditTravelingLocator[],
stranded: ExportAuditStrandedLocator[],
): void {
for (const locator of node.materials ?? []) {
for (const material of node.materials ?? []) {
const locator = materialLocator(material);
const classified = classifyMaterialLocator(locator);
if (classified.kind === "url") {
travels.push({ nodeId: node.id, locator, tier: "url" });
travels.push({
nodeId: node.id,
locator,
tier: "url",
access: classified.access,
});
continue;
}

Expand Down Expand Up @@ -213,8 +222,22 @@ function formatExportMarkdown(fields: {
if (fields.audit.travels.length > 0) {
lines.push("Travels with the archive:", "");
for (const item of fields.audit.travels) {
const label = item.tier === "url" ? "HTTPS URL" : "bundled material";
lines.push(`- \`${item.nodeId}\` — \`${item.locator}\` (${label})`);
if (item.access === "connector") {
const provider = externalLocatorScheme(item.locator) ?? "connector";
lines.push(
`- \`${item.nodeId}\` — \`${item.locator}\` (${provider} external locator only)`,
` - The locator travels; the recipient may need a ${provider} connection or permission to access the material.`,
);
} else if (item.access === "https") {
lines.push(
`- \`${item.nodeId}\` — \`${item.locator}\` (HTTPS external locator only)`,
" - The locator travels; access depends on the URL and any permissions it requires.",
);
} else {
lines.push(
`- \`${item.nodeId}\` — \`${item.locator}\` (bundled material)`,
);
}
}
} else {
lines.push("Travels with the archive: none.");
Expand Down
25 changes: 15 additions & 10 deletions packages/ghost/src/commands/pull-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,21 +160,25 @@ function appendMaterialMarkdown(
): void {
if (material.inlined !== undefined) {
const info = material.path ?? material.locator;
lines.push("", fencedMarkdown(material.inlined.trimEnd(), info));
} else if (material.reason === "binary inspect-pointer") {
lines.push(
`- inspect: ${material.path ?? material.locator} — view this image before generating`,
);
} else {
const reason = material.omitted
? ` — ${material.reason ?? "not inlined"}`
: "";
lines.push(`- ${material.locator}${reason}`);
lines.push("");
if (material.note !== undefined) {
lines.push(`Note for \`${material.locator}\`: ${material.note}`, "");
}
lines.push(fencedMarkdown(material.inlined.trimEnd(), info));
return;
}

const target =
material.reason === "binary inspect-pointer"
? `inspect: ${material.path ?? material.locator} — view this image before generating`
: `${material.locator}${material.omitted ? ` — ${material.reason ?? "not inlined"}` : ""}`;
lines.push(`- ${target}`);
if (material.note !== undefined) lines.push(` Note: ${material.note}`);
}

function formatJsonMaterial(material: TransportedMaterial): {
locator: string;
note?: string;
tier: TransportedMaterial["tier"];
inlined?: string;
omitted?: true;
Expand All @@ -183,6 +187,7 @@ function formatJsonMaterial(material: TransportedMaterial): {
} {
return {
locator: material.locator,
...(material.note !== undefined ? { note: material.note } : {}),
tier: material.tier,
...(material.inlined !== undefined ? { inlined: material.inlined } : {}),
...(material.omitted
Expand Down
9 changes: 6 additions & 3 deletions packages/ghost/src/embed/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { TextDecoder } from "node:util";
import {
classifyMaterialLocator,
expandLocalMaterialLocator,
type GhostMaterial,
hasGlobMagic,
inferMaterialMime,
isTextMime,
materialLocator,
materialLocatorClaimsPath,
resolveContainedRealFile,
resolveLocalMaterialLocator,
Expand Down Expand Up @@ -205,20 +207,21 @@ export async function inspectGhostMaterial(
}

function declaredMaterialLocator(
declared: readonly string[],
declared: readonly GhostMaterial[],
requested: string,
repoRoot: string,
packageDir: string,
): string | undefined {
if (declared.includes(requested)) return requested;
const locators = declared.map(materialLocator);
if (locators.includes(requested)) return requested;
const requestedKind = classifyMaterialLocator(requested);
if (requestedKind.kind !== "local") return undefined;
const requestedPath = resolveLocalMaterialLocator(requested, {
repoRoot,
packageDir,
materialsDir: GHOST_MATERIALS_DIR,
}).pattern;
return declared.find(
return locators.find(
(locator) =>
hasGlobMagic(locator) &&
materialLocatorClaimsPath(locator, requestedPath, {
Expand Down
30 changes: 18 additions & 12 deletions packages/ghost/src/embed/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
closestIds,
extractSkeletonFences,
type GhostCatalogNode,
type GhostMaterial,
type MaterialTransportResult,
normalizeMaterial,
resolveLocalMaterialLocator,
stripSkeletonSections,
transportMaterials,
Expand Down Expand Up @@ -122,22 +124,26 @@ function steeringBucket(
}

function locatorOnlyMaterials(
locators: readonly string[] | undefined,
declarations: readonly GhostMaterial[] | undefined,
repoRoot: string,
packageDir: string,
): MaterialTransportResult {
return {
materials: (locators ?? []).map((locator) => ({
locator,
tier:
classifyMaterialLocator(locator).kind === "url"
? "url"
: resolveLocalMaterialLocator(locator, {
repoRoot,
packageDir,
materialsDir: GHOST_MATERIALS_DIR,
}).tier,
})),
materials: (declarations ?? []).map((declaration) => {
const { locator, note } = normalizeMaterial(declaration);
return {
locator,
...(note !== undefined ? { note } : {}),
tier:
classifyMaterialLocator(locator).kind === "url"
? "url"
: resolveLocalMaterialLocator(locator, {
repoRoot,
packageDir,
materialsDir: GHOST_MATERIALS_DIR,
}).tier,
};
}),
inlined: 0,
omitted: 0,
};
Expand Down
3 changes: 2 additions & 1 deletion packages/ghost/src/embed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
CatalogMenuEntry,
GhostCatalogNode,
GhostGlossaryKind,
GhostMaterial,
GhostPackageManifest,
TransportedMaterial,
} from "#ghost-core";
Expand Down Expand Up @@ -108,7 +109,7 @@ export interface GhostPulledNode {
id: string;
kind?: string;
description?: string;
declaredMaterials?: readonly string[];
declaredMaterials?: readonly GhostMaterial[];
materials?: readonly TransportedMaterial[];
body: string;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/ghost/src/ghost-core/catalog/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { GhostMaterial } from "../materials.js";

/**
* A node in the fingerprint catalog — pure prose plus its filename-derived
* identity. The body is the design expression; there are no structured content
Expand All @@ -12,8 +14,8 @@ export interface GhostCatalogNode {
slug: string;
/** Retrieval payload shown in gather: what applies, when, and what it contributes. */
description?: string;
/** Optional material locators carried by the authored node. */
materials?: string[];
/** Optional bare or annotated material locators carried by the authored node. */
materials?: GhostMaterial[];
/** True when the node carries a material locator, substantial fence, or Skeleton. */
concrete: boolean;
/** True when the node body carries a fenced block of at least 3 lines. */
Expand Down
5 changes: 5 additions & 0 deletions packages/ghost/src/ghost-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ export {
export {
type ClassifiedGhostMaterialLocator,
classifyMaterialLocator,
externalLocatorScheme,
type GhostAnnotatedMaterial,
type GhostMaterial,
type GhostMaterialLocatorKind,
materialLocator,
normalizeMaterial,
validateMaterialLocator,
} from "./materials.js";
// --- Node (ghost.node/v1) — the markdown node artifact ---
Expand Down
20 changes: 16 additions & 4 deletions packages/ghost/src/ghost-core/material-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { isAbsolute, join, relative, resolve } from "node:path";
import { TextDecoder } from "node:util";
import { hasGlobMagic, matchesGlob, normalizeGlobPath } from "./glob.js";
import { classifyMaterialLocator } from "./materials.js";
import {
classifyMaterialLocator,
type GhostMaterial,
normalizeMaterial,
} from "./materials.js";

export type TransportedMaterialTier = "bundled" | "referenced" | "url";

export interface TransportedMaterial {
locator: string;
note?: string;
tier: TransportedMaterialTier;
/** Repo-relative concrete file path, when the locator resolved to a file. */
path?: string;
Expand Down Expand Up @@ -49,21 +54,25 @@ const DEFAULT_REFERENCED_INLINE_BYTES = 8 * 1024;
const textDecoder = new TextDecoder("utf-8", { fatal: true });

export async function transportMaterials(
locators: string[] | undefined,
declarations: GhostMaterial[] | undefined,
options: MaterialTransportOptions,
): Promise<MaterialTransportResult> {
const materials: TransportedMaterial[] = [];
let inlined = 0;
let omitted = 0;

for (const locator of locators ?? []) {
for (const declaration of declarations ?? []) {
const { locator, note } = normalizeMaterial(declaration);
const annotation = note === undefined ? {} : { note };
const classified = classifyMaterialLocator(locator);
if (classified.kind === "url") {
materials.push({
locator,
...annotation,
tier: "url",
omitted: true,
reason: "HTTPS URL; fetch it only if the task requires it",
reason:
"external locator; use an available host connection if the task requires it",
});
omitted += 1;
continue;
Expand All @@ -76,6 +85,7 @@ export async function transportMaterials(
if (expanded.matches.length === 0) {
materials.push({
locator,
...annotation,
tier: expanded.tier,
omitted: true,
reason: "matched no local files",
Expand All @@ -91,6 +101,7 @@ export async function transportMaterials(
expanded.tier,
options,
);
if (note !== undefined) transported.note = note;
materials.push(transported);
if (transported.inlined !== undefined) inlined += 1;
if (transported.omitted) omitted += 1;
Expand All @@ -99,6 +110,7 @@ export async function transportMaterials(
if (expanded.truncated) {
materials.push({
locator,
...annotation,
tier: expanded.tier,
omitted: true,
reason: `glob matched more than ${options.globCap ?? DEFAULT_GLOB_CAP} files; omitted the rest`,
Expand Down
Loading
Loading