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
6 changes: 5 additions & 1 deletion src/claude/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ export function freshnessSegment(s: Stats): string {
export function renderStatusline(
stats: Stats | null,
session: SessionState | null,
ctx: { ctxPct: number | null },
ctx: { ctxPct: number | null; workspace?: { built: number; total: number } | null },
): string[] {
if (!stats) {
return [C.muted('◤ graft · not built · run ') + C.text('graft build')];
}
const top = [C.muted('◤ ') + C.indigo('graft'), C.text(`${stats.nodeCount} nodes / ${stats.edgeCount} edges`)];
// At a workspace parent the counts cover only the children built so far. When
// that is not all of them, say so; otherwise nothing hints that one is missing.
const ws = ctx.workspace;
if (ws && ws.built < ws.total) top.splice(1, 0, C.amber(`workspace ${ws.built}/${ws.total} repos built`));
top.push(freshnessSegment(stats));
const saved = session?.savedTokens ?? 0;
if (saved > 0) {
Expand Down
75 changes: 70 additions & 5 deletions src/claude/stats.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { readFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { GraphV1 } from '../graph/types.js';
import type { Stats } from './state.js';
import { resolveContextDir } from '../util/state.js';
import { readJson, resolveContextDir } from '../util/state.js';

export function readWiring(projectDir: string): GraphV1 | null {
try {
return JSON.parse(readFileSync(join(resolveContextDir(projectDir), '.graph', 'wiring.json'), 'utf8')) as GraphV1;
} catch { return null; }
}

export function computeStats(
w: GraphV1,
): Pick<Stats, 'nodeCount' | 'edgeCount' | 'languages' | 'totalCount' | 'readyCount'> {
/** The part of {@link Stats} that comes from a graph rather than from the hooks. */
export type GraphStats = Pick<Stats, 'nodeCount' | 'edgeCount' | 'languages' | 'totalCount' | 'readyCount'>;

export function computeStats(w: GraphV1): GraphStats {
const nodes = w.nodes ?? [];
const edges = w.edges ?? [];
const readyCount = nodes.filter((n) => n.summary_state === 'ready').length;
Expand All @@ -24,3 +25,67 @@ export function computeStats(
readyCount,
};
}

/**
* A workspace parent holds no graph of its own: its `graft/` carries only
* `workspace.json`, and the nodes live in each child's `<child>/graft/`. Read
* at the parent, `readWiring` is null — so the statusline said "not built" and
* the Stop-hook sync left `dirty` set forever, while every child had built fine
* and every federated query worked (#433). The parent's numbers are the
* children's, summed: the same graphs `ask`/`grep`/`callers` federate over.
*
* Deliberately not `loadWorkspaceGraphs`: the statusline is a pure read on the
* render path, and that module pulls the whole `ask` engine in with it. The
* child path mirrors `contextDirFor(join(root, child))` with no override —
* children never inherit the parent's `--dir`, exactly as in the federated
* commands. Null when `projectDir` is not a workspace parent, or when no listed
* child has a graph yet, which is the one case that really is "not built".
*/
export function computeWorkspaceStats(projectDir: string): GraphStats | null {
const children = workspaceChildren(projectDir);
if (!children) return null;
const parts: GraphStats[] = [];
for (const child of children) {
try {
const raw = readFileSync(childWiringPath(projectDir, child), 'utf8');
parts.push(computeStats(JSON.parse(raw) as GraphV1));
} catch { /* listed but not built yet — counted by workspaceCoverage, not summed */ }
}
if (parts.length === 0) return null;
const sum = (k: 'nodeCount' | 'edgeCount' | 'totalCount' | 'readyCount') => parts.reduce((n, p) => n + p[k], 0);
return {
nodeCount: sum('nodeCount'),
edgeCount: sum('edgeCount'),
languages: [...new Set(parts.flatMap((p) => p.languages))].sort(),
totalCount: sum('totalCount'),
readyCount: sum('readyCount'),
};
}

/**
* How many of a workspace parent's listed children have a graph. The summed
* counts above cover only the built ones, so without this the bar gives no hint
* that a child is missing. Kept apart from the sum because the statusline stops
* calling that once the Stop-hook sync has written real counts into the parent's
* cache, and a child can still be unbuilt then. A child counts as built when its
* `wiring.json` exists; only a stat per child, since this runs on every render.
* Null when `projectDir` is not a workspace parent.
*/
export function workspaceCoverage(projectDir: string): { built: number; total: number } | null {
const children = workspaceChildren(projectDir);
if (!children) return null;
const built = children.filter((child) => existsSync(childWiringPath(projectDir, child))).length;
return { built, total: children.length };
}

/** The children listed in the parent's `workspace.json`, or null when there are none. */
function workspaceChildren(projectDir: string): string[] | null {
const ws = readJson<{ version: number; children: string[] }>(join(resolveContextDir(projectDir), 'workspace.json'));
if (!ws || !Array.isArray(ws.children) || ws.children.length === 0) return null;
return ws.children;
}

/** A child's graph, at `<child>/graft/` with no override (see computeWorkspaceStats). */
function childWiringPath(projectDir: string, child: string): string {
return join(projectDir, child, 'graft', '.graph', 'wiring.json');
}
12 changes: 10 additions & 2 deletions src/claude/statusline.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs';
import { renderStatusline, renderSubagent } from './format.js';
import { readStats, readSession, emptyStats, type Stats } from './state.js';
import { readWiring, computeStats } from './stats.js';
import { readWiring, computeStats, computeWorkspaceStats, workspaceCoverage } from './stats.js';

/**
* The statusline's fast path is the hook-maintained cache (graft/.cache/stats.json).
Expand All @@ -12,12 +12,18 @@ import { readWiring, computeStats } from './stats.js';
* is missing. The graph carries no drift signal, so it reads as synced until the next
* edit repopulates the cache. Still a pure read (no subprocess): the cache is preferred
* because it carries live dirty/stale state.
*
* A workspace parent has neither a graph nor, until the first sync, a non-empty cache:
* the hooks write a zeroed `dirty` cache there and the graphs sit in the children. The
* counts then come from the children (#433) and the cache still supplies the live flags.
*/
export function resolveStats(dir: string): Stats | null {
const cached = readStats(dir);
if (cached && cached.nodeCount > 0) return cached;
const wiring = readWiring(dir);
if (wiring) return { ...emptyStats(), ...computeStats(wiring) };
const workspace = computeWorkspaceStats(dir);
if (workspace) return { ...(cached ?? emptyStats()), ...workspace };
return null;
}

Expand All @@ -29,7 +35,9 @@ export function main(): void {
const agent = input?.agent?.name;
if (agent) { process.stdout.write(renderSubagent(agent, session)); return; }
const stats = resolveStats(dir);
// Read on every render, not from the cache: a child can be unbuilt after the sync.
const workspace = stats ? workspaceCoverage(dir) : null;
const raw = input?.context_window?.used_percentage;
const ctxPct = typeof raw === 'number' ? Math.round(raw) : null;
process.stdout.write(renderStatusline(stats, session, { ctxPct }).join('\n'));
process.stdout.write(renderStatusline(stats, session, { ctxPct, workspace }).join('\n'));
}
8 changes: 5 additions & 3 deletions src/claude/sync-run.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { execFileSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { readWiring, computeStats } from './stats.js';
import { readWiring, computeStats, computeWorkspaceStats } from './stats.js';
import { patchStats, releaseLock, resolveContextDir } from './state.js';
import { graftCliPath } from './paths.js';

Expand All @@ -19,11 +19,13 @@ function realBuild(dir: string): void {
export function runSync(dir: string, build: (d: string) => void = realBuild): void {
try {
build(dir);
// A workspace parent's build writes the graphs into the children, not here (#433).
const w = readWiring(dir);
if (!w) { patchStats(dir, { syncing: false }); return; } // build ran but output unreadable — stay dirty, retry
const computed = w ? computeStats(w) : computeWorkspaceStats(dir);
if (!computed) { patchStats(dir, { syncing: false }); return; } // build ran but output unreadable — stay dirty, retry
patchStats(dir, {
dirty: false, staleCount: 0, syncing: false, syncedAt: new Date().toISOString(),
...computeStats(w),
...computed,
});
} catch {
patchStats(dir, { syncing: false }); // leave dirty=true; retry next turn
Expand Down
7 changes: 6 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
runWorkspaceCheck,
runWorkspaceGrep,
runWorkspaceMap,
runWorkspaceSkeleton,
} from "./graph/workspace-cli.js";
import { formatInitEpilogue } from "./cli-epilogue.js";
import { planInit, selectedWrites } from "./hosts/plan.js";
Expand Down Expand Up @@ -639,8 +640,12 @@ program
.action(async (file: string, dirArg: string | undefined, opts: { json?: boolean; refresh?: boolean }) => {
const dir = noteQuery(queryRoot(dirArg));
await refreshBefore(dir, opts);
const { skeleton, formatSkeleton } = await import("./ask/ask.js");
const globalOpts = program.opts<{ dir?: string }>();
if (readWorkspace(dir, globalOpts.dir)) {
runWorkspaceSkeleton(dir, globalOpts.dir, file, { json: opts.json });
return;
}
const { skeleton, formatSkeleton } = await import("./ask/ask.js");
const r = skeleton(dir, file, { contextDir: globalOpts.dir });
if (opts.json) console.log(JSON.stringify(r, null, 2));
else process.stdout.write(formatSkeleton(r));
Expand Down
14 changes: 13 additions & 1 deletion src/graph/workspace-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import { Graft } from "../engine.js";
import { contextDirFor, ensureGitignored } from "../context/node-file.js";
import { patchBuildConfig, type BuildConfig } from "../util/state.js";
import type { EngineConfig } from "../ai/providers.js";
import { formatAsk } from "../ask/ask.js";
import { formatAsk, formatSkeleton } from "../ask/ask.js";
import type { Direction } from "./traverse.js";
import {
federateAsk,
federateCallers,
federateCheck,
federateGrep,
federateMap,
federateSkeleton,
formatGrepResult,
migrationNote,
splitWorkspace,
Expand Down Expand Up @@ -123,6 +124,17 @@ export function runWorkspaceMap(
process.stdout.write(federateMap(root, override, { maxDirs: opts.maxDirs }));
}

export function runWorkspaceSkeleton(
root: string,
override: string | undefined,
file: string,
opts: { json?: boolean },
): void {
const r = federateSkeleton(root, override, file);
if (opts.json) console.log(JSON.stringify(r, null, 2));
else process.stdout.write(formatSkeleton(r));
}

export async function runWorkspaceCheck(root: string, override?: string): Promise<void> {
const { text, ok } = await federateCheck(root, override);
process.stdout.write(text);
Expand Down
47 changes: 46 additions & 1 deletion src/graph/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* are combined by score (see `ask/fuse.ts`). Separate child repositories have
* no such shared scale, which is exactly the case rank fusion is for.
* `grep`/`map`/`check`/`callers` run per child and merge, always labeled
* `<child>/`.
* `<child>/`; `skeleton` routes one file to the child that owns it.
*
* This module owns the pure/core pieces (the format, its readers/writers, graph
* loading, and the federated command bodies that return renderable data). The
Expand All @@ -39,9 +39,11 @@ import { wiringPath } from "./write.js";
import type { GraphV1 } from "./types.js";
import {
ask,
skeleton,
type AskHit,
type AskRankingMetadata,
type AskResult,
type SkeletonResult,
} from "../ask/ask.js";
import {
fileFirstRoundRobin,
Expand Down Expand Up @@ -694,6 +696,49 @@ export function federateCallers(
return { text, found: true };
}

/** The exact prefix `skeleton` puts on its own ambiguity note. */
const AMBIGUOUS = "ambiguous — matches: ";

/**
* `graft skeleton` at the parent (#433). `<child>/path` addresses one child
* directly — the shape every federated pointer is printed in, so a path copied
* out of `ask` or `callers` output works as-is. A bare path or basename is
* looked up in every child and must land in exactly one. The child's own
* `skeleton` does the work, so the answer reads exactly as it would from inside
* the child; only the file gets its `<child>/` prefix.
*/
export function federateSkeleton(root: string, override: string | undefined, file: string): SkeletonResult {
const wg = loadWorkspaceGraphs(root, override);
const prefixed = (child: string, r: SkeletonResult): SkeletonResult => ({
...r,
file: `${child}/${r.file}`,
// A within-child "ambiguous" note lists child-relative paths; keep them openable from here.
...(r.note?.startsWith(AMBIGUOUS)
? { note: AMBIGUOUS + r.note.slice(AMBIGUOUS.length).split(", ").map((p) => `${child}/${p}`).join(", ") }
: {}),
});

const slash = file.indexOf("/");
const head = slash > 0 ? file.slice(0, slash) : "";
if (head && wg.missing.includes(head)) {
return { file, entries: [], note: `${head}/ has no graph yet — run graft build` };
}
const direct = head ? wg.loaded.find((c) => c.child === head) : undefined;
if (direct) return prefixed(direct.child, skeleton(join(root, direct.child), file.slice(slash + 1)));

const hits = wg.loaded
.map(({ child }) => ({ child, r: skeleton(join(root, child), file) }))
.filter(({ r }) => r.entries.length > 0 || r.note?.startsWith(AMBIGUOUS));
if (hits.length === 1) return prefixed(hits[0].child, hits[0].r);
if (hits.length > 1) {
const paths = hits.map(({ child, r }) => `${child}/${r.file}`).sort();
return { file, entries: [], note: `${AMBIGUOUS}${paths.join(", ")}` };
}
const cov = coverageNote(wg);
const base = `no definitions indexed for this file in any of the ${wg.loaded.length} workspace repo(s)`;
return { file, entries: [], note: cov ? `${base}\n${cov}` : base };
}

/**
* Split a parent into a workspace: build each git child (via the supplied
* `buildChild` callback, so this stays free of any engine/LLM dependency),
Expand Down
13 changes: 10 additions & 3 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
federateCheck,
federateGrep,
federateMap,
federateSkeleton,
readWorkspace,
} from '../graph/workspace.js';
import type { NodeV1 } from '../graph/types.js';
Expand Down Expand Up @@ -147,9 +148,9 @@ function renderMatches(
}

/** When the MCP server is rooted at a workspace parent, the ask/callers/grep/
* map/check tools federate across the children — identical to the CLI. Returns
* null for tools that don't federate (skeleton is per-file), so the caller
* falls through to the normal single-graph path. */
* map/check/file_api tools federate across the children — identical to the
* CLI. Returns null for a tool that doesn't, so the caller falls through to the
* normal single-graph path. */
async function callWorkspaceTool(
root: string,
dirOverride: string | undefined,
Expand Down Expand Up @@ -193,6 +194,12 @@ async function callWorkspaceTool(
const { text } = await federateCheck(root, dirOverride);
return { text, isError: false };
}
case 'graft_file_api': {
const file = String(args.file ?? '');
if (!file) return { text: 'graft_file_api requires a file', isError: true };
const r = federateSkeleton(root, dirOverride, file);
return { text: formatSkeleton(r), isError: !r.entries.length && !!r.note };
}
default:
return null;
}
Expand Down
10 changes: 10 additions & 0 deletions test/claude-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,13 @@ test('renderStatusline shows tokens alone until a turn has been billed', () => {
assert.match(line, /~100,000 tok saved/);
assert.doesNotMatch(line, /\$/);
});

test('renderStatusline names a workspace parent with an unbuilt child, and only then', () => {
const stats = { ...emptyStats(), nodeCount: 3, edgeCount: 1, totalCount: 3 };
const partial = strip(renderStatusline(stats, null, { ctxPct: null, workspace: { built: 1, total: 2 } })[0]);
assert.match(partial, /graft · workspace 1\/2 repos built · 3 nodes \/ 1 edges · /);
const all = strip(renderStatusline(stats, null, { ctxPct: null, workspace: { built: 2, total: 2 } })[0]);
assert.doesNotMatch(all, /workspace/, 'every child built: the counts are the whole workspace');
const plain = strip(renderStatusline(stats, null, { ctxPct: null })[0]);
assert.doesNotMatch(plain, /workspace/, 'a plain repo gets no workspace segment');
});
25 changes: 25 additions & 0 deletions test/claude-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,31 @@ test('runSync clears dirty/syncing, recomputes stats, releases lock', () => {
assert.equal(acquireLock(d), true, 'lock released, so reacquire succeeds');
});

test('runSync at a workspace parent takes its counts from the children (#433)', () => {
// The parent's build writes the graphs into <child>/graft/, never here — so
// readWiring(parent) is null and, before this, the sync left dirty set forever
// and the bar stuck on "not built" although the build had succeeded.
const p = mkdtempSync(join(tmpdir(), 'graft-sync-ws-'));
mkdirSync(join(p, 'graft'), { recursive: true });
writeFileSync(join(p, 'graft', 'workspace.json'), JSON.stringify({ version: 1, children: ['api', 'web'] }));
for (const [child, n] of [['api', 3], ['web', 4]] as const) {
mkdirSync(join(p, child, 'graft', '.graph'), { recursive: true });
writeFileSync(join(p, child, 'graft', '.graph', 'wiring.json'),
JSON.stringify({ meta: { nodeCount: n, edgeCount: n - 1, languages: ['typescript'] }, nodes: [], edges: [] }));
}
writeStats(p, { ...emptyStats(), dirty: true, syncing: true, staleCount: 1 });
acquireLock(p);
runSync(p, () => { /* the children are already built above */ });
const s = readStats(p)!;
assert.equal(s.dirty, false, 'the sync completed, so the parent is no longer dirty');
assert.equal(s.syncing, false);
assert.equal(s.staleCount, 0);
assert.equal(s.nodeCount, 7, 'summed over the children');
assert.equal(s.edgeCount, 5);
assert.ok(s.syncedAt);
assert.equal(acquireLock(p), true, 'lock released');
});

test("runSync's default build passes --dir <resolved> to graft build when GRAFT_DIR is set", () => {
const d = mkdtempSync(join(tmpdir(), 'graft-sync-dir-'));
mkdirSync(join(d, 'elsewhere', '.graph'), { recursive: true });
Expand Down
Loading
Loading