Skip to content

Commit 6e248cc

Browse files
chailandauGoosenpub1ph5a5cnj7f3rlw4054e0etpcvd9afzm8ly7gh6zh5nszg2wewmwsxcukkkChai Landau
authored
feat: support external material locators (#247)
* Support external material locators Allow annotated material references and approved connector schemes while preserving notes through pull, review, and export. Keep external access host-managed and report archive portability accurately. Co-authored-by: Goose <opensource@block.xyz> Ai-assisted: true * Name external locator provider, cover mixed arrays, de-duplicate scheme docs Address review gaps on the external material locator work: - Export audit names the provider (mcp/figma/github) instead of a flat "connection-dependent" label, at the markdown layer only; the export JSON access contract is unchanged. Adds externalLocatorScheme() as the single source for scheme extraction. - Add end-to-end coverage for a single node carrying a bare local path, a bare https: URL, and an annotated mcp: declaration together through pull, and widen export --strict to span mcp:/figma:/github:. - Collapse the verbatim scheme list from six doc surfaces to one canonical enumeration in references/schema.md; other docs defer to it. The code allowlist remains the source of truth. Co-authored-by: Chai Landau <chai@block.xyz> Signed-off-by: Chai Landau <chai@block.xyz> * Note externalLocatorScheme and provider-named audit in changeset The changeset described the two original public helpers but not externalLocatorScheme(), the third /core export added while naming the export audit provider. Extend the entry to cover both. Co-authored-by: Chai Landau <chai@block.xyz> Signed-off-by: Chai Landau <chai@block.xyz> --------- Signed-off-by: Chai Landau <chai@block.xyz> Co-authored-by: Goose <opensource@block.xyz> Co-authored-by: npub1ph5a5cnj7f3rlw4054e0etpcvd9afzm8ly7gh6zh5nszg2wewmwsxcukkk <0de9da6272f2623fbaafa572fcac38634bd48b67f93c8be857a4e02429d976dd@buzz.block.builderlab.xyz> Co-authored-by: Chai Landau <chai@block.xyz>
1 parent d8c5214 commit 6e248cc

29 files changed

Lines changed: 507 additions & 114 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@design-intelligence/ghost": minor
3+
---
4+
5+
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.

CLAUDE.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,12 @@ hierarchy, no inheritance, no edges; nesting into folders is a browsing
5858
convenience only.
5959

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

6668
While drafting a body, ask three questions of every node (drafting prompts,
6769
never fields): **why** (the stance), **with what** (the materials, and pointers

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,9 @@ Use the full lockup when recognition matters. Use the glyph only when space is
122122
constrained or when brand presence should recede.
123123
```
124124

125-
`materials` is a list of paths or URLs pointing at the concrete stuff the
126-
guidance is about: repo-relative paths/globs or absolute HTTPS URLs. Components,
125+
`materials` points at the concrete stuff the guidance is about through
126+
repo-relative paths/globs or supported external references. An opaque entry may
127+
include a short note describing what the agent will find there. Components,
127128
patterns, logos, motion files, illustrations, and external asset libraries all
128129
use the same field. Guidance stays in prose; `materials` only says where the
129130
material is.

docs/purposes.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ into folders is a browsing convenience only.
3838
| `manifest.yml` | Schema version and package id; the package's anchor. |
3939
| `glossary.md` | The author's dictionary: every term with defined meaning in the corpus. ghost ships no fixed vocabulary. |
4040
| 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. |
41-
| Node frontmatter | `description` (retrieval payload) and optional `materials` (repo-relative paths/globs or HTTPS URLs for concrete materials the prose governs). |
41+
| 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). |
4242
| `checks/` | Optional review assertions binding to nodes with `references`. Never a node source and never generation input. |
4343

4444
One resolution mechanism, read-only:
@@ -87,7 +87,8 @@ Two rules keep the reservation honest:
8787

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

9293
4. **Checks becoming generation input.** Checks are feedback assertions. If they
9394
appear in `gather`, the model starts writing to the test and the review

packages/ghost/src/commands/export-command.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { dirname, resolve } from "node:path";
33
import type { CAC } from "cac";
44
import {
55
classifyMaterialLocator,
6+
externalLocatorScheme,
67
type GhostCatalogNode,
8+
materialLocator,
79
resolveLocalMaterialLocator,
810
type TransportedMaterialTier,
911
} from "#ghost-core";
@@ -26,6 +28,7 @@ interface ExportAuditTravelingLocator {
2628
nodeId: string;
2729
locator: string;
2830
tier: Extract<TransportedMaterialTier, "bundled" | "url">;
31+
access?: "https" | "connector";
2932
}
3033

3134
interface ExportAuditStrandedLocator {
@@ -161,10 +164,16 @@ function auditNodeMaterials(
161164
travels: ExportAuditTravelingLocator[],
162165
stranded: ExportAuditStrandedLocator[],
163166
): void {
164-
for (const locator of node.materials ?? []) {
167+
for (const material of node.materials ?? []) {
168+
const locator = materialLocator(material);
165169
const classified = classifyMaterialLocator(locator);
166170
if (classified.kind === "url") {
167-
travels.push({ nodeId: node.id, locator, tier: "url" });
171+
travels.push({
172+
nodeId: node.id,
173+
locator,
174+
tier: "url",
175+
access: classified.access,
176+
});
168177
continue;
169178
}
170179

@@ -213,8 +222,22 @@ function formatExportMarkdown(fields: {
213222
if (fields.audit.travels.length > 0) {
214223
lines.push("Travels with the archive:", "");
215224
for (const item of fields.audit.travels) {
216-
const label = item.tier === "url" ? "HTTPS URL" : "bundled material";
217-
lines.push(`- \`${item.nodeId}\` — \`${item.locator}\` (${label})`);
225+
if (item.access === "connector") {
226+
const provider = externalLocatorScheme(item.locator) ?? "connector";
227+
lines.push(
228+
`- \`${item.nodeId}\` — \`${item.locator}\` (${provider} external locator only)`,
229+
` - The locator travels; the recipient may need a ${provider} connection or permission to access the material.`,
230+
);
231+
} else if (item.access === "https") {
232+
lines.push(
233+
`- \`${item.nodeId}\` — \`${item.locator}\` (HTTPS external locator only)`,
234+
" - The locator travels; access depends on the URL and any permissions it requires.",
235+
);
236+
} else {
237+
lines.push(
238+
`- \`${item.nodeId}\` — \`${item.locator}\` (bundled material)`,
239+
);
240+
}
218241
}
219242
} else {
220243
lines.push("Travels with the archive: none.");

packages/ghost/src/commands/pull-command.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -160,21 +160,25 @@ function appendMaterialMarkdown(
160160
): void {
161161
if (material.inlined !== undefined) {
162162
const info = material.path ?? material.locator;
163-
lines.push("", fencedMarkdown(material.inlined.trimEnd(), info));
164-
} else if (material.reason === "binary inspect-pointer") {
165-
lines.push(
166-
`- inspect: ${material.path ?? material.locator} — view this image before generating`,
167-
);
168-
} else {
169-
const reason = material.omitted
170-
? ` — ${material.reason ?? "not inlined"}`
171-
: "";
172-
lines.push(`- ${material.locator}${reason}`);
163+
lines.push("");
164+
if (material.note !== undefined) {
165+
lines.push(`Note for \`${material.locator}\`: ${material.note}`, "");
166+
}
167+
lines.push(fencedMarkdown(material.inlined.trimEnd(), info));
168+
return;
173169
}
170+
171+
const target =
172+
material.reason === "binary inspect-pointer"
173+
? `inspect: ${material.path ?? material.locator} — view this image before generating`
174+
: `${material.locator}${material.omitted ? ` — ${material.reason ?? "not inlined"}` : ""}`;
175+
lines.push(`- ${target}`);
176+
if (material.note !== undefined) lines.push(` Note: ${material.note}`);
174177
}
175178

176179
function formatJsonMaterial(material: TransportedMaterial): {
177180
locator: string;
181+
note?: string;
178182
tier: TransportedMaterial["tier"];
179183
inlined?: string;
180184
omitted?: true;
@@ -183,6 +187,7 @@ function formatJsonMaterial(material: TransportedMaterial): {
183187
} {
184188
return {
185189
locator: material.locator,
190+
...(material.note !== undefined ? { note: material.note } : {}),
186191
tier: material.tier,
187192
...(material.inlined !== undefined ? { inlined: material.inlined } : {}),
188193
...(material.omitted

packages/ghost/src/embed/inspect.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { TextDecoder } from "node:util";
33
import {
44
classifyMaterialLocator,
55
expandLocalMaterialLocator,
6+
type GhostMaterial,
67
hasGlobMagic,
78
inferMaterialMime,
89
isTextMime,
10+
materialLocator,
911
materialLocatorClaimsPath,
1012
resolveContainedRealFile,
1113
resolveLocalMaterialLocator,
@@ -205,20 +207,21 @@ export async function inspectGhostMaterial(
205207
}
206208

207209
function declaredMaterialLocator(
208-
declared: readonly string[],
210+
declared: readonly GhostMaterial[],
209211
requested: string,
210212
repoRoot: string,
211213
packageDir: string,
212214
): string | undefined {
213-
if (declared.includes(requested)) return requested;
215+
const locators = declared.map(materialLocator);
216+
if (locators.includes(requested)) return requested;
214217
const requestedKind = classifyMaterialLocator(requested);
215218
if (requestedKind.kind !== "local") return undefined;
216219
const requestedPath = resolveLocalMaterialLocator(requested, {
217220
repoRoot,
218221
packageDir,
219222
materialsDir: GHOST_MATERIALS_DIR,
220223
}).pattern;
221-
return declared.find(
224+
return locators.find(
222225
(locator) =>
223226
hasGlobMagic(locator) &&
224227
materialLocatorClaimsPath(locator, requestedPath, {

packages/ghost/src/embed/pull.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {
33
closestIds,
44
extractSkeletonFences,
55
type GhostCatalogNode,
6+
type GhostMaterial,
67
type MaterialTransportResult,
8+
normalizeMaterial,
79
resolveLocalMaterialLocator,
810
stripSkeletonSections,
911
transportMaterials,
@@ -122,22 +124,26 @@ function steeringBucket(
122124
}
123125

124126
function locatorOnlyMaterials(
125-
locators: readonly string[] | undefined,
127+
declarations: readonly GhostMaterial[] | undefined,
126128
repoRoot: string,
127129
packageDir: string,
128130
): MaterialTransportResult {
129131
return {
130-
materials: (locators ?? []).map((locator) => ({
131-
locator,
132-
tier:
133-
classifyMaterialLocator(locator).kind === "url"
134-
? "url"
135-
: resolveLocalMaterialLocator(locator, {
136-
repoRoot,
137-
packageDir,
138-
materialsDir: GHOST_MATERIALS_DIR,
139-
}).tier,
140-
})),
132+
materials: (declarations ?? []).map((declaration) => {
133+
const { locator, note } = normalizeMaterial(declaration);
134+
return {
135+
locator,
136+
...(note !== undefined ? { note } : {}),
137+
tier:
138+
classifyMaterialLocator(locator).kind === "url"
139+
? "url"
140+
: resolveLocalMaterialLocator(locator, {
141+
repoRoot,
142+
packageDir,
143+
materialsDir: GHOST_MATERIALS_DIR,
144+
}).tier,
145+
};
146+
}),
141147
inlined: 0,
142148
omitted: 0,
143149
};

packages/ghost/src/embed/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
CatalogMenuEntry,
33
GhostCatalogNode,
44
GhostGlossaryKind,
5+
GhostMaterial,
56
GhostPackageManifest,
67
TransportedMaterial,
78
} from "#ghost-core";
@@ -108,7 +109,7 @@ export interface GhostPulledNode {
108109
id: string;
109110
kind?: string;
110111
description?: string;
111-
declaredMaterials?: readonly string[];
112+
declaredMaterials?: readonly GhostMaterial[];
112113
materials?: readonly TransportedMaterial[];
113114
body: string;
114115
}

packages/ghost/src/ghost-core/catalog/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { GhostMaterial } from "../materials.js";
2+
13
/**
24
* A node in the fingerprint catalog — pure prose plus its filename-derived
35
* identity. The body is the design expression; there are no structured content
@@ -12,8 +14,8 @@ export interface GhostCatalogNode {
1214
slug: string;
1315
/** Retrieval payload shown in gather: what applies, when, and what it contributes. */
1416
description?: string;
15-
/** Optional material locators carried by the authored node. */
16-
materials?: string[];
17+
/** Optional bare or annotated material locators carried by the authored node. */
18+
materials?: GhostMaterial[];
1719
/** True when the node carries a material locator, substantial fence, or Skeleton. */
1820
concrete: boolean;
1921
/** True when the node body carries a fenced block of at least 3 lines. */

0 commit comments

Comments
 (0)