diff --git a/src/cli/cli.tsx b/src/cli/cli.tsx index 4d781573..c1ddcede 100644 --- a/src/cli/cli.tsx +++ b/src/cli/cli.tsx @@ -25,6 +25,7 @@ import { runIngestCommand, runNgrokCommand, runPrintCommand, + runStrategyCommand, runVisualizeCommand, } from "./runners.js"; import { runIntegrationsCommand, runMcpCommand } from "./integrations.js"; @@ -90,7 +91,13 @@ async function runStandardCommand( } else if (command.kind === "cron") { await runCronCommand(command); } else if (command.kind === "book") { +<<<<<<< HEAD + await runBookCommand(command); + } else if (command.kind === "strategy") { + await runStrategyCommand(command); +======= await runBookCommand(command, command.mode); +>>>>>>> origin/main } else if (command.kind === "ingest") { await runIngestCommand(command); } else if (command.kind === "visualize") { diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 5837322d..271f9bd1 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -151,6 +151,12 @@ export type CliCommand = name: string | null; query: string | null; } + | { + kind: "strategy"; + action: "seed" | "list"; + exitCode: 0; + description: string | null; + } | { kind: "help"; exitCode: 0 } | { kind: "run"; @@ -192,6 +198,10 @@ export function parseCommand(argv: string[]): CliCommand { return parseBookCommand(argv.slice(1)); } + if (argv[0] === "strategy") { + return parseStrategyCommand(argv.slice(1)); + } + if (argv[0] === "auth") { const action = argv[1] === "configure" @@ -832,6 +842,34 @@ function parseBookCommand(argv: string[]): CliCommand { return { action, exitCode: 0, force, kind: "book", mode, name, query }; } +function parseStrategyCommand(argv: string[]): CliCommand { + const action = argv[0]; + + if (action !== "seed" && action !== "list") { + return { + exitCode: 1, + kind: "error", + message: "Usage: stratiki strategy seed | list", + }; + } + + if (action === "list") { + return { action: "list", description: null, exitCode: 0, kind: "strategy" }; + } + + const description = argv.slice(1).join(" ").trim(); + + if (description.length === 0) { + return { + exitCode: 1, + kind: "error", + message: "Usage: stratiki strategy seed ", + }; + } + + return { action: "seed", description, exitCode: 0, kind: "strategy" }; +} + /** * Builds the registry-derived integration usage error. * diff --git a/src/cli/runners.ts b/src/cli/runners.ts index c86d402d..9aa0bed7 100644 --- a/src/cli/runners.ts +++ b/src/cli/runners.ts @@ -421,6 +421,64 @@ export async function runBookCommand( await refreshBook(bookDir, bookDbPath); } +/** + * Dispatches `stratiki strategy` subcommands: seed, list. + */ +export async function runStrategyCommand( + command: Extract, +): Promise { + const { parseDecisionSeed } = await import("../strategy/parser.js"); + const { decomposeDecision } = await import("../strategy/decomposer.js"); + const { FileStrategyStore } = await import("../strategy/store.js"); + const { openWikiStrategyDir } = await import("../config/openwiki-home.js"); + const bookDir = path.join(process.cwd(), "openwiki"); + const store = new FileStrategyStore(openWikiStrategyDir); + + if (command.action === "list") { + const decisions = await store.listDecisions(); + if (decisions.length === 0) { + process.stdout.write("No decisions seeded yet.\n"); + return; + } + + process.stdout.write(`Decisions (${decisions.length}):\n`); + for (const decision of decisions) { + const goals = await store.getGoalsForDecision(decision.id); + process.stdout.write( + `\n${decision.id}: ${decision.description}\n Status: ${decision.status}\n Goals: ${goals.length}\n`, + ); + } + return; + } + + if (command.description === null) { + process.stderr.write("Description is required for seed action.\n"); + process.exitCode = 1; + return; + } + + const decision = parseDecisionSeed({ description: command.description }); + const index = await ContextIndex.buildFromDirectory(bookDir); + try { + const result = decomposeDecision(decision, index); + await store.saveDecision(result.decision); + await store.saveGoals(result.goals); + + process.stdout.write(`Seeded decision: ${result.decision.id}\n`); + process.stdout.write(` ${result.decision.description}\n`); + process.stdout.write(`\nGenerated ${result.goals.length} goal(s):\n`); + + const sortedGoals = [...result.goals].sort((a, b) => b.rank - a.rank); + for (const goal of sortedGoals) { + process.stdout.write( + `\n- [rank ${goal.rank}] ${goal.description}\n Grounded in: ${goal.groundedIn.length > 0 ? goal.groundedIn.join(", ") : "none"}\n`, + ); + } + } finally { + index.close(); + } +} + async function initBookManifest( bookDir: string, command: Extract, diff --git a/src/config/openwiki-home.ts b/src/config/openwiki-home.ts index 7a9a24ad..32d3a2af 100644 --- a/src/config/openwiki-home.ts +++ b/src/config/openwiki-home.ts @@ -75,9 +75,11 @@ export const openWikiConversationHistoryDir = path.join( export const openWikiLocalWikiDir = path.join(openWikiHomeDir, "wiki"); export const openWikiBookDbPath = path.join(openWikiHomeDir, "book.db"); export const openWikiSkillsDir = path.join(openWikiHomeDir, "skills"); +export const openWikiStrategyDir = path.join(openWikiHomeDir, "strategy"); export const openWikiConnectorsDisplayPath = `${openWikiHomeDisplayPath}/connectors`; export const openWikiLocalWikiDisplayPath = `${openWikiHomeDisplayPath}/wiki`; export const openWikiSkillsDisplayPath = `${openWikiHomeDisplayPath}/skills`; +export const openWikiStrategyDisplayPath = `${openWikiHomeDisplayPath}/strategy`; export const openWikiEnvDisplayPath = `${openWikiHomeDisplayPath}/.env`; // Stratiki getters - lazy to avoid homedir() during module init @@ -134,6 +136,7 @@ export async function ensureOpenWikiHome(): Promise { await mkdir(openWikiConversationHistoryDir, { recursive: true, mode: 0o700 }); await mkdir(openWikiLocalWikiDir, { recursive: true, mode: 0o700 }); await mkdir(openWikiSkillsDir, { recursive: true, mode: 0o700 }); + await mkdir(openWikiStrategyDir, { recursive: true, mode: 0o700 }); } export async function ensureStratikiHome(): Promise { diff --git a/src/strategy/decomposer.ts b/src/strategy/decomposer.ts new file mode 100644 index 00000000..e3ad4892 --- /dev/null +++ b/src/strategy/decomposer.ts @@ -0,0 +1,122 @@ +import { randomUUID } from "node:crypto"; +import type { ContextIndex, ContextPacketEntry } from "../book/packet.js"; +import type { Decision, DecompositionResult, Goal } from "./types.js"; + +/** + * Decomposes a decision into goals, grounded in the company brain context. + * + * This is a minimal implementation that: + * 1. Searches the book for relevant context + * 2. Decomposes the decision into simple goals + * 3. Ranks goals based on how well they're grounded in existing knowledge + */ +export function decomposeDecision( + decision: Decision, + bookIndex: ContextIndex, +): DecompositionResult { + const contextEntries = bookIndex.search(decision.description, 10); + const goals = extractGoalsFromDecision(decision, contextEntries); + + return { + decision, + goals, + }; +} + +/** + * Extracts goals from a decision description and ranks them by grounding. + */ +function extractGoalsFromDecision( + decision: Decision, + contextEntries: readonly ContextPacketEntry[], +): Goal[] { + const now = new Date(); + const groundingPaths = new Set(contextEntries.map((entry) => entry.path)); + + const rawGoals = parseGoalsFromDescription(decision.description); + + return rawGoals.map((goalDesc, index) => { + const grounding = findGroundingForGoal(goalDesc, contextEntries); + const rank = calculateRank(grounding, groundingPaths, index); + + return { + createdAt: now, + decisionId: decision.id, + description: goalDesc, + groundedIn: grounding, + id: randomUUID(), + rank, + status: "pending", + updatedAt: now, + }; + }); +} + +/** + * Naive goal extraction: split by sentence boundaries or bullet points. + * In a real implementation, this would use an LLM. + */ +function parseGoalsFromDescription(description: string): string[] { + const bulletPattern = /^[-*•]\s+(.+)$/gmu; + const bullets: string[] = []; + let match; + + while ((match = bulletPattern.exec(description)) !== null) { + bullets.push(match[1].trim()); + } + + if (bullets.length > 0) { + return bullets; + } + + const sentences = description + .split(/[.!?]+/u) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + return sentences.slice(0, 3); +} + +/** + * Finds book context paths that are relevant to a goal. + */ +function findGroundingForGoal( + goalDesc: string, + contextEntries: readonly ContextPacketEntry[], +): string[] { + const goalWords = new Set( + goalDesc + .toLowerCase() + .split(/\W+/u) + .filter((w) => w.length > 3), + ); + + return contextEntries + .filter((entry) => { + const entryWords = new Set( + entry.excerpt + .toLowerCase() + .split(/\W+/u) + .filter((w) => w.length > 3), + ); + + const commonWords = [...goalWords].filter((w) => entryWords.has(w)); + return commonWords.length >= 1; + }) + .map((entry) => entry.path); +} + +/** + * Calculates a goal's rank based on how well it's grounded. + * Higher rank = better grounded = should be prioritized. + */ +function calculateRank( + grounding: string[], + _allPaths: Set, + baseIndex: number, +): number { + const groundingScore = Math.min(grounding.length, 5) * 10; + const positionPenalty = baseIndex; + + return 100 + groundingScore - positionPenalty; +} diff --git a/src/strategy/parser.ts b/src/strategy/parser.ts new file mode 100644 index 00000000..7c511e06 --- /dev/null +++ b/src/strategy/parser.ts @@ -0,0 +1,24 @@ +import { randomUUID } from "node:crypto"; +import type { Decision, DecisionSeedRequest } from "./types.js"; + +/** + * Parses a decision seed request and creates a Decision record. + */ +export function parseDecisionSeed(request: DecisionSeedRequest): Decision { + const description = request.description.trim(); + + if (description.length === 0) { + throw new Error("Decision description cannot be empty"); + } + + if (description.length > 500) { + throw new Error("Decision description must be 500 characters or less"); + } + + return { + createdAt: new Date(), + description, + id: randomUUID(), + status: "active", + }; +} diff --git a/src/strategy/store.ts b/src/strategy/store.ts new file mode 100644 index 00000000..aafa6eba --- /dev/null +++ b/src/strategy/store.ts @@ -0,0 +1,80 @@ +import type { Decision, Goal } from "./types.js"; + +/** + * Persistence layer for the strategy store. + * + * For the minimal viable implementation, we store decisions and goals + * as simple JSON files in the Stratiki home directory under `strategy/` + * (typically `~/.stratiki/strategy/` or `~/.openwiki/strategy/`). + */ + +export interface StrategyStore { + saveDecision(decision: Decision): Promise; + saveGoals(goals: readonly Goal[]): Promise; + listDecisions(): Promise; + getGoalsForDecision(decisionId: string): Promise; +} + +export class FileStrategyStore implements StrategyStore { + constructor(private readonly strategyDir: string) {} + + async saveDecision(decision: Decision): Promise { + const { mkdir, writeFile } = await import("node:fs/promises"); + await mkdir(this.strategyDir, { recursive: true }); + + const decisions = await this.listDecisions(); + decisions.push(decision); + + await writeFile( + `${this.strategyDir}/decisions.json`, + JSON.stringify(decisions, null, 2), + ); + } + + async saveGoals(goals: readonly Goal[]): Promise { + const { mkdir, writeFile } = await import("node:fs/promises"); + await mkdir(this.strategyDir, { recursive: true }); + + for (const goal of goals) { + const goalsForDecision = await this.getGoalsForDecision(goal.decisionId); + const existingIndex = goalsForDecision.findIndex((g) => g.id === goal.id); + + if (existingIndex >= 0) { + goalsForDecision[existingIndex] = goal; + } else { + goalsForDecision.push(goal); + } + + await writeFile( + `${this.strategyDir}/goals-${goal.decisionId}.json`, + JSON.stringify(goalsForDecision, null, 2), + ); + } + } + + async listDecisions(): Promise { + const { readFile } = await import("node:fs/promises"); + try { + const content = await readFile( + `${this.strategyDir}/decisions.json`, + "utf8", + ); + return JSON.parse(content) as Decision[]; + } catch { + return []; + } + } + + async getGoalsForDecision(decisionId: string): Promise { + const { readFile } = await import("node:fs/promises"); + try { + const content = await readFile( + `${this.strategyDir}/goals-${decisionId}.json`, + "utf8", + ); + return JSON.parse(content) as Goal[]; + } catch { + return []; + } + } +} diff --git a/src/strategy/types.ts b/src/strategy/types.ts new file mode 100644 index 00000000..31bd7fe1 --- /dev/null +++ b/src/strategy/types.ts @@ -0,0 +1,39 @@ +/** + * Stratiki Strategy Layer domain types. + * + * The strategy layer sits on top of the company brain, decomposing decisions + * into goals that are grounded in existing organizational knowledge. Goals are + * ranked by resource availability and decayed over time as they complete or + * become stale. + */ + +export interface Decision { + readonly id: string; + readonly description: string; + readonly createdAt: Date; + readonly status: DecisionStatus; +} + +export type DecisionStatus = "active" | "completed" | "abandoned"; + +export interface Goal { + readonly id: string; + readonly decisionId: string; + readonly description: string; + readonly rank: number; + readonly groundedIn: readonly string[]; + readonly status: GoalStatus; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export type GoalStatus = "pending" | "active" | "done" | "failed" | "stale"; + +export interface DecisionSeedRequest { + readonly description: string; +} + +export interface DecompositionResult { + readonly decision: Decision; + readonly goals: readonly Goal[]; +} diff --git a/test/strategy/decomposer.test.ts b/test/strategy/decomposer.test.ts new file mode 100644 index 00000000..65604477 --- /dev/null +++ b/test/strategy/decomposer.test.ts @@ -0,0 +1,139 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { ContextIndex } from "../../src/book/packet.js"; +import { decomposeDecision } from "../../src/strategy/decomposer.js"; +import type { Decision } from "../../src/strategy/types.js"; + +const tempDirs: string[] = []; + +async function createWiki(pages: Record): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "stratiki-strategy-")); + tempDirs.push(dir); + for (const [relativePath, content] of Object.entries(pages)) { + const fullPath = path.join(dir, relativePath); + await mkdir(path.dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, "utf8"); + } + + return dir; +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })), + ); +}); + +describe("decomposeDecision", () => { + test("decomposes a decision into goals grounded in book context", async () => { + const wikiDir = await createWiki({ + "architecture/api.md": + "---\ntitle: API Architecture\n---\nOur API uses REST endpoints with OAuth2 authentication.", + "operations/deployment.md": + "---\ntitle: Deployment\n---\nWe deploy using blue-green strategy with automated rollback.", + "u1-purpose/overview.md": + "---\ntitle: Company Purpose\n---\nWe provide developer tools for continuous deployment.", + }); + + const index = await ContextIndex.buildFromDirectory(wikiDir); + const decision: Decision = { + createdAt: new Date(), + description: + "Improve API authentication security. Deploy new authentication service. Add monitoring for deployment health.", + id: "test-decision-1", + status: "active", + }; + + try { + const result = decomposeDecision(decision, index); + + expect(result.decision).toBe(decision); + expect(result.goals.length).toBeGreaterThan(0); + + for (const goal of result.goals) { + expect(goal.id).toBeTruthy(); + expect(goal.decisionId).toBe(decision.id); + expect(goal.description).toBeTruthy(); + expect(goal.status).toBe("pending"); + expect(typeof goal.rank).toBe("number"); + expect(Array.isArray(goal.groundedIn)).toBe(true); + } + + const goalsWithGrounding = result.goals.filter( + (g) => g.groundedIn.length > 0, + ); + expect(goalsWithGrounding.length).toBeGreaterThan(0); + } finally { + index.close(); + } + }); + + test("ranks goals with better grounding higher", async () => { + const wikiDir = await createWiki({ + "auth/security.md": + "---\ntitle: Authentication Security\n---\nOur authentication uses OAuth2 with JWT tokens for secure API access.", + "misc/coffee.md": + "---\ntitle: Coffee\n---\nThe office coffee machine is a Jura E8.", + }); + + const index = await ContextIndex.buildFromDirectory(wikiDir); + const decision: Decision = { + createdAt: new Date(), + description: + "Improve authentication security for API access. Buy new coffee machine.", + id: "test-decision-2", + status: "active", + }; + + try { + const result = decomposeDecision(decision, index); + const sorted = [...result.goals].sort((a, b) => b.rank - a.rank); + + const authGoal = sorted.find((g) => + g.description.toLowerCase().includes("authentication"), + ); + const coffeeGoal = sorted.find((g) => + g.description.toLowerCase().includes("coffee"), + ); + + if (authGoal && coffeeGoal) { + expect(authGoal.groundedIn.length).toBeGreaterThanOrEqual( + coffeeGoal.groundedIn.length, + ); + } + } finally { + index.close(); + } + }); + + test("handles decision with no grounding in existing book", async () => { + const wikiDir = await createWiki({ + "u1-purpose/overview.md": + "---\ntitle: Company Purpose\n---\nWe build developer tools.", + }); + + const index = await ContextIndex.buildFromDirectory(wikiDir); + const decision: Decision = { + createdAt: new Date(), + description: "Launch quantum computing research division.", + id: "test-decision-3", + status: "active", + }; + + try { + const result = decomposeDecision(decision, index); + + expect(result.goals.length).toBeGreaterThan(0); + + const totalGrounding = result.goals.reduce( + (sum, g) => sum + g.groundedIn.length, + 0, + ); + expect(totalGrounding).toBe(0); + } finally { + index.close(); + } + }); +}); diff --git a/test/strategy/parser.test.ts b/test/strategy/parser.test.ts new file mode 100644 index 00000000..37374d25 --- /dev/null +++ b/test/strategy/parser.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { parseDecisionSeed } from "../../src/strategy/parser.js"; + +describe("parseDecisionSeed", () => { + test("creates a decision from a valid description", () => { + const result = parseDecisionSeed({ + description: "Build a customer onboarding flow", + }); + + expect(result.id).toBeTruthy(); + expect(result.description).toBe("Build a customer onboarding flow"); + expect(result.status).toBe("active"); + expect(result.createdAt).toBeInstanceOf(Date); + }); + + test("trims whitespace from description", () => { + const result = parseDecisionSeed({ + description: " Improve API performance ", + }); + + expect(result.description).toBe("Improve API performance"); + }); + + test("throws on empty description", () => { + expect(() => parseDecisionSeed({ description: "" })).toThrowError( + "Decision description cannot be empty", + ); + }); + + test("throws on whitespace-only description", () => { + expect(() => parseDecisionSeed({ description: " " })).toThrowError( + "Decision description cannot be empty", + ); + }); + + test("throws on description over 500 characters", () => { + const longDescription = "a".repeat(501); + expect(() => + parseDecisionSeed({ description: longDescription }), + ).toThrowError("Decision description must be 500 characters or less"); + }); + + test("accepts description at exactly 500 characters", () => { + const description = "a".repeat(500); + const result = parseDecisionSeed({ description }); + + expect(result.description).toBe(description); + }); +});