Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
runIngestCommand,
runNgrokCommand,
runPrintCommand,
runStrategyCommand,
runVisualizeCommand,
} from "./runners.js";
import { runIntegrationsCommand, runMcpCommand } from "./integrations.js";
Expand Down Expand Up @@ -91,6 +92,8 @@ async function runStandardCommand(
await runCronCommand(command);
} else if (command.kind === "book") {
await runBookCommand(command, command.mode);
} else if (command.kind === "strategy") {
await runStrategyCommand(command);
} else if (command.kind === "ingest") {
await runIngestCommand(command);
} else if (command.kind === "visualize") {
Expand Down
38 changes: 38 additions & 0 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 <description> | list",
};
}

if (action === "list") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The list action silently ignores any trailing arguments. stratiki strategy list unexpected parses successfully and lists decisions, unlike sibling commands (e.g. parseBookCommand returns Unexpected argument for book ... and parseRunCommand rejects unknown options). Add a guard that errors when argv.length > 1 for the list action, or route any positional leftover to an error like the other parsers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/commands.ts, line 856:

<comment>The `list` action silently ignores any trailing arguments. `stratiki strategy list unexpected` parses successfully and lists decisions, unlike sibling commands (e.g. `parseBookCommand` returns `Unexpected argument for book ...` and `parseRunCommand` rejects unknown options). Add a guard that errors when `argv.length > 1` for the list action, or route any positional leftover to an error like the other parsers.</comment>

<file context>
@@ -832,6 +842,34 @@ function parseBookCommand(argv: string[]): CliCommand {
+    };
+  }
+
+  if (action === "list") {
+    return { action: "list", description: null, exitCode: 0, kind: "strategy" };
+  }
</file context>

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 <description>",
};
}

return { action: "seed", description, exitCode: 0, kind: "strategy" };
}

/**
* Builds the registry-derived integration usage error.
*
Expand Down
63 changes: 63 additions & 0 deletions src/cli/runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: stratiki strategy list reads each decision's goals but prints only their count, so users cannot inspect the decomposed goals promised by this command. Print each goal's description, rank, and grounding in this branch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/runners.ts, line 452:

<comment>`stratiki strategy list` reads each decision's goals but prints only their count, so users cannot inspect the decomposed goals promised by this command. Print each goal's description, rank, and grounding in this branch.</comment>

<file context>
@@ -421,6 +421,69 @@ export async function runBookCommand(
+    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`,
+      );
+    }
</file context>

);
}
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/runners.ts, line 464:

<comment>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.</comment>

<file context>
@@ -421,6 +421,69 @@ export async function runBookCommand(
+    return;
+  }
+
+  const decision = parseDecisionSeed({ description: command.description });
+  const index = await ContextIndex.buildFromDirectory(bookDir);
+  try {
</file context>

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" }>,
Expand Down
10 changes: 10 additions & 0 deletions src/config/openwiki-home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const openWikiEnvDisplayPath = `${openWikiHomeDisplayPath}/.env`;
let _stratikiHomeDir: string | undefined;
let _stratikiCompanyWikiDir: string | undefined;
let _stratikiBookDbPath: string | undefined;
let _stratikiStrategyDir: string | undefined;

export function getStratikiHomeDir(): string {
if (_stratikiHomeDir === undefined) {
Expand All @@ -106,6 +107,13 @@ export function getStratikiBookDbPath(): string {
return _stratikiBookDbPath;
}

export function getStratikiStrategyDir(): string {
if (_stratikiStrategyDir === undefined) {
_stratikiStrategyDir = path.join(getStratikiHomeDir(), "strategy");
}
return _stratikiStrategyDir;
}

export function getConnectorDir(connectorId: string): string {
return path.join(openWikiConnectorsDir, connectorId);
}
Expand Down Expand Up @@ -139,10 +147,12 @@ export async function ensureOpenWikiHome(): Promise<void> {
export async function ensureStratikiHome(): Promise<void> {
const homeDir = getStratikiHomeDir();
const wikiDir = getStratikiCompanyWikiDir();
const strategyDir = getStratikiStrategyDir();
await mkdir(homeDir, { recursive: true, mode: 0o700 });
await chmodIfExists(homeDir, 0o700);
await restrictDirToCurrentUser(homeDir);
await mkdir(wikiDir, { recursive: true, mode: 0o700 });
await mkdir(strategyDir, { recursive: true, mode: 0o700 });
}

export async function ensureConnectorHome(connectorId: string): Promise<void> {
Expand Down
122 changes: 122 additions & 0 deletions src/strategy/decomposer.ts
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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. ContextIndex.search treats those terms as AND, so pages matching individual goals are excluded; search each goal separately or construct an OR query before assigning grounding.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/strategy/decomposer.ts, line 17:

<comment>For a multi-sentence decision, this passes every term to FTS5 as one space-separated query. `ContextIndex.search` treats those terms as AND, so pages matching individual goals are excluded; search each goal separately or construct an OR query before assigning grounding.</comment>

<file context>
@@ -0,0 +1,122 @@
+  decision: Decision,
+  bookIndex: ContextIndex,
+): DecompositionResult {
+  const contextEntries = bookIndex.search(decision.description, 10);
+  const goals = extractGoalsFromDecision(decision, contextEntries);
+
</file context>

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 entry.excerpt only. Include entry.title in the token set before checking word overlap.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/strategy/decomposer.ts, line 97:

<comment>When a wiki match is found only through its title, this marks the goal ungrounded because it tokenizes `entry.excerpt` only. Include `entry.title` in the token set before checking word overlap.</comment>

<file context>
@@ -0,0 +1,122 @@
+  return contextEntries
+    .filter((entry) => {
+      const entryWords = new Set(
+        entry.excerpt
+          .toLowerCase()
+          .split(/\W+/u)
</file context>
Suggested change
entry.excerpt
`${entry.title} ${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<string>,
baseIndex: number,
): number {
const groundingScore = Math.min(grounding.length, 5) * 10;
const positionPenalty = baseIndex;

return 100 + groundingScore - positionPenalty;
}
24 changes: 24 additions & 0 deletions src/strategy/parser.ts
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 String.length counts UTF-16 code units. Count Unicode code points for this limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/strategy/parser.ts, line 14:

<comment>Descriptions containing astral Unicode characters are rejected below the documented 500-character limit because `String.length` counts UTF-16 code units. Count Unicode code points for this limit.</comment>

<file context>
@@ -0,0 +1,24 @@
+    throw new Error("Decision description cannot be empty");
+  }
+
+  if (description.length > 500) {
+    throw new Error("Decision description must be 500 characters or less");
+  }
</file context>
Suggested change
if (description.length > 500) {
if ([...description].length > 500) {

throw new Error("Decision description must be 500 characters or less");
}

return {
createdAt: new Date(),
description,
id: randomUUID(),
status: "active",
};
}
Loading
Loading