Recovery: re-land fn goals CLI + pi tools onto main
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
127
packages/cli/src/__tests__/goals-commands.test.ts
Normal file
127
packages/cli/src/__tests__/goals-commands.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
143
packages/cli/src/commands/goals.ts
Normal file
143
packages/cli/src/commands/goals.ts
Normal 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();
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user