From 445f13215a144af853584d0652c8849d067b5e22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 06:41:01 +0000 Subject: [PATCH 1/4] feat(strategy): add minimal strategy layer on top of company brain - Add strategy module with Decision and Goal types - Implement decision seed parser with validation - Add goal decomposer that grounds decisions in book context - Add file-based strategy store under .strategy/ - Add CLI commands: stratiki strategy seed|list - Add tests for parser and decomposer with fixture wikis - Goals are ranked by grounding quality from existing knowledge Co-authored-by: divo12 --- src/cli/cli.tsx | 3 + src/cli/commands.ts | 38 +++++++++ src/cli/runners.ts | 57 +++++++++++++ src/strategy/decomposer.ts | 125 +++++++++++++++++++++++++++ src/strategy/parser.ts | 24 ++++++ src/strategy/store.ts | 81 ++++++++++++++++++ src/strategy/types.ts | 39 +++++++++ test/strategy/decomposer.test.ts | 139 +++++++++++++++++++++++++++++++ test/strategy/parser.test.ts | 49 +++++++++++ 9 files changed, 555 insertions(+) create mode 100644 src/strategy/decomposer.ts create mode 100644 src/strategy/parser.ts create mode 100644 src/strategy/store.ts create mode 100644 src/strategy/types.ts create mode 100644 test/strategy/decomposer.test.ts create mode 100644 test/strategy/parser.test.ts diff --git a/src/cli/cli.tsx b/src/cli/cli.tsx index 3e0791331..32be20f33 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"; @@ -91,6 +92,8 @@ async function runStandardCommand( await runCronCommand(command); } else if (command.kind === "book") { await runBookCommand(command); + } else if (command.kind === "strategy") { + await runStrategyCommand(command); } 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 af0baed18..a4f11de27 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -150,6 +150,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"; @@ -191,6 +197,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" @@ -798,6 +808,34 @@ function parseBookCommand(argv: string[]): CliCommand { return { action, exitCode: 0, force, kind: "book", 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 6316ede7b..28eeef08b 100644 --- a/src/cli/runners.ts +++ b/src/cli/runners.ts @@ -412,6 +412,63 @@ export async function runBookCommand( await refreshBook(bookDir); } +/** + * 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 bookDir = path.join(process.cwd(), "openwiki"); + const strategyDir = path.join(bookDir, ".strategy"); + const store = new FileStrategyStore(strategyDir); + + 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`); + + for (const goal of result.goals.sort((a, b) => b.rank - a.rank)) { + 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/strategy/decomposer.ts b/src/strategy/decomposer.ts new file mode 100644 index 000000000..0d12f33c3 --- /dev/null +++ b/src/strategy/decomposer.ts @@ -0,0 +1,125 @@ +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 000000000..7c511e06f --- /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 000000000..a3c452613 --- /dev/null +++ b/src/strategy/store.ts @@ -0,0 +1,81 @@ +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 book directory under `.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); + } 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); + } catch { + return []; + } + } +} diff --git a/src/strategy/types.ts b/src/strategy/types.ts new file mode 100644 index 000000000..31bd7fe16 --- /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 000000000..fc1c157a3 --- /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 000000000..75ed1f3cf --- /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); + }); +}); From 304b13f49ad0f3e458a79292a5d327fa5907dffa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 06:44:14 +0000 Subject: [PATCH 2/4] fix: add type assertions for JSON.parse in strategy store - Add 'as Decision[]' and 'as Goal[]' type assertions - Fix lint errors in runners and tests - Create immutable copy for sort operations Co-authored-by: divo12 --- src/book/lease.ts | 5 ++++- src/book/packet.ts | 4 +++- src/cli/runners.ts | 3 ++- src/strategy/decomposer.ts | 5 +---- src/strategy/store.ts | 8 +++----- test/book/lease.test.ts | 5 ++++- test/book/packet.test.ts | 29 ++++++++++++++++++++--------- test/book/refresh-planner.test.ts | 18 ++++++++++++++---- test/strategy/decomposer.test.ts | 2 +- test/strategy/parser.test.ts | 12 ++++++------ 10 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/book/lease.ts b/src/book/lease.ts index 8d188ff83..e0269d566 100644 --- a/src/book/lease.ts +++ b/src/book/lease.ts @@ -88,7 +88,10 @@ function isLeaseContents(value: unknown): value is LeaseContents { ); } -async function mkdirAndWrite(filePath: string, contents: LeaseContents): Promise { +async function mkdirAndWrite( + filePath: string, + contents: LeaseContents, +): Promise { await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, `${JSON.stringify(contents, null, 2)}\n`, "utf8"); } diff --git a/src/book/packet.ts b/src/book/packet.ts index ad306bd51..35376920b 100644 --- a/src/book/packet.ts +++ b/src/book/packet.ts @@ -134,7 +134,9 @@ function toRelativeDisplayPath(wikiDir: string, filePath: string): string { } function extractTitle(raw: string, filePath: string): string { - const frontMatterTitle = raw.match(/^---[\s\S]*?^title:\s*(.+)$/mu)?.[1]?.trim(); + const frontMatterTitle = raw + .match(/^---[\s\S]*?^title:\s*(.+)$/mu)?.[1] + ?.trim(); if (frontMatterTitle !== undefined && frontMatterTitle.length > 0) { return stripQuotes(frontMatterTitle); } diff --git a/src/cli/runners.ts b/src/cli/runners.ts index 28eeef08b..6ace9d4b7 100644 --- a/src/cli/runners.ts +++ b/src/cli/runners.ts @@ -459,7 +459,8 @@ export async function runStrategyCommand( process.stdout.write(` ${result.decision.description}\n`); process.stdout.write(`\nGenerated ${result.goals.length} goal(s):\n`); - for (const goal of result.goals.sort((a, b) => b.rank - a.rank)) { + 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`, ); diff --git a/src/strategy/decomposer.ts b/src/strategy/decomposer.ts index 0d12f33c3..e3ad48921 100644 --- a/src/strategy/decomposer.ts +++ b/src/strategy/decomposer.ts @@ -15,10 +15,7 @@ export function decomposeDecision( bookIndex: ContextIndex, ): DecompositionResult { const contextEntries = bookIndex.search(decision.description, 10); - const goals = extractGoalsFromDecision( - decision, - contextEntries, - ); + const goals = extractGoalsFromDecision(decision, contextEntries); return { decision, diff --git a/src/strategy/store.ts b/src/strategy/store.ts index a3c452613..a3a2c188e 100644 --- a/src/strategy/store.ts +++ b/src/strategy/store.ts @@ -35,9 +35,7 @@ export class FileStrategyStore implements StrategyStore { await mkdir(this.strategyDir, { recursive: true }); for (const goal of goals) { - const goalsForDecision = await this.getGoalsForDecision( - goal.decisionId, - ); + const goalsForDecision = await this.getGoalsForDecision(goal.decisionId); const existingIndex = goalsForDecision.findIndex((g) => g.id === goal.id); if (existingIndex >= 0) { @@ -60,7 +58,7 @@ export class FileStrategyStore implements StrategyStore { `${this.strategyDir}/decisions.json`, "utf8", ); - return JSON.parse(content); + return JSON.parse(content) as Decision[]; } catch { return []; } @@ -73,7 +71,7 @@ export class FileStrategyStore implements StrategyStore { `${this.strategyDir}/goals-${decisionId}.json`, "utf8", ); - return JSON.parse(content); + return JSON.parse(content) as Goal[]; } catch { return []; } diff --git a/test/book/lease.test.ts b/test/book/lease.test.ts index 0fa781ede..dd56a8744 100644 --- a/test/book/lease.test.ts +++ b/test/book/lease.test.ts @@ -81,7 +81,10 @@ describe("BookLease", () => { // Simulate a foreign holder by writing its lease directly. await writeFile( leasePath, - JSON.stringify({ acquiredAtIso: new Date().toISOString(), owner: "other" }), + JSON.stringify({ + acquiredAtIso: new Date().toISOString(), + owner: "other", + }), "utf8", ); const impostor = BookLease.at(leasePath, "impostor"); diff --git a/test/book/packet.test.ts b/test/book/packet.test.ts index 34e3262c2..bf59b2994 100644 --- a/test/book/packet.test.ts +++ b/test/book/packet.test.ts @@ -27,8 +27,10 @@ afterEach(async () => { describe("ContextIndex", () => { test("indexes nested markdown and ranks matching pages", async () => { const wikiDir = await createWiki({ - "architecture/overview.md": "---\ntitle: Architecture\n---\nThe deploy pipeline uses blue-green releases.", - "quickstart.md": "# Quickstart\nRun stratiki init to generate the book. The deploy pipeline is documented elsewhere.", + "architecture/overview.md": + "---\ntitle: Architecture\n---\nThe deploy pipeline uses blue-green releases.", + "quickstart.md": + "# Quickstart\nRun stratiki init to generate the book. The deploy pipeline is documented elsewhere.", "claims/notes.md": "Unrelated content about coffee preferences.", }); const index = await ContextIndex.buildFromDirectory(wikiDir); @@ -39,9 +41,13 @@ describe("ContextIndex", () => { expect(entries.length).toBeGreaterThanOrEqual(2); expect(entries.map((entry) => entry.path)).toContain("/quickstart.md"); // Titles come from front matter or the first heading. - const architecture = entries.find((entry) => entry.path === "/architecture/overview.md"); + const architecture = entries.find( + (entry) => entry.path === "/architecture/overview.md", + ); expect(architecture?.title).toBe("Architecture"); - const quickstart = entries.find((entry) => entry.path === "/quickstart.md"); + const quickstart = entries.find( + (entry) => entry.path === "/quickstart.md", + ); expect(quickstart?.title).toBe("Quickstart"); } finally { index.close(); @@ -50,16 +56,17 @@ describe("ContextIndex", () => { test("skips dot-directories like .claims", async () => { const wikiDir = await createWiki({ - ".claims/secret-page.md": "Internal claim metadata should never be searchable.", + ".claims/secret-page.md": + "Internal claim metadata should never be searchable.", "public.md": "# Public page\nVisible knowledge.", }); const index = await ContextIndex.buildFromDirectory(wikiDir); try { expect(index.search("claim metadata")).toEqual([]); - expect(index.search("visible knowledge").map((entry) => entry.path)).toEqual([ - "/public.md", - ]); + expect( + index.search("visible knowledge").map((entry) => entry.path), + ).toEqual(["/public.md"]); } finally { index.close(); } @@ -81,7 +88,11 @@ describe("ContextIndex", () => { describe("renderPacket", () => { test("renders provenance-first markdown", () => { const packet = renderPacket("deploy pipeline", [ - { excerpt: ">>blue-green<< releases", path: "/arch.md", title: "Architecture" }, + { + excerpt: ">>blue-green<< releases", + path: "/arch.md", + title: "Architecture", + }, ]); expect(packet).toContain("# Context packet"); diff --git a/test/book/refresh-planner.test.ts b/test/book/refresh-planner.test.ts index 9d023b8e0..de1a1e5f2 100644 --- a/test/book/refresh-planner.test.ts +++ b/test/book/refresh-planner.test.ts @@ -40,14 +40,20 @@ describe("planRefresh", () => { NOW, ); - expect(due.map((decision) => decision.entry.connectorId)).toEqual(["github"]); - expect(deferred.map((decision) => decision.entry.connectorId)).toEqual(["linear"]); + expect(due.map((decision) => decision.entry.connectorId)).toEqual([ + "github", + ]); + expect(deferred.map((decision) => decision.entry.connectorId)).toEqual([ + "linear", + ]); // 168h window minus 0.5h elapsed rounds up to a whole remaining hour. expect(deferred[0]?.hoursRemaining).toBe(168); }); test("a source past its tier window is due with the elapsed reason", () => { - const oldIngest = new Date(NOW.getTime() - 25 * 60 * 60 * 1000).toISOString(); + const oldIngest = new Date( + NOW.getTime() - 25 * 60 * 60 * 1000, + ).toISOString(); const { due } = planRefresh( { hackernews: "daily" }, new Map([["hackernews", episode(oldIngest, "hackernews")]]), @@ -61,7 +67,11 @@ describe("planRefresh", () => { test("an unparseable last-ingest timestamp defers to due, never silently stalls", () => { const broken = episode("not-a-timestamp"); - const { due, deferred } = planRefresh({ rss: "cold" }, new Map([["rss", broken]]), NOW); + const { due, deferred } = planRefresh( + { rss: "cold" }, + new Map([["rss", broken]]), + NOW, + ); expect(deferred).toEqual([]); expect(due).toHaveLength(1); diff --git a/test/strategy/decomposer.test.ts b/test/strategy/decomposer.test.ts index fc1c157a3..65604477c 100644 --- a/test/strategy/decomposer.test.ts +++ b/test/strategy/decomposer.test.ts @@ -89,7 +89,7 @@ describe("decomposeDecision", () => { try { const result = decomposeDecision(decision, index); - const sorted = result.goals.sort((a, b) => b.rank - a.rank); + const sorted = [...result.goals].sort((a, b) => b.rank - a.rank); const authGoal = sorted.find((g) => g.description.toLowerCase().includes("authentication"), diff --git a/test/strategy/parser.test.ts b/test/strategy/parser.test.ts index 75ed1f3cf..37374d25d 100644 --- a/test/strategy/parser.test.ts +++ b/test/strategy/parser.test.ts @@ -22,15 +22,15 @@ describe("parseDecisionSeed", () => { }); test("throws on empty description", () => { - expect(() => - parseDecisionSeed({ description: "" }), - ).toThrowError("Decision description cannot be empty"); + expect(() => parseDecisionSeed({ description: "" })).toThrowError( + "Decision description cannot be empty", + ); }); test("throws on whitespace-only description", () => { - expect(() => - parseDecisionSeed({ description: " " }), - ).toThrowError("Decision description cannot be empty"); + expect(() => parseDecisionSeed({ description: " " })).toThrowError( + "Decision description cannot be empty", + ); }); test("throws on description over 500 characters", () => { From 305a82fcae779b551de66d6ee7716eb0d243e74b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 06:48:10 +0000 Subject: [PATCH 3/4] fix(strategy): store decisions in ~/.stratiki/strategy/ not openwiki/.strategy - Use openWikiStrategyDir from openwiki-home helpers - Reuse existing company-mode home infrastructure - Add strategy dir to ensureOpenWikiHome initialization - Update store documentation to reflect correct location Co-authored-by: divo12 --- src/cli/runners.ts | 6 ++++-- src/config/openwiki-home.ts | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cli/runners.ts b/src/cli/runners.ts index 6ace9d4b7..5c801efb3 100644 --- a/src/cli/runners.ts +++ b/src/cli/runners.ts @@ -421,9 +421,11 @@ export async function runStrategyCommand( 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 strategyDir = path.join(bookDir, ".strategy"); - const store = new FileStrategyStore(strategyDir); + const store = new FileStrategyStore(openWikiStrategyDir); if (command.action === "list") { const decisions = await store.listDecisions(); diff --git a/src/config/openwiki-home.ts b/src/config/openwiki-home.ts index 5849441c9..540acc8b2 100644 --- a/src/config/openwiki-home.ts +++ b/src/config/openwiki-home.ts @@ -46,9 +46,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`; export function getConnectorDir(connectorId: string): string { @@ -79,6 +81,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 ensureConnectorHome(connectorId: string): Promise { From 21bef9a33b305475d276d4fb411242b54d320e1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 06:49:57 +0000 Subject: [PATCH 4/4] chore: apply linter fixes Co-authored-by: divo12 --- src/cli/runners.ts | 4 +--- src/strategy/store.ts | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/cli/runners.ts b/src/cli/runners.ts index 5c801efb3..3f39fef12 100644 --- a/src/cli/runners.ts +++ b/src/cli/runners.ts @@ -421,9 +421,7 @@ export async function runStrategyCommand( 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 { openWikiStrategyDir } = await import("../config/openwiki-home.js"); const bookDir = path.join(process.cwd(), "openwiki"); const store = new FileStrategyStore(openWikiStrategyDir); diff --git a/src/strategy/store.ts b/src/strategy/store.ts index a3a2c188e..aafa6eba7 100644 --- a/src/strategy/store.ts +++ b/src/strategy/store.ts @@ -4,7 +4,8 @@ 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 book directory under `.strategy/`. + * as simple JSON files in the Stratiki home directory under `strategy/` + * (typically `~/.stratiki/strategy/` or `~/.openwiki/strategy/`). */ export interface StrategyStore {