-
Notifications
You must be signed in to change notification settings - Fork 0
feat(strategy): Add minimal strategy layer with decision → goal decomposition #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: cursor/company-brain-restacked-6abe
Are you sure you want to change the base?
Changes from all commits
034feea
e80c601
c9d185b
0762a65
44cb495
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -426,6 +426,69 @@ export async function runBookCommand( | |
| await refreshBook(bookDir, bookDbPath); | ||
| } | ||
|
|
||
| /** | ||
| * Dispatches `stratiki strategy` subcommands: seed, list. | ||
| */ | ||
| export async function runStrategyCommand( | ||
| command: Extract<CliCommand, { kind: "strategy" }>, | ||
| ): Promise<void> { | ||
| const { parseDecisionSeed } = await import("../strategy/parser.js"); | ||
| const { decomposeDecision } = await import("../strategy/decomposer.js"); | ||
| const { FileStrategyStore } = await import("../strategy/store.js"); | ||
| const { | ||
| getStratikiStrategyDir, | ||
| getStratikiCompanyWikiDir, | ||
| ensureStratikiHome, | ||
| } = await import("../config/openwiki-home.js"); | ||
| const bookDir = getStratikiCompanyWikiDir(); | ||
| const store = new FileStrategyStore(getStratikiStrategyDir()); | ||
|
|
||
| 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`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||
| ); | ||
| } | ||
| 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 }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: A seed description longer than 500 characters makes parseDecisionSeed throw (src/strategy/parser.ts enforces a 500-char limit and throws), but runStrategyCommand has no try/catch and parseStrategyCommand places no length validation on the seed argument. The resulting uncaught rejection is only surfaced through the generic crash guard, so the user gets a raw surfaced error instead of a usage message for valid-looking CLI input. Validate the description length in parseStrategyCommand (or wrap decompose/save in try/catch) so the long-input case returns a friendly error and non-zero exit code. Prompt for AI agents |
||
| const index = await ContextIndex.buildFromDirectory(bookDir); | ||
| try { | ||
| const result = decomposeDecision(decision, index); | ||
| await ensureStratikiHome(); | ||
| 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<CliCommand, { kind: "book" }>, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: For a multi-sentence decision, this passes every term to FTS5 as one space-separated query. Prompt for AI agents |
||||||
| 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 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a wiki match is found only through its title, this marks the goal ungrounded because it tokenizes Prompt for AI agents
Suggested change
|
||||||
| .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<string>, | ||||||
| baseIndex: number, | ||||||
| ): number { | ||||||
| const groundingScore = Math.min(grounding.length, 5) * 10; | ||||||
| const positionPenalty = baseIndex; | ||||||
|
|
||||||
| return 100 + groundingScore - positionPenalty; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Descriptions containing astral Unicode characters are rejected below the documented 500-character limit because Prompt for AI agents
Suggested change
|
||||||
| throw new Error("Decision description must be 500 characters or less"); | ||||||
| } | ||||||
|
|
||||||
| return { | ||||||
| createdAt: new Date(), | ||||||
| description, | ||||||
| id: randomUUID(), | ||||||
| status: "active", | ||||||
| }; | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: The
listaction silently ignores any trailing arguments.stratiki strategy list unexpectedparses successfully and lists decisions, unlike sibling commands (e.g.parseBookCommandreturnsUnexpected argument for book ...andparseRunCommandrejects unknown options). Add a guard that errors whenargv.length > 1for the list action, or route any positional leftover to an error like the other parsers.Prompt for AI agents