Recovery: re-land fn goals CLI + pi tools onto main

This commit is contained in:
gsxdsm
2026-05-28 19:44:06 -07:00
parent be0be507b3
commit 009d569bdd
12 changed files with 554 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ Mission → Milestone → Slice → Feature → Task
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan`
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_delete`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update`
- **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart`
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
- **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show`

View File

@@ -62,6 +62,9 @@ fn mission list # List all missions
fn mission show M-001 # Show mission hierarchy
fn mission delete M-001 [--force] # Delete mission (cascades)
fn mission activate-slice SL-001 # Manually activate a slice
fn goals list [--status STATE] # List goals (default: active)
fn goals create "Title" "Description" # Create a new goal
fn goals archive G-001 # Archive a goal
```
## GitHub Integration

View File

@@ -274,6 +274,33 @@ Update an existing milestone's title, description, or acceptance criteria (the s
| `description` | string | — | Updated milestone description |
| `acceptanceCriteria` | string | — | Updated acceptance criteria for completing the milestone |
## Goal Tools
### fn_goal_list
List goals by status with active-goal warning details.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | union | — | Filter by goal status (default: active) |
### fn_goal_create
Create a new project goal.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `title` | string | ✓ | Goal title — brief but descriptive |
| `description` | string | — | Long-form goal description (free-text markdown) |
### fn_goal_archive
Archive a goal by ID.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Goal ID (G-…) to archive |
## Agent Tools
### fn_agent_stop

View File

@@ -43,6 +43,9 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_insight_run_show` | Show a single insight-generation run by ID. |
| `fn_mission_create` | Create a new mission — a high-level objective that can span multiple milestones. Missions contain milestones that break down work into phases. |
| `fn_mission_list` | List all missions with their current status. |
| `fn_goal_list` | List goals by status with active-goal warning details. |
| `fn_goal_create` | Create a new project goal. |
| `fn_goal_archive` | Archive a goal by ID. |
| `fn_mission_show` | Show mission details with full hierarchy: milestones → slices → features. |
| `fn_mission_delete` | Delete a mission and all its milestones, slices, and features. Cannot be undone. |
| `fn_milestone_add` | Add a milestone to a mission. Milestones represent phases of work. |

View File

@@ -56,6 +56,9 @@ const commandMocks = vi.hoisted(() => ({
runMissionShow: vi.fn(),
runMissionDelete: vi.fn(),
runMissionActivateSlice: vi.fn(),
runGoalsList: vi.fn(),
runGoalsCreate: vi.fn(),
runGoalsArchive: vi.fn(),
runProjectList: vi.fn(),
runProjectAdd: vi.fn(),
@@ -170,6 +173,12 @@ vi.mock("../commands/mission.js", () => ({
runMissionActivateSlice: commandMocks.runMissionActivateSlice,
}));
vi.mock("../commands/goals.js", () => ({
runGoalsList: commandMocks.runGoalsList,
runGoalsCreate: commandMocks.runGoalsCreate,
runGoalsArchive: commandMocks.runGoalsArchive,
}));
vi.mock("../commands/project.js", () => ({
runProjectList: commandMocks.runProjectList,
runProjectAdd: commandMocks.runProjectAdd,
@@ -538,6 +547,35 @@ describe("bin command routing and fallbacks", () => {
expect(commandMocks.runMissionActivateSlice).toHaveBeenCalledWith("SL-001", undefined);
});
it("routes goals list with default status", async () => {
await runBin(["goals", "list"]);
expect(commandMocks.runGoalsList).toHaveBeenCalledWith(undefined, { status: "active" });
});
it("routes goals ls with explicit status", async () => {
await runBin(["goals", "ls", "--status", "all"]);
expect(commandMocks.runGoalsList).toHaveBeenCalledWith(undefined, { status: "all" });
});
it("routes goals list with project and archived status", async () => {
await runBin(["goals", "list", "--status", "archived", "--project", "demo"]);
expect(commandMocks.runGoalsList).toHaveBeenCalledWith("demo", { status: "archived" });
});
it("routes goals create with multi-word description", async () => {
await runBin(["goals", "create", "Title", "Long", "desc"]);
expect(commandMocks.runGoalsCreate).toHaveBeenCalledWith("Title", "Long desc", undefined);
});
it("routes goals archive with project", async () => {
await runBin(["goals", "archive", "G-001", "--project", "demo"]);
expect(commandMocks.runGoalsArchive).toHaveBeenCalledWith("G-001", "demo");
});
it("exits on unknown goals subcommand", async () => {
await expect(runBin(["goals", "bogus"])).rejects.toThrow("process.exit:1");
});
it("routes daemon command with all flags", async () => {
await runBin(["daemon", "--port", "4040", "--host", "127.0.0.1", "--token", "fn_abc123", "--paused", "--token-only"]);

View File

@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../project-resolver.js", () => ({
getStore: vi.fn(),
}));
const { getStore } = await import("../project-resolver.js");
const { runGoalsList, runGoalsCreate, runGoalsArchive } = await import("../commands/goals.js");
describe("goals commands", () => {
const originalExit = process.exit;
beforeEach(() => {
vi.clearAllMocks();
process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
});
afterEach(() => {
process.exit = originalExit;
vi.restoreAllMocks();
});
function mockStore(goalStore: Record<string, unknown>) {
vi.mocked(getStore).mockResolvedValue({
getGoalStore: () => goalStore,
} as any);
}
it("runGoalsList prints empty-state when no goals", async () => {
mockStore({
listGoals: vi.fn().mockReturnValue([]),
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
await expect(runGoalsList()).rejects.toThrow("process.exit:0");
expect(logSpy).toHaveBeenCalledWith("\n No goals yet. Create one with: fn goals create\n");
});
it("runGoalsList prints rows and soft warning when active count is high", async () => {
const listGoals = vi
.fn()
.mockReturnValueOnce([{ id: "G-001", title: "Goal one", status: "active", description: "desc" }])
.mockReturnValueOnce([
{ id: "G-001", title: "Goal one", status: "active", description: "desc" },
{ id: "G-002", title: "Goal two", status: "active" },
{ id: "G-003", title: "Goal three", status: "active" },
]);
mockStore({ listGoals });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
await expect(runGoalsList()).rejects.toThrow("process.exit:0");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("G-001"));
expect(logSpy).toHaveBeenCalledWith(" ⚠ 3/5 active goals — soft warning at 3, hard cap at 5");
});
it("runGoalsCreate trims args and prints success", async () => {
const createGoal = vi.fn().mockReturnValue({ id: "G-001", title: "Title", status: "active" });
mockStore({
createGoal,
listGoals: vi.fn().mockReturnValue([{ id: "G-001", status: "active" }]),
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
await runGoalsCreate(" Title ", " Long desc ");
expect(createGoal).toHaveBeenCalledWith({ title: "Title", description: "Long desc" });
expect(logSpy).toHaveBeenCalledWith(" ✓ Created G-001: Title");
});
it("runGoalsCreate handles active-goal cap error", async () => {
mockStore({
createGoal: vi.fn(() => {
throw { code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit: 5, currentActive: 5 };
}),
listGoals: vi.fn(),
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
await expect(runGoalsCreate("Title", "Desc")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("hard cap"));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("5"));
});
it("runGoalsArchive rejects missing id", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
await expect(runGoalsArchive(undefined)).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Usage: fn goals archive <id>");
});
it("runGoalsArchive archives valid id", async () => {
const archiveGoal = vi.fn().mockReturnValue({ id: "G-001", title: "Goal", status: "archived" });
mockStore({
getGoal: vi.fn().mockReturnValue({ id: "G-001", title: "Goal", status: "active" }),
archiveGoal,
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
await runGoalsArchive("G-001");
expect(archiveGoal).toHaveBeenCalledWith("G-001");
expect(logSpy).toHaveBeenCalledWith(" ✓ Archived G-001: Goal");
});
it("runGoalsArchive exits successfully when already archived", async () => {
mockStore({
getGoal: vi.fn().mockReturnValue({ id: "G-001", title: "Goal", status: "archived" }),
archiveGoal: vi.fn(),
});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
await expect(runGoalsArchive("G-001")).rejects.toThrow("process.exit:0");
expect(logSpy).toHaveBeenCalledWith("Goal G-001 is already archived");
});
it("runGoalsArchive prints not-found error", async () => {
mockStore({
getGoal: vi.fn().mockReturnValue(null),
archiveGoal: vi.fn(),
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
await expect(runGoalsArchive("G-404")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Goal G-404 not found");
});
});

View File

@@ -127,6 +127,7 @@ async function loadCommandHandlers() {
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } = await import("./commands/memory-backup.js");
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
const { runGoalsList, runGoalsCreate, runGoalsArchive } = await import("./commands/goals.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const { runNodeList, runNodeConnect, runNodeDisconnect, runNodeShow, runNodeHealth, runMeshStatus } = await import("./commands/node.js");
const { runInit } = await import("./commands/init.js");
@@ -192,6 +193,9 @@ async function loadCommandHandlers() {
runMissionShow,
runMissionDelete,
runMissionActivateSlice,
runGoalsList,
runGoalsCreate,
runGoalsArchive,
runProjectList,
runProjectAdd,
runProjectRemove,
@@ -309,6 +313,9 @@ PR:
fn mission show | info <id> Show mission details
fn mission delete <id> [--force] Delete a mission
fn mission activate-slice <id> Mark a slice active
fn goals list [--status STATE] List goals (default: active)
fn goals create [title] [desc] Create a new goal
fn goals archive <id> Archive a goal
fn project list | ls [--json] List all registered projects
fn project add [name] [path] [opts] Register a new project
fn project remove | rm <name> [--force]
@@ -593,6 +600,9 @@ async function main() {
runMissionShow,
runMissionDelete,
runMissionActivateSlice,
runGoalsList,
runGoalsCreate,
runGoalsArchive,
runProjectList,
runProjectAdd,
runProjectRemove,
@@ -1334,6 +1344,37 @@ async function main() {
break;
}
case "goals": {
const subcommand = args[1];
switch (subcommand) {
case "list":
case "ls": {
const statusIdx = args.indexOf("--status");
const status = statusIdx !== -1 && statusIdx + 1 < args.length
? args[statusIdx + 1] as "active" | "archived" | "all"
: "active";
await runGoalsList(projectName, { status });
break;
}
case "create": {
const title = args[2];
const description = args.length > 3 ? args.slice(3).join(" ") : undefined;
await runGoalsCreate(title, description, projectName);
break;
}
case "archive": {
const id = args[2];
await runGoalsArchive(id, projectName);
break;
}
default:
console.error(`Unknown subcommand: goals ${subcommand || ""}`);
console.log("Try: fn goals list | create | archive");
process.exit(1);
}
break;
}
case "settings": {
const subcommand = args[1];
if (!subcommand || subcommand === "show") {

View File

@@ -0,0 +1,143 @@
import { createInterface } from "node:readline/promises";
import { getStore } from "../project-resolver.js";
type GoalStatusFilter = "active" | "archived" | "all";
interface RunGoalsListOptions {
status?: GoalStatusFilter;
}
const ACTIVE_SOFT_WARNING_THRESHOLD = 3;
const ACTIVE_HARD_LIMIT = 5;
function truncateDescription(description: string, max = 60): string {
return description.length > max ? `${description.slice(0, max)}` : description;
}
function printActiveSoftWarning(activeCount: number): void {
if (activeCount >= ACTIVE_SOFT_WARNING_THRESHOLD) {
console.log(`${activeCount}/${ACTIVE_HARD_LIMIT} active goals — soft warning at 3, hard cap at 5`);
}
}
async function promptForTitleAndDescription(
titleArg: string | undefined,
): Promise<{ title: string; description?: string }> {
let title = titleArg;
let description: string | undefined;
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question("Goal title: ");
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question("Goal description (optional): ");
rl.close();
}
return {
title: title.trim(),
description: description?.trim() || undefined,
};
}
export async function runGoalsList(projectName?: string, opts: RunGoalsListOptions = {}): Promise<void> {
const store = await getStore({ project: projectName });
const goalStore = store.getGoalStore();
const status = opts.status ?? "active";
const goals = status === "all" ? goalStore.listGoals() : goalStore.listGoals({ status });
if (goals.length === 0) {
console.log("\n No goals yet. Create one with: fn goals create\n");
process.exit(0);
}
const activeCount = goalStore.listGoals({ status: "active" }).length;
console.log();
for (const goal of goals) {
const statusBadge = goal.status === "active" ? "● active" : "○ archived";
const desc = goal.description ? `${truncateDescription(goal.description)}` : "";
console.log(` ${goal.id} [${statusBadge}] ${goal.title}${desc}`);
}
console.log();
printActiveSoftWarning(activeCount);
if (activeCount >= ACTIVE_SOFT_WARNING_THRESHOLD) {
console.log();
}
process.exit(0);
}
export async function runGoalsCreate(
titleArg?: string,
descriptionArg?: string,
projectName?: string,
): Promise<void> {
const store = await getStore({ project: projectName });
const goalStore = store.getGoalStore();
const { title, description } = titleArg
? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined }
: await promptForTitleAndDescription(titleArg);
try {
const goal = goalStore.createGoal({ title, description });
const activeCount = goalStore.listGoals({ status: "active" }).length;
console.log();
console.log(` ✓ Created ${goal.id}: ${goal.title}`);
console.log(` Status: ${goal.status}`);
printActiveSoftWarning(activeCount);
console.log();
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: string }).code === "ACTIVE_GOAL_LIMIT_EXCEEDED"
) {
const limit = (error as { limit?: number }).limit ?? ACTIVE_HARD_LIMIT;
const currentActive = (error as { currentActive?: number }).currentActive ?? ACTIVE_HARD_LIMIT;
console.error(
`Error: Cannot create goal — already at the hard cap of ${limit} active goals (currently ${currentActive}). Archive one with 'fn goals archive <id>' first.`,
);
process.exit(1);
}
throw error;
}
}
export async function runGoalsArchive(idArg: string | undefined, projectName?: string): Promise<void> {
if (!idArg) {
console.error("Usage: fn goals archive <id>");
process.exit(1);
}
const store = await getStore({ project: projectName });
const goalStore = store.getGoalStore();
const existing = goalStore.getGoal(idArg);
if (!existing) {
console.error(`Goal ${idArg} not found`);
process.exit(1);
}
if (existing.status === "archived") {
console.log(`Goal ${idArg} is already archived`);
process.exit(0);
}
const archived = goalStore.archiveGoal(idArg);
console.log();
console.log(` ✓ Archived ${archived.id}: ${archived.title}`);
console.log();
}

View File

@@ -2378,6 +2378,150 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── Goal Tools ───────────────────────────────────────────────
// Author-facing goal management
pi.registerTool({
name: "fn_goal_list",
label: "fn: List Goals",
description: "List goals by status with active-goal warning details.",
promptSnippet: "List project goals",
promptGuidelines: [
"Use to inspect current goals before creating or archiving",
"Default status is active; pass archived/all when needed",
"Soft warning begins at 3 active goals and hard cap is 5",
],
parameters: Type.Object({
status: Type.Optional(
Type.Union([
Type.Literal("active"),
Type.Literal("archived"),
Type.Literal("all"),
], { description: "Filter by goal status (default: active)" }),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const goalStore = store.getGoalStore();
const status = params.status ?? "active";
const goals = status === "all" ? goalStore.listGoals() : goalStore.listGoals({ status });
const activeCount = goalStore.listGoals({ status: "active" }).length;
const softWarning = activeCount >= 3;
const lines: string[] = [];
lines.push(`Goals (${goals.length}) [filter: ${status}]`);
lines.push(`Active: ${activeCount}/5`);
if (softWarning) {
lines.push("⚠ 3/5 active goals — soft warning at 3, hard cap at 5");
}
lines.push("");
if (goals.length === 0) {
lines.push("No goals found.");
} else {
for (const goal of goals) {
const description = goal.description ? `${goal.description}` : "";
lines.push(`- ${goal.id} [${goal.status}] ${goal.title}${description}`);
}
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { goals, activeCount, softWarning, hardLimit: 5 },
};
},
});
pi.registerTool({
name: "fn_goal_create",
label: "fn: Create Goal",
description: "Create a new project goal.",
promptSnippet: "Create a new goal",
promptGuidelines: [
"Use clear titles and optional context-rich descriptions",
"Goal creation counts toward the 5 active-goal hard cap",
"Archive older goals when the active cap is reached",
],
parameters: Type.Object({
title: Type.String({ description: "Goal title — brief but descriptive" }),
description: Type.Optional(
Type.String({ description: "Long-form goal description (free-text markdown)" }),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const goalStore = store.getGoalStore();
try {
const goal = goalStore.createGoal({
title: params.title.trim(),
description: params.description?.trim() || undefined,
});
const activeCount = goalStore.listGoals({ status: "active" }).length;
const softWarning = activeCount >= 3;
return {
content: [{ type: "text", text: `Created ${goal.id}: ${goal.title}\nStatus: ${goal.status}${softWarning ? `\n⚠ ${activeCount}/5 active goals — approaching hard cap` : ""}` }],
details: { goalId: goal.id, title: goal.title, status: goal.status, softWarning },
};
} catch (err) {
if (typeof err === "object" && err !== null && "code" in err && (err as { code?: string }).code === "ACTIVE_GOAL_LIMIT_EXCEEDED") {
const limit = (err as { limit?: number }).limit ?? 5;
const currentActive = (err as { currentActive?: number }).currentActive ?? 5;
return {
isError: true,
content: [{ type: "text", text: `Cannot create goal — already at the hard cap of 5 active goals (currently ${currentActive}). Archive one first.` }],
details: { code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit, currentActive },
};
}
throw err;
}
},
});
pi.registerTool({
name: "fn_goal_archive",
label: "fn: Archive Goal",
description: "Archive a goal by ID.",
promptSnippet: "Archive a goal by ID",
promptGuidelines: [
"Use when a goal is complete or no longer active",
"Archiving frees active-goal capacity",
"Returns success for already archived goals",
],
parameters: Type.Object({
id: Type.String({ description: "Goal ID (G-…) to archive" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const goalStore = store.getGoalStore();
const goal = goalStore.getGoal(params.id);
if (!goal) {
return {
isError: true,
content: [{ type: "text", text: `Goal ${params.id} not found` }],
details: { code: "GOAL_NOT_FOUND", goalId: params.id },
};
}
if (goal.status === "archived") {
return {
content: [{ type: "text", text: `Goal ${params.id} is already archived` }],
details: { goalId: params.id, status: "archived" },
};
}
const archived = goalStore.archiveGoal(params.id);
return {
content: [{ type: "text", text: `Archived ${archived.id}: ${archived.title}` }],
details: { goalId: archived.id, status: "archived" },
};
},
});
// ── fn_mission_show ──────────────────────────────────────────────
pi.registerTool({