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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ Beyond the slash commands, the Pi extension layers on:
| `/codecarto-complete [phase]` | Validate and atomically apply the phase handoff, canonical status, closeout, and log entry |
| `/codecarto-skill <name>` | Run a post-pipeline skill once all phases are complete |
| `/codecarto-publish` | Publish the reimplementation spec to the configured library after reviewing an explicit confirmation preview |
| `/codecarto-library-init <path> [--namespace <name>]` | Create a library directory with marker and write the config — fixes the first-publish dead end |
| `/codecarto-config` | Show the effective merged configuration (global + workspace) and library marker status |
| `/codecarto-usage` | Cumulative + per-phase token usage |
| `/codecarto-dashboard [--narrate]` | Regenerate `.codecarto/dashboard.html`; `--narrate` for the LLM executive summary |

Expand Down
45 changes: 44 additions & 1 deletion core/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { basename, join, resolve } from "node:path";
import { isPlainObject, pathExists } from "./utils.ts";
import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.ts";

Expand Down Expand Up @@ -170,6 +170,49 @@ function isVisibility(v: string): v is LibraryVisibility {
return v === "internal" || v === "shared" || v === "public";
}

// ─── Library initialization ────────────────────────────────────────────────

export interface InitLibraryOptions {
/** Library name (defaults to basename of the path). */
name?: string;
/** Visibility level. Default "internal". */
visibility?: LibraryVisibility;
/** Whether this is a namespaced (shared) library. Default false. */
namespaced?: boolean;
}

export interface InitLibraryResult {
libraryPath: string;
marker: LibraryMarker;
/** True if the marker already existed (idempotent re-run). */
alreadyExisted: boolean;
}

/**
* Initialize a CodeCartographer library at the given path: create the
* directory if needed, write the `.codecarto-library` marker if missing,
* and return the marker. Idempotent — re-running on an existing library
* is safe and preserves the existing marker.
*/
export async function initLibrary(libraryPath: string, options: InitLibraryOptions = {}): Promise<InitLibraryResult> {
const existing = await discoverLibrary(libraryPath);
if (existing) {
return { libraryPath, marker: existing, alreadyExisted: true };
}

const name = options.name?.trim() || basename(libraryPath);
const marker: LibraryMarker = {
schema_version: MARKER_SCHEMA_VERSION,
name,
namespaced: options.namespaced ?? false,
visibility: options.visibility ?? "internal",
created_at: new Date().toISOString(),
};

await writeMarker(libraryPath, marker);
return { libraryPath, marker, alreadyExisted: false };
}

// ─── Slug helpers ───────────────────────────────────────────────────────────

export function isValidSlug(slug: string): boolean {
Expand Down
33 changes: 32 additions & 1 deletion core/orchestrator-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { PathLike } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { expandTilde, pathExists } from "./utils.ts";
import { loadYamlFile } from "./yaml.ts";
import { loadYamlFile, parseSimpleYaml, stringifySimpleYaml } from "./yaml.ts";

export interface OrchestratorConfig {
/** When true, /codecarto-next runs an LLM rewriter to produce a seed prompt
Expand Down Expand Up @@ -152,3 +153,33 @@ function cloneDefault(): CodecartoConfig {
library: { ...DEFAULT_CONFIG.library },
};
}

/**
* Write a `library:` block into a config file (user-global or workspace).
* Creates the file and parent directories if needed. Preserves any existing
* `orchestrator:` block. Overwrites the `library:` block if present.
*/
export async function writeLibraryConfig(
configPath: string,
libraryPath: string,
namespace: string | null = null,
publishConfirm = true,
): Promise<void> {
let existing: Record<string, unknown> = {};
if (await pathExists(configPath)) {
try {
const raw = await readFile(configPath, "utf8");
existing = parseSimpleYaml(raw) as Record<string, unknown>;
} catch {
// Malformed file — start fresh
}
}

const library: Record<string, unknown> = { path: libraryPath, publish_confirm: publishConfirm };
if (namespace) library.namespace = namespace;

const updated: Record<string, unknown> = { ...existing, library };
const dir = configPath.includes("/") ? configPath.slice(0, configPath.lastIndexOf("/")) : ".";
await mkdir(dir, { recursive: true });
await writeFile(configPath, `${stringifySimpleYaml(updated)}\n`, "utf8");
}
72 changes: 72 additions & 0 deletions extensions/codecarto/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cp, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, join, resolve } from "node:path";
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";

Expand Down Expand Up @@ -47,7 +48,10 @@ import {
switchPipeline,
validatePhaseOutput,
type WorkspaceState,
writeLibraryConfig,
} from "../../core/index.ts";
import { initLibrary } from "../../core/library.ts";
import { resolveUserConfigPath, USER_CONFIG_DIR } from "../../core/orchestrator-config.ts";

const STATUS_WIDGET_ID = "codecarto-widget";
const STATUS_LINE_ID = "codecarto-status";
Expand Down Expand Up @@ -732,6 +736,74 @@ export default function codeCartographerExtension(pi: ExtensionAPI) {
},
});

pi.registerCommand("codecarto-library-init", {
description: "Initialize a CodeCartographer library and configure it: /codecarto-library-init <path> [--namespace <name>]",
handler: async (args, ctx) => {
const parts = args.trim().split(/\s+/);
const pathArg = parts[0];
const namespaceIdx = parts.indexOf("--namespace");
const namespace = namespaceIdx >= 0 ? parts[namespaceIdx + 1] : null;

if (!pathArg) {
ctx.ui.notify("Usage: /codecarto-library-init <path> [--namespace <name>]", "warning");
return;
}

const libraryPath = pathArg.startsWith("~") ? join(homedir(), pathArg.slice(1)) : resolve(pathArg);

try {
const result = await initLibrary(libraryPath, {
namespaced: !!namespace,
...(namespace ? {} : {}),
});

// Write the config to the user-global location
const configPath = resolveUserConfigPath();
await writeLibraryConfig(configPath, libraryPath, namespace);

const msg = result.alreadyExisted
? `Library already exists at ${libraryPath} (marker preserved). Config updated.`
: `Created library at ${libraryPath} with marker "${result.marker.name}".`;
lastFeedbackLines = [msg, `Config written to ${configPath}`];
if (ctx.hasUI) {
ctx.ui.notify(msg, "info");
ctx.ui.notify(`Config written to ${configPath}`, "info");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Library init failed: ${message}`, "error");
}
},
});

pi.registerCommand("codecarto-config", {
description: "Show the effective merged CodeCartographer configuration (global + workspace)",
handler: async (_args, ctx) => {
const state = await ensureWorkspaceState(ctx);
const config = await loadCodecartoConfig(state ? state.workspaceDir : join(ctx.cwd, ".codecarto"));

const lines = [
"Effective CodeCartographer configuration:",
` library.path: ${config.library.path ?? "(not set)"}`,
` library.namespace: ${config.library.namespace ?? "(not set)"}`,
` library.publish_confirm: ${config.library.publish_confirm}`,
` orchestrator.llm_steer_next_phase: ${config.orchestrator.llm_steer_next_phase}`,
"",
` User-global config: ${resolveUserConfigPath()}`,
` Workspace config: ${state ? join(state.workspaceDir, "workflow/config.yaml") : "(no workspace)"}`,
];

if (config.library.path) {
const marker = await discoverLibrary(config.library.path);
lines.push(` Library marker: ${marker ? `found ("${marker.name}", namespaced: ${marker.namespaced})` : "MISSING — run /codecarto-library-init"}`);
}

lastFeedbackLines = lines;
if (state) setUiState(ctx, state, lastFeedbackLines);
ctx.ui.notify("Configuration shown in status widget.", "info");
},
});

pi.registerCommand("codecarto-usage", {
description: "Show cumulative + per-phase token usage from local phase runs",
handler: async (_args, ctx) => {
Expand Down
97 changes: 96 additions & 1 deletion mcp-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ import {
type StatusFile,
stringifySimpleYaml,
switchPipeline,

validatePhaseOutput,
type WorkspaceState,
writeLibraryConfig,
} from "../core/index.ts";
import { initLibrary } from "../core/library.ts";
import { loadUserConfig, resolveUserConfigPath } from "../core/orchestrator-config.ts";

// ---------- input helpers ----------

Expand Down Expand Up @@ -625,6 +627,72 @@ export async function handleLibraryReindex(args: Record<string, unknown>) {
);
}

export async function handleLibraryInit(args: { library_path: string; name?: string; namespace?: string; cwd?: string }) {
if (!args.library_path || typeof args.library_path !== "string") {
throw new McpError(ErrorCode.InvalidParams, "library_path is required.");
}

const libraryPath = args.library_path;
const namespaced = !!args.namespace;

const result = await initLibrary(libraryPath, {
name: args.name,
namespaced,
});

// Write config to user-global location
const configPath = resolveUserConfigPath();
await writeLibraryConfig(configPath, libraryPath, args.namespace ?? null);

const msg = result.alreadyExisted
? `Library already exists at ${libraryPath} (marker preserved). Config written to ${configPath}.`
: `Created library at ${libraryPath} with marker "${result.marker.name}". Config written to ${configPath}.`;

return textResult(msg, {
libraryPath,
markerName: result.marker.name,
namespaced: result.marker.namespaced,
alreadyExisted: result.alreadyExisted,
configPath,
});
}

export async function handleConfig(args: { cwd?: string }) {
const config = args.cwd
? await loadCodecartoConfig(join(args.cwd, ".codecarto"))
: await loadUserConfig();

const userConfigPath = resolveUserConfigPath();
const workspaceConfigPath = args.cwd ? join(args.cwd, ".codecarto", "workflow", "config.yaml") : null;

let markerStatus = "not configured";
if (config.library.path) {
const marker = await discoverLibrary(config.library.path);
markerStatus = marker ? `found ("${marker.name}", namespaced: ${marker.namespaced})` : "MISSING";
}

return textResult(
[
"Effective CodeCartographer configuration:",
` library.path: ${config.library.path ?? "(not set)"}`,
` library.namespace: ${config.library.namespace ?? "(not set)"}`,
` library.publish_confirm: ${config.library.publish_confirm}`,
` orchestrator.llm_steer_next_phase: ${config.orchestrator.llm_steer_next_phase}`,
` Library marker: ${markerStatus}`,
` User-global config: ${userConfigPath}`,
` Workspace config: ${workspaceConfigPath ?? "(no cwd provided)"}`,
].join("\n"),
{
libraryPath: config.library.path,
libraryNamespace: config.library.namespace,
publishConfirm: config.library.publish_confirm,
llmSteerNextPhase: config.orchestrator.llm_steer_next_phase,
userConfigPath,
workspaceConfigPath,
},
);
}

// ---------- tool registry ----------

const TOOLS = [
Expand Down Expand Up @@ -802,6 +870,31 @@ const TOOLS = [
},
},
},
{
name: "codecarto_library_init",
description:
"Initialize a CodeCartographer library at the given path: create the directory, write the .codecarto-library marker, and write the library.path into the user-global config. Idempotent — safe to re-run on an existing library. Pass a namespace to create a namespaced (shared) library.",
inputSchema: {
type: "object",
properties: {
library_path: { type: "string", description: "Absolute path for the library directory." },
name: { type: "string", description: "Library name (defaults to the directory basename)." },
namespace: { type: "string", description: "Default namespace for a namespaced (shared) library." },
},
required: ["library_path"],
},
},
{
name: "codecarto_config",
description:
"Show the effective merged CodeCartographer configuration (library.path, library.namespace, publish_confirm, llm_steer_next_phase) and whether the library marker was found. Pass cwd to include workspace-level config in the merge.",
inputSchema: {
type: "object",
properties: {
cwd: { type: "string", description: "Absolute path to a repository with a .codecarto/ workspace (optional)." },
},
},
},
] as const;

const HANDLERS: Record<string, (args: any) => Promise<unknown>> = {
Expand All @@ -816,6 +909,8 @@ const HANDLERS: Record<string, (args: any) => Promise<unknown>> = {
codecarto_publish: handlePublish,
codecarto_library_list: handleLibraryList,
codecarto_library_reindex: handleLibraryReindex,
codecarto_library_init: handleLibraryInit,
codecarto_config: handleConfig,
};

// ---------- server bootstrap ----------
Expand Down
2 changes: 1 addition & 1 deletion tests/default-pipeline.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ test("every PIPELINE_ALIASES target resolves to a real file", async () => {

test("Pi extension registers a command for every CodeCartographer operation", async () => {
// The set of operations the framework exposes; both wrappers must surface them.
const expected = ["init", "open", "switch-pipeline", "status", "next", "phase", "validate", "complete", "skill", "publish", "usage", "dashboard"];
const expected = ["init", "open", "switch-pipeline", "status", "next", "phase", "validate", "complete", "skill", "publish", "library-init", "config", "usage", "dashboard"];
const indexSrc = await readFile(join(REPO_ROOT, "extensions", "codecarto", "index.ts"), "utf8");
const missing = expected.filter((op) => !indexSrc.includes(`pi.registerCommand("codecarto-${op}"`));
assert.deepEqual(
Expand Down