Skip to content

feat(strategy): Add minimal strategy layer with decision → goal decomposition - #16

Open
divo12 wants to merge 5 commits into
cursor/company-brain-restacked-6abefrom
cursor/strategy-stacked-6abe
Open

feat(strategy): Add minimal strategy layer with decision → goal decomposition#16
divo12 wants to merge 5 commits into
cursor/company-brain-restacked-6abefrom
cursor/strategy-stacked-6abe

Conversation

@divo12

@divo12 divo12 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Strategy Layer

Stacked on #15 — This PR adds the strategy layer on top of the company brain.

Changes

  • Decision → Goal decomposition: stratiki strategy seed <description> parses a decision and decomposes it into ranked goals
  • Goal listing: stratiki strategy list shows all seeded decisions and their goals
  • Storage: Decisions stored in ~/.stratiki/strategy/ (correct path from the start)
  • Grounding: Goals grounded against the company wiki at ~/.stratiki/wiki/
  • Type safety: JSON.parse calls have proper type assertions

Implementation

  • Parser (strategy/parser.ts): Parses decision seed input
  • Decomposer (strategy/decomposer.ts): Decomposes decisions into goals using the company wiki context
  • Store (strategy/store.ts): Persists decisions and goals as JSON files
  • Types (strategy/types.ts): Decision and Goal interfaces
  • Tests: Unit tests for parser and decomposer

Storage Path

Storage location is ~/.stratiki/strategy/ via getStratikiStrategyDir():

  • ✅ Uses lazy getter to avoid homedir() during test init
  • ✅ Strategy directory created by ensureStratikiHome()
  • ✅ Grounded against company wiki at ~/.stratiki/wiki/

What's NOT Included

Per requirements:

  • ❌ No scheduler
  • ❌ No agent runtime
  • ❌ No Horizon clone

Testing

  • All tests pass
  • Linter passes
  • Manual verification of storage path and company wiki grounding

This is a focused PR that adds the minimal strategy layer without scheduler or runtime complexity.

Open in Web Open in Cursor 

Summary by cubic

Adds a minimal strategy layer that turns a seeded decision into ranked goals grounded in the company wiki, exposed via two new CLI commands: stratiki strategy seed <description> and stratiki strategy list.

New Features

  • Goals are ranked by how well they're grounded in existing wiki pages.
  • Decisions and goals persist as JSON under ~/.stratiki/strategy/, created lazily by ensureStratikiHome().
  • Seeds validate the description (non-empty, 500 chars max), split it into goals, and record grounding paths from the wiki index.
  • Scheduler, agent runtime, and Horizon clone are intentionally out of scope.

Written for commit 44cb495. Summary will update on new commits.

Review in cubic

@cursor
cursor Bot deleted the branch cursor/company-brain-restacked-6abe September 1, 2026 07:11
@cursor
cursor Bot deleted the cursor/strategy-stacked-6abe branch September 1, 2026 07:11

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

14 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/strategy/parser.ts">

<violation number="1" location="src/strategy/parser.ts:14">
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.</violation>
</file>

<file name="src/strategy/types.ts">

<violation number="1" location="src/strategy/types.ts:13">
P2: After a save→load round trip through FileStrategyStore, `decision.createdAt` and `goal.createdAt`/`updatedAt` come back as ISO strings, not `Date`, because the store writes with `JSON.stringify` and reads with `JSON.parse(...) as Decision[]` without reviving dates. Any caller that trusts the `Date` type and calls `.getTime()`/`.toISOString()` (e.g. goal decay or ordering by `createdAt`) will throw or misbehave. Either deserialize the date fields in the store when reading, or declare these fields as ISO `string` so the persisted shape matches the type contract.</violation>
</file>

<file name="src/cli/runners.ts">

<violation number="1" location="src/cli/runners.ts:452">
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.</violation>

<violation number="2" location="src/cli/runners.ts:464">
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.</violation>
</file>

<file name="src/strategy/store.ts">

<violation number="1" location="src/strategy/store.ts:25">
P1: Concurrent `strategy seed` processes can lose decisions: each reads the same array, appends locally, and the last `writeFile` overwrites the other. Serialize these updates with a lock or use an atomic datastore.</violation>

<violation number="2" location="src/strategy/store.ts:62">
P2: After persistence, `listDecisions()` and `getGoalsForDecision()` return ISO strings where the `Decision` and `Goal` contracts require `Date` objects. Rehydrate decision and goal timestamp fields when loading JSON.</violation>

<violation number="3" location="src/strategy/store.ts:62">
P2: After a save/load round trip, `createdAt` (and `updatedAt` for goals) are no longer `Date` objects. `JSON.stringify(new Date())` emits an ISO string and `JSON.parse` returns a plain string, but `JSON.parse(content) as Decision[]` asserts them back to `Date`. Any consumer calling `.getTime()`, `.toISOString()`, or comparing dates on values returned by `listDecisions()`/`getGoalsForDecision()` will silently misbehave. This contradicts the PR's claim of type-safe JSON handling. Revive the dates after parsing (or store timestamps as strings and adjust the types accordingly).</violation>

<violation number="4" location="src/strategy/store.ts:63">
P1: When a persisted JSON file is malformed, the read methods return `[]` and the next save silently overwrites the file, discarding all existing decisions or goals. Return an empty array only for `ENOENT` and rethrow parse or filesystem errors.</violation>
</file>

<file name="src/cli/commands.ts">

<violation number="1" location="src/cli/commands.ts:856">
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.</violation>
</file>

<file name="test/strategy/decomposer.test.ts">

<violation number="1" location="test/strategy/decomposer.test.ts:67">
P1: Test 1 fails and the grounding/ranking tests are vacuous: decomposeDecision issues a single FTS5 AND query over the whole concatenated description, so no wiki page matches and every goal.groundedIn is empty. The assertion `expect(goalsWithGrounding.length).toBeGreaterThan(0)` can never hold, while tests 2 and 3 assert `0 >= 0` / `totalGrounding === 0` and pass trivially, meaning the grounding and ranking behavior is not actually tested. Search each goal's own text (split the description) instead of the full description, and assert on real grounded entries.</violation>

<violation number="2" location="test/strategy/decomposer.test.ts:101">
P2: The `if (authGoal && coffeeGoal)` guard turns the assertions into a no-op whenever either goal is missing, so a regression that stops extracting one of the two goals (e.g., `parseGoalsFromDescription` failing to split on the period boundary) would silently pass this test. Guarding a test's only assertions hides failures instead of surfacing them — replace it with an explicit `expect(authGoal).toBeDefined()` and `expect(coffeeGoal).toBeDefined()` so the test fails loudly when the expected goals are not produced.</violation>

<violation number="3" location="test/strategy/decomposer.test.ts:102">
P2: The test "ranks goals with better grounding higher" never asserts on the `rank` field, so it does not verify ranking — it only compares `groundedIn.length`. The name promises ranking behavior, but the single structured assertion checks grounding counts, and the enclosing `if (authGoal && coffeeGoal)` guard silently skips even that if either goal isn't found. Assert that both goals exist and assert rank ordering directly.</violation>
</file>

<file name="src/strategy/decomposer.ts">

<violation number="1" location="src/strategy/decomposer.ts:17">
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.</violation>

<violation number="2" location="src/strategy/decomposer.ts:97">
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.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/strategy/store.ts
const { mkdir, writeFile } = await import("node:fs/promises");
await mkdir(this.strategyDir, { recursive: true });

const decisions = await this.listDecisions();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Concurrent strategy seed processes can lose decisions: each reads the same array, appends locally, and the last writeFile overwrites the other. Serialize these updates with a lock or use an atomic datastore.

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

<comment>Concurrent `strategy seed` processes can lose decisions: each reads the same array, appends locally, and the last `writeFile` overwrites the other. Serialize these updates with a lock or use an atomic datastore.</comment>

<file context>
@@ -0,0 +1,80 @@
+    const { mkdir, writeFile } = await import("node:fs/promises");
+    await mkdir(this.strategyDir, { recursive: true });
+
+    const decisions = await this.listDecisions();
+    decisions.push(decision);
+
</file context>

Comment thread src/strategy/store.ts
"utf8",
);
return JSON.parse(content) as Decision[];
} catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When a persisted JSON file is malformed, the read methods return [] and the next save silently overwrites the file, discarding all existing decisions or goals. Return an empty array only for ENOENT and rethrow parse or filesystem errors.

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

<comment>When a persisted JSON file is malformed, the read methods return `[]` and the next save silently overwrites the file, discarding all existing decisions or goals. Return an empty array only for `ENOENT` and rethrow parse or filesystem errors.</comment>

<file context>
@@ -0,0 +1,80 @@
+        "utf8",
+      );
+      return JSON.parse(content) as Decision[];
+    } catch {
+      return [];
+    }
</file context>

const goalsWithGrounding = result.goals.filter(
(g) => g.groundedIn.length > 0,
);
expect(goalsWithGrounding.length).toBeGreaterThan(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Test 1 fails and the grounding/ranking tests are vacuous: decomposeDecision issues a single FTS5 AND query over the whole concatenated description, so no wiki page matches and every goal.groundedIn is empty. The assertion expect(goalsWithGrounding.length).toBeGreaterThan(0) can never hold, while tests 2 and 3 assert 0 >= 0 / totalGrounding === 0 and pass trivially, meaning the grounding and ranking behavior is not actually tested. Search each goal's own text (split the description) instead of the full description, and assert on real grounded entries.

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

<comment>Test 1 fails and the grounding/ranking tests are vacuous: decomposeDecision issues a single FTS5 AND query over the whole concatenated description, so no wiki page matches and every goal.groundedIn is empty. The assertion `expect(goalsWithGrounding.length).toBeGreaterThan(0)` can never hold, while tests 2 and 3 assert `0 >= 0` / `totalGrounding === 0` and pass trivially, meaning the grounding and ranking behavior is not actually tested. Search each goal's own text (split the description) instead of the full description, and assert on real grounded entries.</comment>

<file context>
@@ -0,0 +1,139 @@
+      const goalsWithGrounding = result.goals.filter(
+        (g) => g.groundedIn.length > 0,
+      );
+      expect(goalsWithGrounding.length).toBeGreaterThan(0);
+    } finally {
+      index.close();
</file context>

Comment thread src/strategy/parser.ts
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) {

Comment thread src/strategy/types.ts
export interface Decision {
readonly id: string;
readonly description: string;
readonly createdAt: Date;

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: After a save→load round trip through FileStrategyStore, decision.createdAt and goal.createdAt/updatedAt come back as ISO strings, not Date, because the store writes with JSON.stringify and reads with JSON.parse(...) as Decision[] without reviving dates. Any caller that trusts the Date type and calls .getTime()/.toISOString() (e.g. goal decay or ordering by createdAt) will throw or misbehave. Either deserialize the date fields in the store when reading, or declare these fields as ISO string so the persisted shape matches the type contract.

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

<comment>After a save→load round trip through FileStrategyStore, `decision.createdAt` and `goal.createdAt`/`updatedAt` come back as ISO strings, not `Date`, because the store writes with `JSON.stringify` and reads with `JSON.parse(...) as Decision[]` without reviving dates. Any caller that trusts the `Date` type and calls `.getTime()`/`.toISOString()` (e.g. goal decay or ordering by `createdAt`) will throw or misbehave. Either deserialize the date fields in the store when reading, or declare these fields as ISO `string` so the persisted shape matches the type contract.</comment>

<file context>
@@ -0,0 +1,39 @@
+export interface Decision {
+  readonly id: string;
+  readonly description: string;
+  readonly createdAt: Date;
+  readonly status: DecisionStatus;
+}
</file context>

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}`

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>

Comment thread src/strategy/store.ts
`${this.strategyDir}/decisions.json`,
"utf8",
);
return JSON.parse(content) as Decision[];

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: After a save/load round trip, createdAt (and updatedAt for goals) are no longer Date objects. JSON.stringify(new Date()) emits an ISO string and JSON.parse returns a plain string, but JSON.parse(content) as Decision[] asserts them back to Date. Any consumer calling .getTime(), .toISOString(), or comparing dates on values returned by listDecisions()/getGoalsForDecision() will silently misbehave. This contradicts the PR's claim of type-safe JSON handling. Revive the dates after parsing (or store timestamps as strings and adjust the types accordingly).

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

<comment>After a save/load round trip, `createdAt` (and `updatedAt` for goals) are no longer `Date` objects. `JSON.stringify(new Date())` emits an ISO string and `JSON.parse` returns a plain string, but `JSON.parse(content) as Decision[]` asserts them back to `Date`. Any consumer calling `.getTime()`, `.toISOString()`, or comparing dates on values returned by `listDecisions()`/`getGoalsForDecision()` will silently misbehave. This contradicts the PR's claim of type-safe JSON handling. Revive the dates after parsing (or store timestamps as strings and adjust the types accordingly).</comment>

<file context>
@@ -0,0 +1,80 @@
+        `${this.strategyDir}/decisions.json`,
+        "utf8",
+      );
+      return JSON.parse(content) as Decision[];
+    } catch {
+      return [];
</file context>

Comment thread src/cli/commands.ts
};
}

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>

Comment thread src/cli/runners.ts
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>

cursoragent and others added 5 commits September 1, 2026 07:19
- 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 <divo12@users.noreply.github.com>
- 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 <divo12@users.noreply.github.com>
….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 <divo12@users.noreply.github.com>
Co-authored-by: divo12 <divo12@users.noreply.github.com>
- Add getStratikiStrategyDir() lazy getter
- Update runStrategyCommand to use getStratikiStrategyDir() for storage
- Update runStrategyCommand to use getStratikiCompanyWikiDir() for grounding
- Call ensureStratikiHome() before saving decisions
- Update strategy dir creation in ensureStratikiHome()
- Remove unused openWikiStrategyDir constant
- Update store.ts documentation to specify ~/.stratiki/strategy

This ensures strategy decisions are stored in ~/.stratiki/strategy and
grounded against the company brain wiki at ~/.stratiki/wiki.

Co-authored-by: divo12 <divo12@users.noreply.github.com>
@cursor
cursor Bot force-pushed the cursor/strategy-stacked-6abe branch from f86c9e2 to 44cb495 Compare September 1, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants