Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .changeset/gather-vocab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@anarchitecture/ghost": major
---

Rename the `gather` slice vocabulary to plain language. The JSON `slice.spokes`
field is now `slice.pointers`, and the pointer `kind` `"edge-hub"` is now
`"related"` with its origin in a `from` field (was `hub`). Docs and the skill
bundle drop the spine/corridor/hub-and-spoke metaphors; `edge` provenance is
unchanged.
6 changes: 3 additions & 3 deletions apps/docs/src/content/docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ A slice composes three things:
surface's own folder. A sibling folder's nodes never appear.
- **edges** (full bodies, one hop): the `relates` targets of every node on that
path, so a rule authored once high in the tree reaches every descendant. A
link to a node also offers that node's subtree as spokes.
- **spokes** (pointers): the surface's own descendants and the subtree of any
node it relates to, offered as `id` + `description` for the agent to pull on
link to a node also offers that node's subtree as pointers.
- **pointers**: the surface's own descendants and the subtree of any node it
relates to, offered as `id` + `description` for the agent to pull on
demand.

Use `--as` to filter full-body nodes to a single incarnation; untagged essence
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/content/docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ ghost gather marketing --format json
```

`gather` returns full bodies for every node from the root down to the surface,
the **edges** reachable in one hop from any of their `relates`, and **spokes**,
pointers to nearby nodes the agent can pull on demand. Run it before generation,
the **edges** reachable in one hop from any of their `relates`, and **pointers**
to nearby nodes the agent can pull on demand. Run it before generation,
so the agent builds with surface composition in hand rather than discovering the
gaps in review.

Expand Down
2 changes: 1 addition & 1 deletion docs/purposes.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Two resolution mechanisms, both read-only:
| Consumer | CLI surface | Projection it needs | Reads | Changes the model? |
| --- | --- | --- | --- | --- |
| **Authoring** | `init`, `scan`, `signals`, `validate`, `migrate` | The raw nodes plus repo signals, for a human or agent writing the fingerprint. | the node graph, raw signals | **No**, this *is* the model. |
| **Generation** | `gather` | A narrow, task-scoped *slice* delivered before building: full bodies along the surface's path, one-hop edges, and spoke pointers. | the composed `gather` slice | **No** if selection stays a read-only narrowing pass. **Leak risk:** if retrieval needs are pushed back into the tree shape. |
| **Generation** | `gather` | A narrow, task-scoped *slice* delivered before building: full bodies along the surface's path, one-hop edges, and pointers. | the composed `gather` slice | **No** if selection stays a read-only narrowing pass. **Leak risk:** if retrieval needs are pushed back into the tree shape. |
| **Governance** | `checks`, `review` | Every check offered, grounded in the touched surfaces' slice, evaluated against a diff. | offered checks plus the grounding slice | **No** if checks stay offered-and-grounded. **Leak risk:** making checks filter or route by surface instead of binding to prose via `source:`. |
| **Fleet** | (`ghost-fleet`, private) | Many bundles at once: distances, cohorts, tracks-graph. | many fingerprints, read-only | **No**, consumes workspace exports read-only. |
| **Discovery / pathless** | `gather <query>` | A ranked set of candidate nodes when there is no exact surface to name. | `description` payloads, ranked | **Leak risk:** inventing a routing model in the data instead of ranking on `description` and letting the agent pick. |
Expand Down
23 changes: 12 additions & 11 deletions packages/ghost/src/commands/gather-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function registerGatherCommand(cli: CAC): void {

// An inexact query (not an exact node id): rank the closest nodes
// rather than dumping the whole menu. This is `gather`'s search front
// end the same act as picking from the menu, done intelligently.
// end, the same act as picking from the menu, done intelligently.
if (!known.has(surface)) {
const matches = searchGraph(surface, loaded.graph);
if (opts.format === "json") {
Expand Down Expand Up @@ -131,12 +131,12 @@ function formatCandidatesMarkdown(query: string, matches: SearchHit[]): string {
return `${lines.join("\n")}\n`;
}
lines.push(
`\`${query}\` is not a node id. Closest matches run \`ghost gather <node>\`:`,
`\`${query}\` is not a node id. Closest matches (run \`ghost gather <node>\`):`,
"",
);
for (const hit of matches) {
const kind = hit.surface ? "surface" : "node";
lines.push(`- \`${hit.id}\` (${kind}) ${matchLabel(hit)}`);
lines.push(`- \`${hit.id}\` (${kind}): ${matchLabel(hit)}`);
if (hit.description) lines.push(` - ${hit.description}`);
}
return `${lines.join("\n")}\n`;
Expand Down Expand Up @@ -207,26 +207,27 @@ function formatSliceMarkdown(slice: GraphSlice): string {
const tag = node.incarnation ? ` _(as ${node.incarnation})_` : "";
lines.push(
"",
`### \`${node.id}\` ${provenanceLabel(node.provenance)}${tag}`,
`### \`${node.id}\` (${provenanceLabel(node.provenance)})${tag}`,
"",
node.body,
);
}
}

// Spokes: pointers the agent may pull on demand (descendants + edge hubs).
if (slice.spokes.length > 0) {
// Pointers the agent may pull on demand (descendants + related subtrees).
if (slice.pointers.length > 0) {
lines.push(
"",
"## Available to pull",
"",
"Pointers to nearby context — run `ghost gather <id>` to expand.",
"Pointers to nearby context. Run `ghost gather <id>` to expand.",
"",
);
for (const spoke of slice.spokes) {
const via = spoke.kind === "edge-hub" ? ` _(via \`${spoke.hub}\`)_` : "";
lines.push(`- \`${spoke.id}\`${via}`);
if (spoke.description) lines.push(` - ${spoke.description}`);
for (const pointer of slice.pointers) {
const via =
pointer.kind === "related" ? ` _(via \`${pointer.from}\`)_` : "";
lines.push(`- \`${pointer.id}\`${via}`);
if (pointer.description) lines.push(` - ${pointer.description}`);
}
}

Expand Down
99 changes: 48 additions & 51 deletions packages/ghost/src/ghost-core/graph/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { GHOST_GRAPH_ROOT_ID, type GhostGraph } from "./types.js";
/**
* Why a full-body node is present in a resolved slice.
* - `own`: a file in the requested surface's own folder.
* - `ancestor`: a file in a folder higher on the corridor (root → surface).
* - `edge`: contributed by a typed `relates` link from a spine node (one hop).
* - `ancestor`: a file in a folder higher on the path (root → surface).
* - `edge`: contributed by a typed `relates` link from a node on the path (one hop).
*/
export type GraphSliceProvenance =
| { kind: "own" }
Expand All @@ -21,21 +21,20 @@ export interface GraphSliceNode {
}

/**
* A spoke: a node offered as a pointer (id + description, no body) for the agent
* to pull on demand. Spokes are navigable optionality, never authoritative
* context.
* A pointer: a node offered as id + description (no body) for the agent to pull
* on demand. Pointers are navigable optionality, never authoritative context.
* - `descendant`: a node within or below the requested surface's own subtree.
* - `edge-hub`: a node within or below an `edge` target's subtree (a hub the
* surface `relates` to unfolds its menu).
* - `related`: a node within or below an `edge` target's subtree (a node the
* surface `relates` to offers its subtree as pointers).
*/
export type GraphSpokeKind = "descendant" | "edge-hub";
export type GraphPointerKind = "descendant" | "related";

export interface GraphSlicePointer {
id: string;
description?: string;
kind: GraphSpokeKind;
/** For an `edge-hub` spoke, the hub id it belongs to. */
hub?: string;
kind: GraphPointerKind;
/** For a `related` pointer, the `relates` target whose subtree it belongs to. */
from?: string;
}

export interface GraphSlice {
Expand All @@ -45,10 +44,10 @@ export interface GraphSlice {
ancestors: string[];
/** The `--as` incarnation filter applied, if any. */
incarnation?: string;
/** Full-body context: the corridor spine plus one-hop `relates` edges. */
/** Full-body context: every node on the path plus one-hop `relates` edges. */
nodes: GraphSliceNode[];
/** Pointers (id + description) the agent may pull: descendants + edge hubs. */
spokes: GraphSlicePointer[];
/** Pointers (id + description) the agent may pull: descendants + related subtrees. */
pointers: GraphSlicePointer[];
}

export interface ResolveGraphSliceOptions {
Expand All @@ -57,23 +56,21 @@ export interface ResolveGraphSliceOptions {
}

/**
* Compose a context slice for a surface by traversing the graph — deterministic,
* no I/O, no LLM. The model is "folders are walls; files fill the corridor":
* Compose a context slice for a surface by traversing the graph. Deterministic,
* no I/O, no LLM.
*
* - **spine** (`own`/`ancestor`): every node whose **file folder** is on the
* surface's corridor — the chain of folders from the package root down to the
* surface's own folder. A sibling folder is a wall: its nodes never appear.
* Spine nodes are full bodies.
* - **edge**: for each spine node's `relates`, the target node's body is
* - **path nodes** (`own`/`ancestor`): every node whose **file folder** is on
* the path from the package root down to the surface's own folder. A sibling
* folder's nodes never appear. Path nodes are full bodies.
* - **edge**: for each path node's `relates`, the target node's body is
* included once (one hop, no recursion), tagged by the relation qualifier.
* This is how a broad rule placed high in the corridor (e.g. "all feature UI
* This is how a broad rule placed high in the tree (e.g. "all feature UI
* draws on Arcade", authored once on `features`) reaches every descendant.
* - **spokes**: descendants of the surface's own folder, plus descendants of
* each edge target (a hub the surface draws on unfolds its menu), as pointers.
* - **pointers**: descendants of the surface's own folder, plus descendants of
* each edge target (a related node offers its subtree), as id + description.
*
* The `incarnation` option filters full-body nodes: essence (untagged) always
* passes; a tagged node passes only when it matches. Spokes are unfiltered
* pointers.
* passes; a tagged node passes only when it matches. Pointers are unfiltered.
*/
export function resolveGraphSlice(
graph: GhostGraph,
Expand All @@ -84,10 +81,10 @@ export function resolveGraphSlice(
const ancestors = ancestorsFull.filter((id) => id !== GHOST_GRAPH_ROOT_ID);

const surfaceNode = graph.nodes.get(surfaceId);
// The surface's own file folder anchors the corridor. For a bare tree
// position (a directory with no index node) the folder is the id itself.
// The surface's own file folder anchors the path. For a bare tree position
// (a directory with no index node) the folder is the id itself.
const surfaceFolder = surfaceNode?.folder ?? surfaceId;
const corridor = corridorFolders(surfaceFolder);
const pathFolders = foldersOnPath(surfaceFolder);

const passesIncarnation = (incarnation?: string): boolean => {
if (options.incarnation === undefined) return true;
Expand All @@ -102,7 +99,7 @@ export function resolveGraphSlice(
? { incarnation: options.incarnation }
: {}),
nodes: [],
spokes: [],
pointers: [],
};

const seenBody = new Set<string>();
Expand All @@ -123,23 +120,23 @@ export function resolveGraphSlice(
return true;
};

// Spine: every node whose file folder is on the corridor. `own` when the
// Path nodes: every node whose file folder is on the path. `own` when the
// node sits in the surface's own folder; `ancestor` when higher up.
for (const node of graph.nodes.values()) {
if (node.origin === "inherited") continue;
if (!corridor.has(node.folder)) continue;
if (!pathFolders.has(node.folder)) continue;
const provenance: GraphSliceProvenance =
node.folder === surfaceFolder
? { kind: "own" }
: { kind: "ancestor", from: node.parent ?? GHOST_GRAPH_ROOT_ID };
addBody(node.id, provenance);
}

// Edges: one hop along `relates` from every spine node. The target's body is
// included; if the target is a hub, its subtree is offered as spokes.
const spineIds = slice.nodes.map((n) => n.id);
// Edges: one hop along `relates` from every path node. The target's body is
// included; the target's subtree is then offered as pointers.
const pathNodeIds = slice.nodes.map((n) => n.id);
const edgeTargets: string[] = [];
for (const sourceId of spineIds) {
for (const sourceId of pathNodeIds) {
const source = graph.nodes.get(sourceId);
if (!source) continue;
for (const relation of source.relates) {
Expand All @@ -152,44 +149,44 @@ export function resolveGraphSlice(
}
}

// Spokes: descendants of the surface, plus descendants of each edge hub.
const seenSpoke = new Set<string>(seenBody);
const addSpoke = (id: string, kind: GraphSpokeKind, hub?: string) => {
if (seenSpoke.has(id)) return;
// Pointers: descendants of the surface, plus descendants of each edge target.
const seenPointer = new Set<string>(seenBody);
const addPointer = (id: string, kind: GraphPointerKind, from?: string) => {
if (seenPointer.has(id)) return;
const node = graph.nodes.get(id);
if (!node) return;
seenSpoke.add(id);
slice.spokes.push({
seenPointer.add(id);
slice.pointers.push({
id: node.id,
...(node.description !== undefined
? { description: node.description }
: {}),
kind,
...(hub !== undefined ? { hub } : {}),
...(from !== undefined ? { from } : {}),
});
};

for (const node of graph.nodes.values()) {
if (node.origin === "inherited") continue;
if (isWithinOrBelow(node.folder, surfaceFolder)) {
addSpoke(node.id, "descendant");
addPointer(node.id, "descendant");
}
}
for (const hubId of edgeTargets) {
const hub = graph.nodes.get(hubId);
if (!hub) continue;
for (const targetId of edgeTargets) {
const target = graph.nodes.get(targetId);
if (!target) continue;
for (const node of graph.nodes.values()) {
if (isWithinOrBelow(node.folder, hub.folder)) {
addSpoke(node.id, "edge-hub", hubId);
if (isWithinOrBelow(node.folder, target.folder)) {
addPointer(node.id, "related", targetId);
}
}
}

return slice;
}

/** The set of folders on the corridor from the package root down to `folder`. */
function corridorFolders(folder: string): Set<string> {
/** The set of folders on the path from the package root down to `folder`. */
function foldersOnPath(folder: string): Set<string> {
const set = new Set<string>([""]); // root files reach everywhere
let current = folder;
while (current !== "") {
Expand Down
12 changes: 6 additions & 6 deletions packages/ghost/src/ghost-core/graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ export interface GhostGraphNode {
/** The containing directory's id; absent ⇒ this node is the `core` root. */
parent?: string;
/**
* The node's **file folder** the directory the source file physically sits
* in, which is the unit of containment for corridor composition. This differs
* from `parent` for index nodes: `features/bitcoin/index.md` has folder
* The node's **file folder**: the directory the source file physically sits
* in, the unit of containment for slice composition. This differs from
* `parent` for index nodes: `features/bitcoin/index.md` has folder
* `features/bitcoin` but parent `features`. A plain leaf shares its parent's
* value (`features/bitcoin/buy.md` → folder `features/bitcoin`). The root
* `core` node has folder `""`. Folders are walls: a node only cascades into
* surfaces whose folder chain includes this folder.
* `core` node has folder `""`. A node only cascades into surfaces whose
* folder path includes this folder.
*/
folder: string;
relates: GhostNodeRelation[];
Expand All @@ -47,7 +47,7 @@ export interface GhostGraphNode {
/**
* The in-memory fingerprint graph: prose nodes indexed by id, plus the
* containment tree (parent edges from the directory layout, root = `core`) that
* is the traversal spine. This is the shape later phases (gather, checks,
* is traversed. This is the shape later phases (gather, checks,
* review) traverse; the directory layout is just one serialization of it.
*/
export interface GhostGraph {
Expand Down
2 changes: 1 addition & 1 deletion packages/ghost/src/ghost-core/node/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { GHOST_NODE_RELATION_KINDS } from "./types.js";

/**
* A node id is its path within the package, `.md` dropped (`marketing/email`).
* The directory tree is the containment spine: the containing directory is the
* The directory tree is the containment graph: the containing directory is the
* parent, so the id *does* encode hierarchy by design. A segment is a permissive
* lowercase slug (alphanumeric plus `.` `_` `-`); segments join with `/`. No
* leading, trailing, or doubled slash. Ids are computed by the loader from the
Expand Down
2 changes: 1 addition & 1 deletion packages/ghost/src/scan/fingerprint-package-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async function loadInheritedNodes(
? { description: node.description }
: {}),
// Inherited nodes carry a package-qualified folder so they never sit on
// a local corridor (folders are walls per package); they enter a slice
// a local path (folders are scoped per package); they enter a slice
// only via an explicit cross-package `relates` edge.
folder: `${id}:${node.folder}`,
relates: [],
Expand Down
4 changes: 2 additions & 2 deletions packages/ghost/src/scan/node-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ async function walk(

/**
* Compute a node's id, parent, and file folder from its package-relative path.
* The folder is the directory the file sits inthe unit of corridor
* containment, which differs from `parent` for index nodes.
* The folder is the directory the file sits in, the unit of containment for
* slice composition, which differs from `parent` for index nodes.
* - `index.md` → id `core`, parent absent, folder ``.
* - `a/index.md` → id `a`, parent `core`, folder `a`.
* - `a/b/index.md` → id `a/b`, parent `a`, folder `a/b`.
Expand Down
4 changes: 2 additions & 2 deletions packages/ghost/src/scan/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ function manifestFile(): TemplateFile {
}

/**
* The default starter: a manifest plus the package-root `index.md` the `core`
* node whose prose cascades to every surface. The directory tree is the spine:
* The default starter: a manifest plus the package-root `index.md`, the `core`
* node whose prose cascades to every surface. The directory tree is the graph:
* add a surface by adding a directory, give it prose with its own `index.md`,
* and place nodes as `<surface>/<node>.md`.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/ghost/src/skill-bundle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ node shape.
path. Author a broad rule once at the level it is true (say
`relates: { to: arcade }` on `features/`) and every descendant inherits it. A
link to a node also offers that node's subtree as pointers.
- **spokes** (pointers: id + description): the surface's own descendants and the
- **pointers** (id + description, no body): the surface's own descendants and the
subtree of any node it relates to. The agent reads the descriptions and pulls
what it needs with a follow-up `gather`.

Expand Down Expand Up @@ -105,7 +105,7 @@ ref). Inherited nodes are read-only and flow into gather/validate like local one
| `ghost validate [file-or-dir]` | Validate the package: artifact shape and the node graph (links resolve, one root, acyclic). |
| `ghost checks --surface <ids>` | List the markdown checks and ground the named surfaces. |
| `ghost review --surface <ids> [--diff <patch>]` | Emit an advisory review packet: touched surfaces, the offered checks, and fingerprint grounding (diff embedded verbatim). |
| `ghost gather [node] [--as <incarnation>]` | Compose a node's context slice (full bodies along its path + relates edges, plus spoke pointers), list the node menu, or rank the closest nodes for an inexact query. |
| `ghost gather [node] [--as <incarnation>]` | Compose a node's context slice (full bodies along its path + relates edges, plus pointers), list the node menu, or rank the closest nodes for an inexact query. |
| `ghost skill install` | Install this unified skill bundle. |

## Advanced CLI Verbs
Expand Down
Loading