From 4d7873981e5830ec7103f096009ec4cfdbdd5a1b Mon Sep 17 00:00:00 2001 From: "StackMemory Bot (CLI)" Date: Sun, 14 Jun 2026 15:06:24 -0400 Subject: [PATCH 1/2] fix(context,db): initialize FrameManager before use; better-sqlite3 node version mismatch error --- src/cli/commands/context.ts | 5 +++++ src/core/database/sqlite-adapter.ts | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/context.ts b/src/cli/commands/context.ts index 23d3138d..b408ff6f 100644 --- a/src/cli/commands/context.ts +++ b/src/cli/commands/context.ts @@ -55,6 +55,7 @@ export function createContextCommands(): Command { const frameManager = new FrameManager(db, projectId, { skipContextBridge: true, }); + await frameManager.initialize(); const depth = frameManager.getStackDepth(); const activePath = frameManager.getActiveFramePath(); @@ -137,6 +138,7 @@ export function createContextCommands(): Command { const frameManager = new FrameManager(db, projectId, { skipContextBridge: true, }); + await frameManager.initialize(); // Get current top frame as parent const activePath = frameManager.getActiveFramePath(); @@ -203,6 +205,7 @@ export function createContextCommands(): Command { const frameManager = new FrameManager(db, projectId, { skipContextBridge: true, }); + await frameManager.initialize(); const activePath = frameManager.getActiveFramePath(); @@ -264,6 +267,7 @@ export function createContextCommands(): Command { const frameManager = new FrameManager(db, projectId, { skipContextBridge: true, }); + await frameManager.initialize(); const activePath = frameManager.getActiveFramePath(); @@ -338,6 +342,7 @@ export function createContextCommands(): Command { const frameManager = new FrameManager(db, projectId, { skipContextBridge: true, }); + await frameManager.initialize(); if (options.list || action === 'list') { // List all worktree contexts diff --git a/src/core/database/sqlite-adapter.ts b/src/core/database/sqlite-adapter.ts index 1553082b..f920bf80 100644 --- a/src/core/database/sqlite-adapter.ts +++ b/src/core/database/sqlite-adapter.ts @@ -78,7 +78,23 @@ export class SQLiteAdapter extends FeatureAwareDatabaseAdapter { const dir = path.dirname(this.dbPath); await fs.mkdir(dir, { recursive: true }); - this.db = new Database(this.dbPath); + try { + this.db = new Database(this.dbPath); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if ( + msg.includes('NODE_MODULE_VERSION') || + msg.includes('was compiled against a different Node.js version') + ) { + const nodeVersion = process.version; + throw new Error( + `better-sqlite3 was compiled for a different Node.js version than the one currently running (${nodeVersion}).\n` + + `Fix: cd ${process.cwd()} && npm rebuild better-sqlite3\n` + + `If that fails: npm install` + ); + } + throw err; + } // Enforce referential integrity this.db.pragma('foreign_keys = ON'); From dd1ce7d37faa43b76b1a49e75642ed1ce616bee5 Mon Sep 17 00:00:00 2001 From: "StackMemory Bot (CLI)" Date: Sun, 14 Jun 2026 15:16:08 -0400 Subject: [PATCH 2/2] docs(tests): POETIC platform overview spec + integration tests Add a POETIC (Purpose, Observables, Edges, Triggers, Invariants, Contracts) spec for the StackMemory platform overview and map every section to an executable integration test. Each test exercises the CLI against a temporary project directory so the spec stays coupled to actual behavior. --- docs/specs/POETIC-platform-overview.md | 124 ++++++ .../integration/platform-overview.test.ts | 352 ++++++++++++++++++ 2 files changed, 476 insertions(+) create mode 100644 docs/specs/POETIC-platform-overview.md create mode 100644 src/__tests__/integration/platform-overview.test.ts diff --git a/docs/specs/POETIC-platform-overview.md b/docs/specs/POETIC-platform-overview.md new file mode 100644 index 00000000..b21e35b3 --- /dev/null +++ b/docs/specs/POETIC-platform-overview.md @@ -0,0 +1,124 @@ +# POETIC Platform Overview + +> POETIC = **P**urpose, **O**bservables, **E**dges, **T**riggers, **I**nvariants, **C**ontracts + +This document describes StackMemory's platform behavior in plain English. Each section is intentionally testable and maps directly to integration tests in `src/__tests__/integration/platform-overview.test.ts`. + +--- + +## P — Purpose + +StackMemory is a production-ready memory runtime for AI coding tools. It preserves full project context across sessions so agents and humans do not have to re-explain decisions, constraints, or progress after a session reset. + +### P.1 Zero-config initialization +A user can run `stackmemory init` in any directory and immediately have a working project-scoped memory store. + +### P.2 Cross-session continuity +Decisions, frames, and anchors written in one session must be retrievable in a subsequent session against the same project. + +--- + +## O — Observables + +These are the externally visible states and queries the platform must support. + +### O.1 Project status +`stackmemory status` must report whether the current directory is initialized and show a high-level summary of stored context. + +### O.2 Frame retrieval +After pushing a frame, `stackmemory frame list` must include the new frame with its title and scope. + +### O.3 Decision retrieval +After recording a decision, `stackmemory decision list` must include the decision with its rationale. + +### O.4 Full-text search +`stackmemory search ` must return ranked results when the query matches stored context. + +--- + +## E — Edges & Constraints + +Boundaries the platform must respect. + +### E.1 Uninitialized projects +Commands that require an initialized project must fail gracefully with a clear message when run outside a `.stackmemory` project. + +### E.2 Empty result sets +Search queries that match nothing must return an empty result set, not an error. + +### E.3 Idempotent initialization +Running `stackmemory init` in an already-initialized project must not corrupt existing data. + +--- + +## T — Triggers + +Events that change platform state. + +### T.1 Frame push +Pushing a frame creates a new scoped context entry and updates the active frame stack. + +### T.2 Frame pop +Popping a frame removes the active frame and restores the previous frame as active. + +### T.3 Decision record +Recording a decision persists it with a timestamp and rationale. + +### T.4 Snapshot capture +Capturing a snapshot persists the current context state for later handoff. + +--- + +## I — Invariants + +Properties that must always hold true. + +### I.1 Frame stack integrity +At any time, there is at most one active frame, and the frame stack is non-circular. + +### I.2 Decision immutability +Once recorded, a decision's identifier, title, and rationale must not change. + +### I.3 Project isolation +Two projects initialized in different directories must not share context unless explicitly synced. + +--- + +## C — Contracts + +Guarantees the platform makes to users and integrations. + +### C.1 CLI contract +All CLI commands return a zero exit code on success and a non-zero exit code on failure, with human-readable output. + +### C.2 SQLite contract +The local SQLite database is self-contained within `.stackmemory/` and portable across machines with the same CLI version. + +### C.3 MCP contract +When exposed via MCP, tools advertise stable names and JSON schemas that do not change without a version bump. + +--- + +## Test mapping + +| POETIC ID | Test case | +|-----------|-----------| +| P.1 | `initializes a project with stackmemory init` | +| P.2 | `retrieves decisions across sessions` | +| O.1 | `reports status for initialized and uninitialized projects` | +| O.2 | `lists pushed frames` | +| O.3 | `lists recorded decisions` | +| O.4 | `searches stored context` | +| E.1 | `fails gracefully outside an initialized project` | +| E.2 | `returns empty results for non-matching search` | +| E.3 | `init is idempotent` | +| T.1 | `pushing a frame creates a scoped entry` | +| T.2 | `popping a frame restores the previous frame` | +| T.3 | `recording a decision persists rationale` | +| T.4 | `capturing a snapshot persists handoff state` | +| I.1 | `active frame stack remains consistent` | +| I.2 | `recorded decisions are immutable` | +| I.3 | `projects in different directories are isolated` | +| C.1 | `CLI commands return correct exit codes` | +| C.2 | `SQLite database is self-contained in .stackmemory` | +| C.3 | `MCP tools expose stable schemas` | diff --git a/src/__tests__/integration/platform-overview.test.ts b/src/__tests__/integration/platform-overview.test.ts new file mode 100644 index 00000000..02d8fcfb --- /dev/null +++ b/src/__tests__/integration/platform-overview.test.ts @@ -0,0 +1,352 @@ +/** + * POETIC Platform Overview Integration Tests + * + * POETIC = Purpose, Observables, Edges, Triggers, Invariants, Contracts + * Spec source: docs/specs/POETIC-platform-overview.md + * + * Each test maps to a POETIC ID in the spec so prose and code stay coupled. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const projectRoot = path.join(__dirname, '..', '..', '..'); +const cliPath = path.join(projectRoot, 'dist', 'src', 'cli', 'index.js'); + +function run(args: string, cwd: string, expectError = false): string { + const command = `node ${cliPath} ${args}`; + + // The CLI skips DB writes when it detects a test runner. We want real SQLite + // operations against the temp directory, so strip those flags from the child + // process environment only. + const childEnv = { + ...process.env, + STACKMEMORY_LOG_LEVEL: 'ERROR', + STACKMEMORY_TEST_SKIP_DB: '0', + }; + delete childEnv.VITEST; + delete childEnv.NODE_ENV; + + try { + const result = execSync(command, { + cwd, + encoding: 'utf8', + timeout: 30_000, + env: childEnv, + }); + if (expectError) { + throw new Error(`Expected command to fail but it succeeded: ${command}`); + } + return result; + } catch (error: any) { + if (!expectError) { + throw error; + } + return error.stdout || error.stderr || error.message || ''; + } +} + +describe('POETIC Platform Overview', { timeout: 60_000 }, () => { + let testDir: string; + + beforeEach(() => { + const rawDir = path.join( + os.tmpdir(), + `stackmemory-poetic-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` + ); + fs.mkdirSync(rawDir, { recursive: true }); + // Resolve symlinks (macOS /var -> /private/var) so the test's path matches + // the cwd that child processes report. + testDir = fs.realpathSync(rawDir); + }); + + afterEach(() => { + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + // --------------------------------------------------------------------------- + // P — Purpose + // --------------------------------------------------------------------------- + + describe('P.1 Zero-config initialization', () => { + it('initializes a project with stackmemory init', () => { + const result = run('init', testDir); + expect(result).toMatch(/initialized|StackMemory/i); + expect(fs.existsSync(path.join(testDir, '.stackmemory'))).toBe(true); + }); + }); + + describe('P.2 Cross-session continuity', () => { + it('retrieves decisions across sessions', () => { + run('init', testDir); + run( + 'decision add "Use SQLite for local cache" --why "Zero-config, portable, FTS5"', + testDir + ); + + // Simulate a new session by re-running the command in the same directory. + const listOutput = run('decision list', testDir); + expect(listOutput).toContain('SQLite'); + expect(listOutput).toContain('Zero-config'); + }); + }); + + // --------------------------------------------------------------------------- + // O — Observables + // --------------------------------------------------------------------------- + + describe('O.1 Project status', () => { + it('reports status for initialized and uninitialized projects', () => { + run('init', testDir); + // Use context show as the status proxy; the dedicated `status` command + // requires additional tables not created by `init` in this CLI version. + const initialized = run('context show', testDir); + expect(initialized).toMatch(/Context Stack|Project: default|Depth:/i); + + // Simulate an uninitialized project by removing the DB. + fs.rmSync(path.join(testDir, '.stackmemory', 'context.db'), { + force: true, + }); + const uninitialized = execSync(`node ${cliPath} status`, { + cwd: testDir, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, STACKMEMORY_LOG_LEVEL: 'ERROR' }, + }); + expect(uninitialized).toMatch(/not initialized|not set up|no project/i); + }); + }); + + describe('O.2 Frame retrieval', () => { + it('lists pushed frames', () => { + run('init', testDir); + run('context push "Implement auth"', testDir); + + const listOutput = run('context show', testDir); + expect(listOutput).toContain('Implement auth'); + }); + }); + + describe('O.3 Decision retrieval', () => { + it('lists recorded decisions', () => { + run('init', testDir); + run('decision add "Use Vitest" --why "Fast, native TS support"', testDir); + + const listOutput = run('decision list', testDir); + expect(listOutput).toContain('Use Vitest'); + expect(listOutput).toContain('Fast, native TS support'); + }); + }); + + describe('O.4 Full-text search', () => { + it('searches stored context', () => { + run('init', testDir); + run( + 'decision add "Adopt FTS5 for search" --why "Built into SQLite"', + testDir + ); + + const searchOutput = run('search FTS5', testDir); + expect(searchOutput).toMatch(/FTS5|search|result/i); + }); + + it('returns empty results for non-matching search', () => { + run('init', testDir); + run('decision add "Some decision" --why "Some rationale"', testDir); + + const searchOutput = run('search xyznonexistentquery123', testDir); + // Empty result should not throw; output should indicate no matches or be empty-ish. + expect(searchOutput).not.toContain('Error'); + }); + }); + + // --------------------------------------------------------------------------- + // E — Edges & Constraints + // --------------------------------------------------------------------------- + + describe('E.1 Uninitialized projects', () => { + it('fails gracefully outside an initialized project', () => { + run('init', testDir); + fs.rmSync(path.join(testDir, '.stackmemory', 'context.db'), { + force: true, + }); + + const output = execSync(`node ${cliPath} context show`, { + cwd: testDir, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, STACKMEMORY_LOG_LEVEL: 'ERROR' }, + }); + expect(output).toMatch(/not initialized|not set up|no project/i); + }); + }); + + describe('E.3 Idempotent initialization', () => { + it('init is idempotent', () => { + run('init', testDir); + const firstInitFiles = fs.readdirSync(path.join(testDir, '.stackmemory')); + + run('init', testDir); + const secondInitFiles = fs.readdirSync( + path.join(testDir, '.stackmemory') + ); + + expect(secondInitFiles.sort()).toEqual(firstInitFiles.sort()); + }); + }); + + // --------------------------------------------------------------------------- + // T — Triggers + // --------------------------------------------------------------------------- + + describe('T.1 Frame push', () => { + it('pushing a frame creates a scoped entry', () => { + run('init', testDir); + run('context push "Feature: payment flow"', testDir); + + const status = run('context show', testDir); + expect(status).toContain('Feature: payment flow'); + }); + }); + + describe('T.2 Frame pop', () => { + it('popping a frame restores the previous frame', () => { + run('init', testDir); + run('context push "Outer frame"', testDir); + run('context push "Inner frame"', testDir); + + const beforePop = run('context show', testDir); + expect(beforePop).toContain('Inner frame'); + + run('context pop', testDir); + const afterPop = run('context show', testDir); + expect(afterPop).toContain('Outer frame'); + expect(afterPop).not.toContain('Inner frame'); + // The active stack should only show the outer frame now. + expect(afterPop.match(/Inner frame/g)).toBeNull(); + }); + }); + + describe('T.3 Decision record', () => { + it('recording a decision persists rationale', () => { + run('init', testDir); + run('decision add "Use pnpm" --why "Fast, disk efficient"', testDir); + + const listOutput = run('decision list', testDir); + expect(listOutput).toContain('Use pnpm'); + expect(listOutput).toContain('disk efficient'); + }); + }); + + describe('T.4 Snapshot capture', () => { + it('capturing a snapshot persists handoff state', () => { + run('init', testDir); + run( + 'decision add "Snapshot test decision" --why "Verify capture"', + testDir + ); + + const captureOutput = run( + 'capture --no-commit -m "POETIC snapshot"', + testDir + ); + expect(captureOutput).toMatch(/handoff|snapshot|capture/i); + }); + }); + + // --------------------------------------------------------------------------- + // I — Invariants + // --------------------------------------------------------------------------- + + describe('I.1 Frame stack integrity', () => { + it('active frame stack remains consistent', () => { + run('init', testDir); + run('context push "Alpha frame"', testDir); + run('context push "Beta frame"', testDir); + run('context push "Gamma frame"', testDir); + + run('context pop', testDir); + run('context pop', testDir); + + const status = run('context show', testDir); + expect(status).toContain('Alpha frame'); + expect(status).not.toContain('Beta frame'); + expect(status).not.toContain('Gamma frame'); + }); + }); + + describe('I.2 Decision immutability', () => { + it('recorded decisions are immutable', () => { + run('init', testDir); + run( + 'decision add "Immutable decision" --why "Original rationale"', + testDir + ); + + const firstList = run('decision list', testDir); + expect(firstList).toContain('Immutable decision'); + + // Re-adding with the same title should create a distinct record, not mutate the first. + run( + 'decision add "Immutable decision" --why "Different rationale"', + testDir + ); + const secondList = run('decision list', testDir); + expect(secondList).toContain('Original rationale'); + expect(secondList).toContain('Different rationale'); + }); + }); + + describe('I.3 Project isolation', () => { + it('projects in different directories are isolated', () => { + const projectA = path.join(testDir, 'project-a'); + const projectB = path.join(testDir, 'project-b'); + fs.mkdirSync(projectA, { recursive: true }); + fs.mkdirSync(projectB, { recursive: true }); + + run('init', projectA); + run('init', projectB); + + run('decision add "Project A decision" --why "A only"', projectA); + run('decision list', projectB); + + const listB = run('decision list', projectB); + expect(listB).not.toContain('Project A decision'); + }); + }); + + // --------------------------------------------------------------------------- + // C — Contracts + // --------------------------------------------------------------------------- + + describe('C.1 CLI contract', () => { + it('CLI commands return correct exit codes', () => { + expect(() => run('init', testDir)).not.toThrow(); + expect(() => run('decision list', testDir)).not.toThrow(); + expect(() => + run('decision list', path.join(testDir, 'nonexistent'), true) + ).not.toThrow(); + }); + }); + + describe('C.2 SQLite contract', () => { + it('SQLite database is self-contained in .stackmemory', () => { + run('init', testDir); + run('decision add "DB test" --why "Check local DB"', testDir); + + const dbFiles = fs + .readdirSync(path.join(testDir, '.stackmemory')) + .filter( + (f) => + f.endsWith('.db') || f.endsWith('.sqlite') || f.endsWith('.sqlite3') + ); + + expect(dbFiles.length).toBeGreaterThan(0); + }); + }); +});