Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# POETIC Platform Overview
# PROSE Platform Overview

> POETIC = **P**urpose, **O**bservables, **E**dges, **T**riggers, **I**nvariants, **C**ontracts
> PROSE = **P**urpose, **R**ules & Constraints, **O**bservables, **S**cenarios, **E**xpectations

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

Expand All @@ -18,6 +18,21 @@ Decisions, frames, and anchors written in one session must be retrievable in a s

---

## R — Rules & Constraints

Boundaries the platform must respect.

### R.1 Uninitialized projects
Commands that require an initialized project must fail gracefully with a clear message when run outside a `.stackmemory` project.

### R.2 Empty result sets
Search queries that match nothing must return an empty result set, not an error.

### R.3 Idempotent initialization
Running `stackmemory init` in an already-initialized project must not corrupt existing data.

---

## O — Observables

These are the externally visible states and queries the platform must support.
Expand All @@ -36,89 +51,64 @@ After recording a decision, `stackmemory decision list` must include the decisio

---

## EEdges & Constraints
## SScenarios

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.
Events and actions that change platform state.

### 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
### S.1 Frame push
Pushing a frame creates a new scoped context entry and updates the active frame stack.

### T.2 Frame pop
### S.2 Frame pop
Popping a frame removes the active frame and restores the previous frame as active.

### T.3 Decision record
### S.3 Decision record
Recording a decision persists it with a timestamp and rationale.

### T.4 Snapshot capture
### S.4 Snapshot capture
Capturing a snapshot persists the current context state for later handoff.

---

## IInvariants
## EExpectations

Properties that must always hold true.
Properties that must always hold true and guarantees the platform makes to users and integrations.

### I.1 Frame stack integrity
### E.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
### E.2 Decision immutability
Once recorded, a decision's identifier, title, and rationale must not change.

### I.3 Project isolation
### E.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
### E.4 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
### E.5 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 |
|-----------|-----------|
| PROSE ID | Test case |
|----------|-----------|
| P.1 | `initializes a project with stackmemory init` |
| P.2 | `retrieves decisions across sessions` |
| R.1 | `fails gracefully outside an initialized project` |
| R.2 | `returns empty results for non-matching search` |
| R.3 | `init is idempotent` |
| 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` |
| S.1 | `pushing a frame creates a scoped entry` |
| S.2 | `popping a frame restores the previous frame` |
| S.3 | `recording a decision persists rationale` |
| S.4 | `capturing a snapshot persists handoff state` |
| E.1 | `active frame stack remains consistent` |
| E.2 | `recorded decisions are immutable` |
| E.3 | `projects in different directories are isolated` |
| E.4 | `CLI commands return correct exit codes` |
| E.5 | `SQLite database is self-contained in .stackmemory` |
130 changes: 64 additions & 66 deletions src/__tests__/integration/platform-overview.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* POETIC Platform Overview Integration Tests
* PROSE Platform Overview Integration Tests
*
* POETIC = Purpose, Observables, Edges, Triggers, Invariants, Contracts
* Spec source: docs/specs/POETIC-platform-overview.md
* PROSE = Purpose, Rules & Constraints, Observables, Scenarios, Expectations
* Spec source: docs/specs/PROSE-platform-overview.md
*
* Each test maps to a POETIC ID in the spec so prose and code stay coupled.
* Each test maps to a PROSE ID in the spec so prose and code stay coupled.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
Expand Down Expand Up @@ -49,13 +49,13 @@ function run(args: string, cwd: string, expectError = false): string {
}
}

describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
describe('PROSE 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)}`
`stackmemory-prose-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
Expand Down Expand Up @@ -96,6 +96,52 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
});
});

// ---------------------------------------------------------------------------
// R — Rules & Constraints
// ---------------------------------------------------------------------------

describe('R.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('R.2 Empty result sets', () => {
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');
});
});

describe('R.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());
});
});

// ---------------------------------------------------------------------------
// O — Observables
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -154,57 +200,13 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
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');
});
});

// ---------------------------------------------------------------------------
// EEdges & Constraints
// SScenarios
// ---------------------------------------------------------------------------

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', () => {
describe('S.1 Frame push', () => {
it('pushing a frame creates a scoped entry', () => {
run('init', testDir);
run('context push "Feature: payment flow"', testDir);
Expand All @@ -214,7 +216,7 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
});
});

describe('T.2 Frame pop', () => {
describe('S.2 Frame pop', () => {
it('popping a frame restores the previous frame', () => {
run('init', testDir);
run('context push "Outer frame"', testDir);
Expand All @@ -232,7 +234,7 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
});
});

describe('T.3 Decision record', () => {
describe('S.3 Decision record', () => {
it('recording a decision persists rationale', () => {
run('init', testDir);
run('decision add "Use pnpm" --why "Fast, disk efficient"', testDir);
Expand All @@ -243,7 +245,7 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
});
});

describe('T.4 Snapshot capture', () => {
describe('S.4 Snapshot capture', () => {
it('capturing a snapshot persists handoff state', () => {
run('init', testDir);
run(
Expand All @@ -252,18 +254,18 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
);

const captureOutput = run(
'capture --no-commit -m "POETIC snapshot"',
'capture --no-commit -m "PROSE snapshot"',
testDir
);
expect(captureOutput).toMatch(/handoff|snapshot|capture/i);
});
});

// ---------------------------------------------------------------------------
// IInvariants
// EExpectations
// ---------------------------------------------------------------------------

describe('I.1 Frame stack integrity', () => {
describe('E.1 Frame stack integrity', () => {
it('active frame stack remains consistent', () => {
run('init', testDir);
run('context push "Alpha frame"', testDir);
Expand All @@ -280,7 +282,7 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
});
});

describe('I.2 Decision immutability', () => {
describe('E.2 Decision immutability', () => {
it('recorded decisions are immutable', () => {
run('init', testDir);
run(
Expand All @@ -302,7 +304,7 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
});
});

describe('I.3 Project isolation', () => {
describe('E.3 Project isolation', () => {
it('projects in different directories are isolated', () => {
const projectA = path.join(testDir, 'project-a');
const projectB = path.join(testDir, 'project-b');
Expand All @@ -320,11 +322,7 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
});
});

// ---------------------------------------------------------------------------
// C — Contracts
// ---------------------------------------------------------------------------

describe('C.1 CLI contract', () => {
describe('E.4 CLI contract', () => {
it('CLI commands return correct exit codes', () => {
expect(() => run('init', testDir)).not.toThrow();
expect(() => run('decision list', testDir)).not.toThrow();
Expand All @@ -334,7 +332,7 @@ describe('POETIC Platform Overview', { timeout: 60_000 }, () => {
});
});

describe('C.2 SQLite contract', () => {
describe('E.5 SQLite contract', () => {
it('SQLite database is self-contained in .stackmemory', () => {
run('init', testDir);
run('decision add "DB test" --why "Check local DB"', testDir);
Expand Down
Loading