feat(cli): extract Obelisk runtime into npm package

Add @obelisk-apps/cli with the existing build, search, query, and attune contract plus official skill installation.

Separate the docs-only skill artifact, bootstrap installer, release layout, cross-platform CI, and package-level regression coverage.
This commit is contained in:
tommy0103
2026-07-16 17:26:23 +08:00
parent 49158e9b9c
commit 90191e4604
64 changed files with 1112 additions and 745 deletions
+32
View File
@@ -0,0 +1,32 @@
name: CLI
on:
pull_request:
push:
branches: [main]
jobs:
package:
name: Node 22 / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run build:cli
- name: Verify CLI package and runtime contract
run: node --experimental-test-module-mocks --test tests/cli-package.test.mjs tests/runtime-cli-envelope.test.mjs tests/runtime.test.mjs
- name: Verify POSIX bootstrap installer
if: runner.os != 'Windows'
run: node --test tests/cli-bootstrap-install.test.mjs
+7 -6
View File
@@ -10,8 +10,9 @@ not a spec.
**Runtime interface**: **Runtime interface**:
The public contract, expressed as four verbs — `build`, `search(text)`, The public contract, expressed as four verbs — `build`, `search(text)`,
`query(code)`, `attune(code)`. Skill, CLI, and MCP are transports over this same `query(code)`, `attune(code)`. CLI and a future MCP server are transports over
shape; none of them add their own retrieval surface. this same shape; neither adds its own retrieval surface. The agent skill is
docs-only guidance that invokes the CLI rather than a transport of its own.
_Avoid_: API, tool surface _Avoid_: API, tool surface
**CodeAct**: **CodeAct**:
@@ -44,7 +45,7 @@ persistence happens.
**Persist layer**: **Persist layer**:
The single shared, provider- and binding-agnostic writer that consumes records The single shared, provider- and binding-agnostic writer that consumes records
from any adapter and writes them into an injected SQLite handle inside a from any adapter and writes them into an injected SQLite handle inside a
transaction. The binding is injected — `node:sqlite` (skill/CLI) or transaction. The binding is injected — `node:sqlite` (CLI) or
`better-sqlite3` (app) — so there is one persist implementation, not one per `better-sqlite3` (app) — so there is one persist implementation, not one per
binding. binding.
_Avoid_: writer, sink, DAO _Avoid_: writer, sink, DAO
@@ -56,8 +57,8 @@ as files change.
_Avoid_: watcher mode, live indexing _Avoid_: watcher mode, live indexing
**Passive pull mode**: **Passive pull mode**:
On-demand incremental indexing performed by the skill when there is no active On-demand incremental indexing performed by a CLI invocation when there is no
daemon: an invocation of the runtime brings the index up to date, then answers. active daemon: the command brings the index up to date, then answers.
_Avoid_: lazy indexing, on-read indexing _Avoid_: lazy indexing, on-read indexing
**index_state**: **index_state**:
@@ -68,7 +69,7 @@ arbitration.
**Daemon arbitration**: **Daemon arbitration**:
The policy by which the passive pull mode detects a fresh daemon from the The policy by which the passive pull mode detects a fresh daemon from the
`__app_heartbeat__` marker and skips every skill-side mutation, including schema `__app_heartbeat__` marker and skips every CLI-side mutation, including schema
setup, indexing, checkpointing, and `attune`. The heartbeat alone means “the setup, indexing, checkpointing, and `attune`. The heartbeat alone means “the
daemon should write”; `__app_last_successful_build__` records coverage/freshness, daemon should write”; `__app_last_successful_build__` records coverage/freshness,
not ownership. Both indexing modes use the same persist layer. not ownership. Both indexing modes use the same persist layer.
+58 -31
View File
@@ -19,7 +19,9 @@ Every past Claude Code and Codex session -- queryable by your agent, browsable b
Obelisk has two sides that share one SQLite index: Obelisk has two sides that share one SQLite index:
**Skill side**an agent skill that lets coding agents search and query their own session history. The agent writes JS queries, runs them locally, answers in plain language. **Agent side**the `obelisk` CLI owns the local runtime, while a separate
agent skill teaches coding agents how to search and query their session history.
The agent writes JS queries, runs them locally, and answers in plain language.
**App side** — an Electron desktop app for humans to browse sessions, manage memories, view usage stats, and see weekly recap cards. **App side** — an Electron desktop app for humans to browse sessions, manage memories, view usage stats, and see weekly recap cards.
@@ -51,11 +53,31 @@ You can use obelisk like:
### Install ### Install
Obelisk requires Node.js 22.13 or newer. Install the platform-neutral CLI first:
```bash ```bash
npx skills add tommy0103/obelisk-skill npm install --global @obelisk-apps/cli
obelisk --version
``` ```
Or manually: copy `obelisk-skill/skills/obelisk into your project's `.claude/skills/` On macOS, Linux, or WSL, the CLI-only installer is equivalent:
```bash
curl -fsSL https://raw.githubusercontent.com/tommy0103/obelisk/main/install.sh | sh
```
Then install the agent skill:
```bash
obelisk install
```
`obelisk install` delegates to the standard skills installer for
`tommy0103/obelisk-skill`. To let an agent perform the one-time bootstrap, give
it the repository's root
[`SKILL.md`](https://raw.githubusercontent.com/tommy0103/obelisk/main/SKILL.md).
That document installs the CLI and formal skill; it is not the query skill
itself.
Then in any Claude Code session: Then in any Claude Code session:
@@ -72,7 +94,7 @@ You ask a question
Agent writes a JS query against the SQLite index Agent writes a JS query against the SQLite index
Runs it via node $SKILL_DIR/scripts/runtime.js --query <script> Runs it via obelisk --query <script>
Reads the JSON result, answers in natural language Reads the JSON result, answers in natural language
``` ```
@@ -81,11 +103,12 @@ Core API: `search()`, `context()`, `sql()`, plus structured helpers (`sessions`,
### Memory layer ### Memory layer
When a retrieval produces a conclusion worth keeping, the agent proposes a markdown memory file. After user approval, it registers the file with `runtime.js --attune <script>`. Memories are recalled via `memories()` in future sessions — a synthesis cache, not a replacement for raw evidence. When a retrieval produces a conclusion worth keeping, the agent proposes a markdown memory file. After user approval, it registers the file with `obelisk --attune <script>`. Memories are recalled via `memories()` in future sessions — a synthesis cache, not a replacement for raw evidence.
## App: A surface for humans ## App: A surface for humans
A companion desktop app for browsing what the skill indexes. A companion desktop app for browsing the same index maintained by the CLI or
the app daemon.
<div align="center"> <div align="center">
<img src=".github/assets/app-screenshot.png" alt="Obelisk App" width="720"> <img src=".github/assets/app-screenshot.png" alt="Obelisk App" width="720">
@@ -170,23 +193,20 @@ packages/core/ # @obelisk/core npm workspace (TypeScript + ESM)
│ ├── parsing.ts # Pure helpers (node:sqlite-free, app-consumable) │ ├── parsing.ts # Pure helpers (node:sqlite-free, app-consumable)
│ ├── db.ts # node:sqlite lifecycle + migrations │ ├── db.ts # node:sqlite lifecycle + migrations
│ ├── query.ts # Query/attune sandbox API (helpers) │ ├── query.ts # Query/attune sandbox API (helpers)
│ ├── runtime.ts # Thin CLI shell (--build/--search/--query/--attune)
│ └── schema.sql # SQLite schema (single source of truth) │ └── schema.sql # SQLite schema (single source of truth)
├── package.json ├── package.json
└── dist/ # Generated package JS, declarations, and schema └── dist/ # Generated package JS, declarations, and schema
references/ # Agent-readable docs (progressive disclosure) packages/cli/ # @obelisk-apps/cli npm workspace
├── schema.md ├── src/obelisk.ts # CLI shell + skill installer delegation
├── api-reference.md ├── scripts/build.mjs # Compiles CLI + readable Core into one package
├── query-patterns.md ├── package.json
── retrieval-semantics.md ── dist/ # Generated platform-neutral npm payload
├── pitfalls.md
├── recap-patterns.md skill-doc/ # Source for the docs-only obelisk agent skill
├── recap-writing.md ├── SKILL.md # Query and memory workflow
└── recap/ # Per-card pattern + writing references └── references/ # Progressive-disclosure API/schema/pattern docs
── overview.md ── recap/ # Per-card recap retrieval + writing references
├── pattern1-cover.md … pattern5-closing.md
└── writing1-cover.md … writing5-closing.md
app/ # Electron desktop app (electron-vite + Vue) app/ # Electron desktop app (electron-vite + Vue)
├── src/main/ # TypeScript main process (consumes shared core) ├── src/main/ # TypeScript main process (consumes shared core)
@@ -195,32 +215,37 @@ app/ # Electron desktop app (electron-vite + Vue)
└── electron.vite.config.ts └── electron.vite.config.ts
packaging/ # Skill publish infrastructure packaging/ # Skill publish infrastructure
├── build-skill.mjs # Builds the docs-only skill artifact
├── skill-package.json ├── skill-package.json
├── skill-README.md ├── skill-README.md
├── skill-LICENSE # MIT (relicensed for the skill artifact) ├── skill-LICENSE # MIT (relicensed for the skill artifact)
└── publish-skill.sh └── publish-skill.sh
SKILL.md # Skill definition (installed with the artifact) SKILL.md # Remote one-time CLI + skill bootstrap guide
install.sh # POSIX CLI-only installer
CONTEXT.md # Project glossary CONTEXT.md # Project glossary
docs/adr/ # Architecture decision records (00010006) docs/adr/ # Architecture decision records (00010006)
``` ```
The optional `/obelisk recap` flow is loaded only for explicit `/obelisk recap` intent. The optional `/obelisk recap` flow is loaded only for explicit `/obelisk recap` intent.
It starts at `references/recap/overview.md` and proceeds card-by-card: It starts at `skill-doc/references/recap/overview.md` and proceeds card-by-card:
- `references/recap/pattern1-cover.md` + `references/recap/writing1-cover.md` - `skill-doc/references/recap/pattern1-cover.md` + `skill-doc/references/recap/writing1-cover.md`
- `references/recap/pattern2-thinking.md` + `references/recap/writing2-thinking.md` - `skill-doc/references/recap/pattern2-thinking.md` + `skill-doc/references/recap/writing2-thinking.md`
- `references/recap/pattern3-vibe.md` + `references/recap/writing3-vibe.md` - `skill-doc/references/recap/pattern3-vibe.md` + `skill-doc/references/recap/writing3-vibe.md`
- `references/recap/pattern4-workflow.md` + `references/recap/writing4-workflow.md` - `skill-doc/references/recap/pattern4-workflow.md` + `skill-doc/references/recap/writing4-workflow.md`
- `references/recap/pattern5-closing.md` + `references/recap/writing5-closing.md` - `skill-doc/references/recap/pattern5-closing.md` + `skill-doc/references/recap/writing5-closing.md`
### Generated build outputs ### Generated build outputs
- `packages/core/dist/` is produced by `npm run build:core`. It is the compiled - `packages/core/dist/` is produced by `npm run build:core`. It is the compiled
`@obelisk/core` package: JavaScript, type declarations, and `schema.sql`. internal `@obelisk/core` workspace: JavaScript, type declarations, and
`schema.sql`.
- `packages/cli/dist/` is produced by `npm run build:cli`. It is the publishable
`@obelisk-apps/cli` payload: the thin command shell, readable compiled Core,
and `schema.sql`.
- `dist/obelisk-skill/` is produced by `npm run build:skill`. It is the - `dist/obelisk-skill/` is produced by `npm run build:skill`. It is the
install-ready skill artifact: readable plain JavaScript under `scripts/`, docs-only skill artifact: `SKILL.md`, references, and skill package metadata.
`SKILL.md`, references, and the skill package metadata.
- Skill publishing stages that artifact at `skills/obelisk/` in the - Skill publishing stages that artifact at `skills/obelisk/` in the
`obelisk-skill` repository; only `README.md` and `LICENSE` remain at the `obelisk-skill` repository; only `README.md` and `LICENSE` remain at the
repository root for `npx skills` discovery. repository root for `npx skills` discovery.
@@ -233,11 +258,13 @@ app imports `packages/core/src/` directly so electron-vite can bundle Core.
The index rebuilds incrementally — only new or modified JSONL files are re-parsed. The index rebuilds incrementally — only new or modified JSONL files are re-parsed.
When the optional app is running, it is the active indexer: it watches Claude When the optional app is running, it is the active indexer: it watches Claude
project files and builds in a worker thread. A fresh `__app_heartbeat__` alone project files and builds in a worker thread. A fresh `__app_heartbeat__` alone
means the daemon owns writes, so the skill remains read-only; a separate SQLite means the daemon owns writes, so CLI invocations remain read-only; a separate SQLite
writer lease prevents cross-process writes from overlapping. The writer lease prevents cross-process writes from overlapping. The
`__app_last_successful_build__` marker records index freshness, not ownership. `__app_last_successful_build__` marker records index freshness, not ownership.
Zero npm dependencies. Uses Node 22's built-in node:sqlite with FTS5. The entire runtime is ~400 lines. The CLI has zero runtime npm dependencies and uses Node 22's built-in
`node:sqlite` with FTS5. The formal skill contains instructions and references,
not a second executable runtime.
20K lines of scattered JSONL → something the agent can search() and sql() against in milliseconds. 20K lines of scattered JSONL → something the agent can search() and sql() against in milliseconds.
+33 -380
View File
@@ -1,408 +1,61 @@
--- ---
name: obelisk name: obelisk-installer
description: > description: Install the official Obelisk CLI and then install the Obelisk agent skill. Use only for initial setup or repair when the `obelisk` command is missing.
Search and query past Claude Code and Codex session history.
Reactive: when the user asks "how did I fix X", "what did we do last time", "find the session where", "上次怎么修的", "之前的session", "历史记录".
Proactive: when the user references past work you lack context for, when you're about to modify a file with complex edit history, when the user says "继续之前的" or "continue where we left off", or when understanding prior decisions would improve your current response.
Memory: when the user says "记住这个", "remember this", "写入记忆", "save this conclusion", or when you determine a retrieval result contains a conclusion worth persisting.
allowed-tools: allowed-tools:
- Read - Bash
- Bash(node:*)
- Write
--- ---
# obelisk # Install Obelisk
Search and query Claude Code and Codex session history stored in `~/.claude/` This is a one-time bootstrap guide. It installs the local Obelisk runtime, then
and `~/.codex/`. uses that runtime to install the official agent skill from
Obelisk indexes sessions, messages, tool calls, tool results, summaries, `github.com/tommy0103/obelisk-skill`.
subagents, workflows, workflow agents, parent chains, and raw JSONL lines into
SQLite + FTS5.
Obelisk has two transcript sources. Treat both as ordinary sessions by default: ## 1. Check for the CLI
Claude rows use `source='claude'`; Codex rows use `source='codex'` and IDs
prefixed with `codex:`. Use `source` only when provenance matters or the user
asks to scope to one provider. Codex subagent child threads are mapped to the
same `subagents` table; Codex workflow rows may be absent because Codex does not
emit Claude-style workflow metadata.
Obelisk is a CodeAct memory layer: write a small JS query, run it locally, read
the JSON, then answer. Do not turn history into a flat document or browse entire
sessions by default.
## Quick Start
The skill directory is provided as `$SKILL_DIR` at invocation time.
Fast keyword search:
```bash ```bash
node $SKILL_DIR/scripts/runtime.js --search "keyword" command -v obelisk >/dev/null 2>&1 && obelisk --version
``` ```
Custom query: If this succeeds, skip to step 3. Do not reinstall a working CLI unless the user
asked to upgrade or repair it.
1. Write a bounded JS query to a temp file, for example `/tmp/q.mjs`. ## 2. Install the CLI
2. Run:
```bash Installing a global command changes the user's machine. Show the command and get
node $SKILL_DIR/scripts/runtime.js --query /tmp/q.mjs the user's approval before running one of these official installation methods.
```
3. Parse JSON stdout and answer with concise evidence. With npm:
The query file runs inside `(async () => { ... })()`. Use `return` to emit JSON.
Query scripts are read-only: `remember()` and `forget()` are not available, and
`sql()` only accepts read-only SELECT/WITH queries.
## Default First Pass
Start with helpers, not raw SQL. For the first Obelisk query in a task, normally
call `overview({ limit: 6 })` unless the user already gave an exact
`session_id`, message `uuid`, or absolute file path.
For semantic or synthesis tasks, combine orientation, memory recall, and raw
session evidence before deciding whether a detail pass is needed:
```js
const map = overview({ limit: 6 });
const project = map.current.project?.project;
const topic = 'English topic terms translated from the user request';
return {
orientation: map.current_project,
prior_memories: memories({ project, query: topic, limit: 5 }),
session_evidence: search(topic.replace(/[-_]/g, ' '), { project, limit: 8 }),
};
```
Use `sql()` only as an escalation path for exact joins, aggregations, or schema
questions that helpers cannot express cleanly. Do not use raw SQL as a generic
fallback for broad retrieval.
## Intent Routing
Obelisk supports a small intent prefix layer after `/obelisk`. This is for
output intent, not retrieval architecture.
| Intent | Description | Reference |
|---|---|---|
| `recap [target]` | Generate weekly/monthly recap card content for app handoff or share-style output. | `references/recap/overview.md` |
Routing rules:
1. If the first word is `recap`, read `references/recap/overview.md` before the
first query. Everything after `recap` is the recap target.
Common app-generated prompts include `/obelisk recap this week`,
`/obelisk recap last week`, `/obelisk recap this month`, and
`/obelisk recap last month`; interpret these as natural period targets
relative to the current date and timezone.
2. `recap` does not create a separate retrieval layer. It still uses
`overview()`, `memories()`, helpers, and `sql()` only when needed.
3. Follow the overview's card-by-card sequence. Each card has its own retrieval
pattern and writing file; retrieve that card's evidence, read that card's
writing file, update the JSON, then move to the next card. Do not preload all
recap references before the current card is written.
4. If the first word is not `recap`, do not load
`references/recap/overview.md`. Continue with Query Routing below. Do not
infer recap from broad requests for weekly/monthly summaries, charts,
rankings, shareable cards, or playlist-style metaphors.
## Reference Map
Use references by job, not by habit:
| Reference | Use when |
|---|---|
| `references/query-patterns.md` | Broad synthesis, progress summaries, design history, weekly/monthly reviews, approved memory write/archive/update scripts, or questions about what the user did/learned/decided/tried/abandoned. |
| `references/retrieval-semantics.md` | Multi-step retrieval, scoped project/file/session searches, or when scope/artifact/semantic boundaries affect query design. |
| `references/schema.md` | Raw SQL field and join quick reference before writing non-trivial `sql()`. |
| `references/api-reference.md` | Helper signatures, option names, return fields, or exact `remember()` / `forget()` parameter details are unclear. |
| `references/pitfalls.md` | Error recovery, FTS syntax, aliases, ordering, row-shape surprises, or compact/raw tradeoffs. |
| `references/recap/overview.md` | Explicit `/obelisk recap ...` requests only. |
## Query Routing
Before writing a query, classify the task. Progressive disclosure is useful, but
skipping the relevant reference usually costs extra query rounds.
- Read `references/query-patterns.md` before the first query for broad synthesis, progress summaries, design history, ordinary weekly/monthly reviews, or questions that ask what the user did, learned, decided, tried, or abandoned. Start from the first-pass or one-shot synthesis pattern, then run a faceted detail pass if needed.
- Read `references/retrieval-semantics.md` before multi-step retrieval, scoped project/file/session searches, or synthesis/conclusion/history questions. It defines the query design frame.
- Read `references/schema.md` before raw `sql()` unless the needed table/column relationship is already explicit here. It is intentionally short and SQL-focused. Do this before running the SQL, not after a missing-column error. Do not start with raw SQL for broad synthesis unless helpers cannot express the needed aggregation or join.
- Read `references/api-reference.md` when helper option names, return fields, scalar shorthand behavior, or `remember()`/`forget()` details are unclear.
- Read `references/pitfalls.md` after an error or when FTS syntax, aliases, ordering, row shapes, or compact/raw tradeoffs are unclear.
If a helper row shape is unclear, first run a tiny scoped query and return
`Object.keys(row)` or a compact sample. Do not invent field names.
For approved memory mutations, follow the Memory Layer section below first.
Use `references/query-patterns.md` for copyable `--attune` scripts
(`Attune Approved Memory`, `Forget Approved Memory`, `Update Approved Memory`),
and `references/api-reference.md` only for exact parameter semantics.
## Core API
### `search(text, opts?)`
Full-text search across main messages, subagent messages, and workflow-agent
messages.
Returns:
```js
[{ message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd, source },
session: { id, title, project, started_at, source },
rank,
context }]
```
`context` here means temporal neighbors: nearby messages in the same session by
timestamp. It is not the parent chain. Use `context(uuid)` or `trace(uuid)` for
causal/parent-chain context.
Use `message.content_type` to keep evidence boundaries intact:
`text` is user/assistant visible language, `thinking` is trace/debug material,
`tool_use` marks a tool-call message whose details live in `tool_calls`, and
`tool_result` marks a tool-result message whose details live in `tool_results`.
`unknown` is a conservative fallback. Do not treat `thinking` as a user-visible
assistant conclusion. Real user input is `type='user'` plus `content_type='text'`;
do not invent a separate `user_message` content type.
Use `message.is_meta` to separate transcript control-plane material from
conversation evidence. `is_meta=1` marks injected caveats, command envelopes, or
other messages that entered the transcript as user-role content but should not
be treated as the user's request by default. `search()` and `thread()` omit meta
messages unless `includeMeta: true` is passed; `context()` and `trace()` preserve
the original chain and expose `is_meta` on rows.
Opts: `{ limit, sessionId, project, after, before, cwd, source, includeMeta }`.
`project` is a SQL `LIKE` filter over `sessions.project`, not an exact project
identity. Results are already ordered by FTS5 rank; lower rank sorts earlier.
Prefer returned order over manually interpreting numeric rank unless you are
deliberately using FTS5 semantics.
`source` can be `'claude'`, `'codex'`, or omitted. Omitted means search all
indexed sources.
### `context(uuid)`
Returns the full story around one indexed message:
```js
{ message, parentChain, session, subagent, workflow }
```
Use this after `search()` finds a promising message. It is the usual way to
expand vertically from one evidence point without dumping the whole session.
### `sql(query, ...params)`
Read-only SQL SELECT/WITH with `?` placeholders. Returns array rows. SQL is an
escape hatch for exact structured joins and aggregations after the helper-first
surface is insufficient; it is not the default retrieval entry point.
Before writing non-trivial SQL, read `references/schema.md`. It is the raw SQL
field/join quick reference. The executable DDL lives in `scripts/schema.sql`;
use the SQL file only when checking source alignment. Common safe joins:
- `tool_calls` does not have timestamps. Join `messages m ON m.uuid = tc.message_uuid`.
- `tool_results` does not have timestamps. Join `messages m ON m.uuid = tr.message_uuid`.
- For project/session filters, join `sessions s ON s.id = <table>.session_id`.
- Prefer SQL-side `GROUP BY`, `COUNT`, `MAX`, `ORDER BY`, and `LIMIT` over hand-counting in the final answer.
Tables: `sessions`, `messages`, `tool_calls`, `tool_results`, `summaries`,
`memories`, `subagents`, `workflows`, `workflow_agents`, `messages_fts`.
## Structured Helpers
These helpers are convenience accessors over the same SQLite structure. They do
not replace `sql()`, but they are the default first-pass surface. Use `sql()`
when you need an exact aggregation or a join the helper does not expose.
All list helpers accept a bounded `limit`. Many also accept:
`{ project, after, before, sessionId, sessions, branch, source }`. Check
`references/api-reference.md` or a tiny sample before relying on less common
filters or return fields.
- `overview(opts?)` -- compact orientation map. Returns current cwd/project if knowable, global project/source counts, and current-project recent sessions plus memory records. It is a map, not evidence.
- `sessions(opts?)` -- session rows, newest first. `project` is a SQL `LIKE` pattern.
- `recent(n?)` -- shorthand for recent sessions.
- `summaries(opts?)` -- summary rows, newest first: `{ id, session_id, timestamp, source, content, session_title, project }`; here `source` is the summary kind, not the transcript provider.
- `subagents(opts?)` -- subagent metadata plus `messageCount`.
- `workflows(opts?)` -- workflow runs, newest first.
- `workflowTree(runId)` -- workflow row plus parsed `result` and `agents`; may include bulky `script` and `result_json`, so project compact fields.
- `fileHistory(filePath, opts?)` -- Read/Edit/Write tool calls for a file, oldest first; includes many `Read` rows.
- `failures(opts?)` -- failed tool results with tool/session context, newest first.
- `trace(uuid)` -- parent chain from root to message.
- `thread(sessionId, opts?)` -- session messages ordered by timestamp, omitting meta messages by default. Pass `{ includeMeta: true }` when investigating injected context or command envelopes.
- `raw(uuid, opts?)` -- windowed access to the original JSONL line.
- `memories(opts?)` -- recall memory layer. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. Without `query`, returns active memory records newest first. With `query`, searches `summary`/`path` through safe FTS5 tokenization and returns `rank`; lower rank sorts earlier. Records may include nullable JSON `anchors` for explicit recall surfaces such as files. Read the file at `path` for full content.
## Retrieval Contract
Keep queries scoped, bounded, and structural.
- Scope First: classify the locator as scope, artifact, or semantic. Use the narrowest structural locator before FTS; empty scoped results are valid unless the user asks to broaden.
- Orient First: for a new task, normally call `overview({ limit: 6 })` before deeper retrieval unless the user gave an exact session/message/file locator. It is a navigation map; confirm facts with `memories()`, `search()`, helpers, or, only when needed, `sql()`.
- Helper First: prefer `overview()`, `memories()`, `search()`, `sessions()`, `summaries()`, `fileHistory()`, and other helpers for first-pass retrieval. Escalate to raw `sql()` only when helpers cannot express the needed join, grouping, or exact schema-level check.
- Plan Before Probe: for conclusion, broad history, failure investigation, or file evolution, write a bounded retrieval script instead of spending turns on intermediate results.
- Structure Before Text: compute counts, joins, grouping, dedupe, and projection in SQL or JS; keep runtime JSON compact, ideally under 10k-12k chars for synthesis tasks.
- Evidence Before Conclusion: return compact evidence with stable IDs (`session_id`, `uuid`, `tool_call_id`, `run_id`, `agent_id`) and short snippets, then synthesize in the final answer.
- Exclude Meta By Default: `is_meta=1` rows are injected/control-plane transcript material. Helpers hide them by default; raw SQL for ordinary conversation evidence should include `COALESCE(m.is_meta,0)=0` unless meta rows are the investigation target.
- Persist Durable Conclusions: after answering, if retrieval produced a durable conclusion that future sessions are likely to reuse and `memories()` does not already cover it, explicitly offer to write a memory. Keep the offer brief. Do not write the markdown file or run `--attune` until the user approves.
If field, context, ordering, FTS, or helper semantics affect the query, read
`references/retrieval-semantics.md` before coding. If a query errors, read
`references/pitfalls.md` before retrying.
## Memory Layer
Obelisk has a persistent memory layer alongside raw session data. Every
retrieval queries both layers: `memories()` for prior conclusions, `search()`
and helpers for raw session evidence. Use memory as prior notes, not final
authority. If a memory record influences your answer, say naturally that it was
previously recorded, and compare it with raw session evidence when correctness
depends on it. Raw session data is the evidence layer, but one hit is not a
complete truth; query and cite it compactly.
The memory layer is English-indexed. Use English terms in `memories({ query })`
even when the user asks in another language. Write every `remember().summary`
in English, regardless of the current conversation language. The runtime rejects
obvious CJK text in memory queries and summaries as a guardrail.
**Recall:** query `memories({ query: 'English topic terms', project: '...' })`
to find prior conclusions relevant to the current task. Translate non-English
user requests into concise English query terms before calling `memories()`.
Memory recall uses safe FTS5 tokenization over `summary` and `path`, so
hyphens/punctuation are tokenized instead of causing raw `MATCH` syntax errors.
Like other list helpers, passing a string is treated as `sessionId`, and passing
a number is treated as `limit`. Read the file at `path` for full content.
`memories()` returns active memories only. An archived memory is
management/audit data, not recall data.
Good memory candidates include design decisions, project conventions, abandoned
alternatives, repeated failure causes, workflow patterns, and conclusions
synthesized across multiple raw evidence points. Do not propose memory for
one-off lookups, uncertain findings, or conclusions already covered by existing
memories.
**Mutation approvals:** judging whether to use a memory in the current answer is
an agent decision and does not require approval. Persistent memory changes do.
If the user explicitly says a memory is wrong, outdated, should be forgotten, or
should now say something else, that request is the approval to archive or update
the exact matching memory. Do not ask for a second confirmation unless multiple
memories could match. If you notice a possible conflict yourself, explain it
briefly and ask before changing memory state.
**Writing memories:** after a retrieval produces a conclusion worth persisting,
propose writing a memory file. The user must approve. Flow:
1. Write a markdown file using the `Write` tool (user approves).
2. Register it via `remember()` in a narrow memory-registration script:
```js
return remember({
path: '.obelisk/memories/design-decision-x.md',
session_id: 'current-session-id',
message_start: 'uuid-of-first-relevant-msg',
message_end: 'uuid-of-last-relevant-msg',
anchors: [{ kind: 'file', path: 'src/path/to/file.ts' }],
summary: 'Detailed summary: what was decided, why, what alternatives were considered, and what constraints drove the choice.'
})
```
Run the registration script with:
```bash ```bash
node $SKILL_DIR/scripts/runtime.js --attune /tmp/register-memory.mjs npm install --global @obelisk-apps/cli
``` ```
`--attune` exposes only memory mutation helpers: `remember()` and `forget()`. On macOS, Linux, or WSL, the official installer performs the same CLI-only
It does not expose `search()`, `sql()`, `memories()`, or other retrieval installation and never installs an agent skill:
helpers. If you need source IDs or memory IDs, find them first with a normal
`--query` script.
`remember()` validates that `path` already exists and points to a file. Relative ```bash
paths are resolved against the source session's `project_path` when curl -fsSL https://raw.githubusercontent.com/tommy0103/obelisk/main/install.sh | sh
`session_id` is provided, then stored as normalized absolute paths. Prefer
project-relative paths such as `.obelisk/memories/...` plus `session_id`.
Optional `anchors` must be an array of objects and is stored as nullable JSON
text. Use it only for explicit recall surfaces, such as files associated with
the memory.
`summary` must be English and detailed enough that `memories()` results alone
can judge relevance without reading the file. Include the decision, the
reasoning, and the key constraints — not just a title.
The `message_start`/`message_end` range marks where in the conversation this
conclusion was drawn. Use it later to trace back to the original evidence.
**Forgetting memories:** if the user says a memory is outdated, wrong, or should
be forgotten, use normal recall first to identify the exact memory ID. If there
is exactly one clear candidate, the user's request is approval to archive it. If
multiple memories could match, ask which one to forget. Then run an `--attune`
script:
```js
return forget({
id: 'mem-id-to-delete',
reason: 'Outdated by newer project guidance.',
});
``` ```
`forget()` archives the memory record by setting `deleted_at` and Never use `sudo`, install a daemon, or download Obelisk from another source.
`deleted_reason`. It removes the record from active recall but does not delete
the markdown file. Memory records survive index rebuilds and are never changed
automatically.
**Updating memories:** updating memory is one user-approved operation: Verify the result:
archive the old memory with `forget()`, then write and register a replacement
markdown memory with `remember()`. If the user explicitly corrected the memory,
that correction is approval for the combined archive-plus-write flow. If you
discovered the mismatch yourself, ask first.
## Minimal Patterns ```bash
obelisk --version
Search, then expand one promising hit:
```js
const hits = search('auth fix', { limit: 5 });
if (!hits.length) return [];
return hits.slice(0, 3).map(h => ({
session_id: h.session.id,
session_title: h.session.title,
uuid: h.message.uuid,
snippet: h.message.text?.slice(0, 240),
}));
``` ```
Check helper fields before assuming names: ## 3. Install the official skill
```js ```bash
const rows = summaries({ project: '%quiet-zero%', limit: 1 }); obelisk install
return rows.length ? Object.keys(rows[0]) : [];
``` ```
Fetch message neighbors without a full thread: Pass through any target or scope options the user requested. The command uses
the standard skills installer, so follow its prompts instead of copying skill
files by hand.
```js After installation, tell the user to reload their agent if the new `/obelisk`
const hit = search('runtime query', { limit: 1 })[0]; skill is not discovered immediately. This bootstrap document is not the query
return sql( skill and must not answer session-history questions itself.
`SELECT uuid, role, timestamp, substr(text,1,240) AS snippet
FROM messages
WHERE session_id=? AND timestamp>=?
ORDER BY timestamp LIMIT 6`,
hit.session.id,
hit.message.timestamp
);
```
See `references/query-patterns.md` for longer recipes.
## Notes
- First run builds the index. Later runs update incrementally.
- DB location: `~/.obelisk/obelisk.sqlite`; old `~/.claude/obelisk.sqlite` is copied forward if needed.
- Query scripts run in a sandboxed VM with no filesystem or network access from inside the script.
- Indexed text and stored tool inputs/results are truncated to 10k chars. Use `raw(uuid, { offset, limit })` for specific JSONL windows.
@@ -70,7 +70,7 @@ messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repea
const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`; const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`;
const codexExecSource = 'const result = { ok: true };\nreturn result;'; const codexExecSource = 'const result = { ok: true };\nreturn result;';
const liveBashToolInput = { const liveBashToolInput = {
command: "cat > /tmp/q_jul15b.mjs <<'EOF'\nconst codex = sessions({ source: 'codex', project: '%quiet-zero%', limit: 3 });\n\nconst tail = sql(`\n SELECT substr(text, 1, 500) as snippet, timestamp, role\n FROM messages\n WHERE session_id = ?\n AND timestamp > '2026-07-14T18:20:00'\n AND text IS NOT NULL\n AND COALESCE(is_meta, 0) = 0\n AND length(text) > 30\n ORDER BY timestamp DESC\n LIMIT 5\n`, codex[0]?.id);\n\n// Any new codex sessions for quiet-zero\nconst newer = sql(`\n SELECT id, title, started_at, ended_at, message_count\n FROM sessions\n WHERE COALESCE(source,'claude') = 'codex'\n AND project LIKE '%quiet-zero%'\n AND started_at > '2026-07-14T18:00:00'\n ORDER BY started_at DESC\n LIMIT 5\n`);\n\nreturn {\n main: { id: codex[0]?.id, ended: codex[0]?.ended_at, msgs: codex[0]?.message_count },\n afterLastSync: tail,\n newerSessions: newer,\n};\nEOF\nnode /Users/tomiya/.claude/skills/obelisk/scripts/runtime.js --query /tmp/q_jul15b.mjs", command: "cat > /tmp/q_jul15b.mjs <<'EOF'\nconst codex = sessions({ source: 'codex', project: '%quiet-zero%', limit: 3 });\n\nconst tail = sql(`\n SELECT substr(text, 1, 500) as snippet, timestamp, role\n FROM messages\n WHERE session_id = ?\n AND timestamp > '2026-07-14T18:20:00'\n AND text IS NOT NULL\n AND COALESCE(is_meta, 0) = 0\n AND length(text) > 30\n ORDER BY timestamp DESC\n LIMIT 5\n`, codex[0]?.id);\n\n// Any new codex sessions for quiet-zero\nconst newer = sql(`\n SELECT id, title, started_at, ended_at, message_count\n FROM sessions\n WHERE COALESCE(source,'claude') = 'codex'\n AND project LIKE '%quiet-zero%'\n AND started_at > '2026-07-14T18:00:00'\n ORDER BY started_at DESC\n LIMIT 5\n`);\n\nreturn {\n main: { id: codex[0]?.id, ended: codex[0]?.ended_at, msgs: codex[0]?.message_count },\n afterLastSync: tail,\n newerSessions: newer,\n};\nEOF\nobelisk --query /tmp/q_jul15b.mjs",
description: 'Query for activity since last sync', description: 'Query for activity since last sync',
}; };
let codexExecOutput = JSON.stringify([{ let codexExecOutput = JSON.stringify([{
@@ -7,7 +7,8 @@
> persist layer, not one per binding. > persist layer, not one per binding.
**Context.** Obelisk had two divergent full indexers — the former **Context.** Obelisk had two divergent full indexers — the former
`scripts/indexer.mjs` (`node:sqlite`, skill/runtime) and `app/indexer.js` `scripts/indexer.mjs` (`node:sqlite`, the former skill-embedded runtime) and
`app/indexer.js`
(`better-sqlite3`, Electron (`better-sqlite3`, Electron
app) — that duplicated the same Claude and Codex JSONL parsing and had silently app) — that duplicated the same Claude and Codex JSONL parsing and had silently
diverged in write semantics (`INSERT OR REPLACE` vs `ON CONFLICT DO UPDATE`, diverged in write semantics (`INSERT OR REPLACE` vs `ON CONFLICT DO UPDATE`,
@@ -31,14 +32,14 @@ binding-agnostic and does not need a per-binding implementation.
incremental `index_state` bookkeeping, FTS maintenance, and the canonical incremental `index_state` bookkeeping, FTS maintenance, and the canonical
**upsert** (`ON CONFLICT(uuid) DO UPDATE`) write semantics reconciled from the **upsert** (`ON CONFLICT(uuid) DO UPDATE`) write semantics reconciled from the
drift on 2026-07-08. The database handle is *injected*, so `node:sqlite` drift on 2026-07-08. The database handle is *injected*, so `node:sqlite`
(skill/CLI) and `better-sqlite3` (app) run the same code — there is no (CLI) and `better-sqlite3` (app) run the same code — there is no
per-binding persist layer. per-binding persist layer.
**Two indexing modes** share all of the above and differ only in trigger: **Two indexing modes** share all of the above and differ only in trigger:
**daemon mode** (app/CLI watches and keeps the index fresh) and **passive pull **daemon mode** (the app, and potentially a future CLI daemon, watches and keeps
mode** (skill indexes on invocation when no daemon is active). They never write the index fresh) and **passive pull mode** (a CLI command indexes on invocation
concurrently — passive mode detects a fresh daemon via heartbeat markers in when no daemon is active). They never write concurrently — passive mode detects
`index_state` (**daemon arbitration**). a fresh daemon via heartbeat markers in `index_state` (**daemon arbitration**).
**Consequences.** Golden tests anchor on each adapter's `parse` output (feed **Consequences.** Golden tests anchor on each adapter's `parse` output (feed
fixture JSONL, assert the yielded record sequence) — independent of binding and fixture JSONL, assert the yielded record sequence) — independent of binding and
+4 -4
View File
@@ -5,8 +5,8 @@ pin what "the contract" is so refactoring cannot silently change observable
behavior. The four verbs (`build`/`search`/`query`/`attune`) are only the entry behavior. The four verbs (`build`/`search`/`query`/`attune`) are only the entry
surface; agents actually depend on the *return shapes* of the sandbox helpers surface; agents actually depend on the *return shapes* of the sandbox helpers
(`search`, `overview`, `memories`, …), which are already documented in (`search`, `overview`, `memories`, …), which are already documented in
`references/api-reference.md` and relied on by every example in `skill-doc/references/api-reference.md` and relied on by every example in
`references/query-patterns.md`. Current behavior is good and there is no reason to `skill-doc/references/query-patterns.md`. Current behavior is good and there is no reason to
change it during migration. change it during migration.
**Decision.** Freeze the contract in two tiers. **Tier 1 (hard freeze, golden **Decision.** Freeze the contract in two tiers. **Tier 1 (hard freeze, golden
@@ -16,8 +16,8 @@ read-only enforcement, `attune` exposing only `remember`/`forget`, the set of
globals/helpers available inside `query`/`attune`). **Tier 2 (locked to globals/helpers available inside `query`/`attune`). **Tier 2 (locked to
api-reference.md):** each helper's documented return shape — not frozen forever, api-reference.md):** each helper's documented return shape — not frozen forever,
but never allowed to drift silently; contract tests assert the live shape matches but never allowed to drift silently; contract tests assert the live shape matches
`references/api-reference.md`, so changing a helper forces a doc change plus a `skill-doc/references/api-reference.md`, so changing a helper forces a doc change plus a
deliberate version bump. `references/api-reference.md` is therefore promoted from deliberate version bump. `skill-doc/references/api-reference.md` is therefore promoted from
description to authoritative contract, and Phase 1 becomes "make it authoritative description to authoritative contract, and Phase 1 becomes "make it authoritative
and enforce it," not "write a new contract doc." and enforce it," not "write a new contract doc."
@@ -1,25 +1,26 @@
# Core is authored in TypeScript, shipped as precompiled ESM JavaScript # Core is authored in TypeScript, shipped as precompiled ESM JavaScript
**Context.** The extracted Obelisk Core must serve two consumers — the ESM skill **Context.** The extracted Obelisk Core must serve two consumers — the ESM CLI
runtime (`node:sqlite`) and the CommonJS Electron app (`better-sqlite3`) — while runtime (`node:sqlite`) and the Electron app (`better-sqlite3`) — while the CLI
the skill artifact must install with **zero build step** on the user's machine must install with **zero build step** on the user's machine. Authoring in TS
(the clone-and-run, "low-friction skill" goal). Authoring in TS gives the infra gives the infrastructure checkable contracts, but raises how compiled output is
its checkable contracts, but raises how the compiled output is shipped and which shipped and which module format it targets. The formal agent skill is a separate
module format it targets. docs-only artifact and must not carry a second runtime.
**Decision.** Author all of Core in the `@obelisk/core` npm workspace **Decision.** Author all of Core in the `@obelisk/core` npm workspace
(`packages/core`) in TypeScript and compile it ahead-of-time to (`packages/core`) in TypeScript and compile it ahead-of-time to
**ESM JavaScript plus `.d.ts`**. The skill/CLI runtime ships the *precompiled* **ESM JavaScript plus `.d.ts`**. `@obelisk-apps/cli` ships the *precompiled*
ESM JS, so installing the skill never runs a build. Rather than have Core ESM JS, so installing the CLI never runs a build. Rather than have Core
dual-publish CJS+ESM, the Electron main process migrates to ESM at Phase 5 so it dual-publish CJS+ESM, the Electron main process migrates to ESM at Phase 5 so it
can `import` the same compiled Core. TypeScript source is the single source of can `import` the same compiled Core. TypeScript source is the single source of
truth; the build step lives in the main repo (`build:skill`), never on the user's truth; the package build lives in the main repo (`build:cli`), never on the
machine. user's machine. `build:skill` copies only `skill-doc/SKILL.md`, references, and
skill metadata.
**Consequences.** A one-time ESM migration of the Electron main process (Phase 5), **Consequences.** A one-time ESM migration of the Electron main process (Phase 5),
in exchange for no dual-build maintenance and a single module format across skill, in exchange for no dual-build maintenance and a single module format across the
CLI, and app. The shipped skill artifact contains compiled JS, not TS. The CLI and app. The shipped CLI package contains compiled JS, not TS. The
renderer (Vue) is out of scope and stays JavaScript. Phase 3's TS baseline only renderer (Vue) is out of scope and stays JavaScript. Phase 3's TS baseline only
adds root tooling (package.json, tsconfig, ESLint); it does not touch the app. adds root tooling (package.json, tsconfig, ESLint); it does not touch the app.
The app imports Core source so electron-vite can bundle it, while package and The app imports Core source so electron-vite can bundle it, while package and
skill builds compile the same workspace source to JavaScript. CLI builds compile the same workspace source to JavaScript.
@@ -1,22 +1,21 @@
# The skill artifact ships readable compiled JS, deliberately not bundled # The CLI ships readable compiled JS; the skill remains docs-only
**Context.** Obelisk reads a user's entire local Claude Code and Codex history, **Context.** Obelisk reads a user's entire local Claude Code and Codex history,
so auditability is the foundation of trust: before a user lets the skill loose on so auditability is the foundation of trust. Bundling/minifying Core into one
their data, they must be able to read what it does. The obvious way to shrink a opaque file would make the runtime harder to inspect. Shipping executable Core
clone-and-run skill artifact is to bundle/minify Core into a single `runtime.js`, inside `.claude/skills` / `.agents/skills` would also blur the boundary between
but that ships an opaque blob into `.claude/skills` / `.agents/skills`. The the agent's instructions and the local data runtime.
"don't drag the whole repo into the user's skills dir" concern is real but
separate — it is solved by shipping *only Core*, not by bundling.
**Decision.** The skill artifact ships **readable, non-bundled, non-minified** **Decision.** `@obelisk-apps/cli` ships **readable, non-bundled, non-minified**
compiled JavaScript emitted straight from `tsc` (module structure and comments compiled JavaScript emitted straight from `tsc` (module structure and comments
preserved, ~1:1 with the TypeScript source), plus `schema.sql`, `SKILL.md`, and preserved, ~1:1 with the TypeScript source), plus `schema.sql`. It excludes the
`references/`. It excludes `app/`, `release/`, `renderer/`, Electron code, and app, renderer, release assets, and tests. The separately published agent skill
`tests/`, which is what keeps it small. Bundling into one file is deliberately ships only `SKILL.md`, `references/`, and metadata; every executable action in
rejected: it trades auditability for marginal size, the wrong trade for a the skill delegates to the installed `obelisk` command. Bundling into one file
history-reading tool. The public TS source in the main repo allows cross-checking. is deliberately rejected because it trades auditability for marginal size.
**Consequences.** The installed skill is a few readable files rather than one **Consequences.** Runtime ownership is unambiguous: npm installs the CLI, while
blob; a future contributor may be tempted to "optimize" by bundling — this ADR the skills installer installs only agent guidance. A future contributor may be
records that the un-bundled form is intentional. Small artifact size comes from tempted to re-embed Core in the skill or bundle the CLI — this ADR records that
scoping the artifact to Core, handled by `build:skill`, not from a bundler. both are intentional boundaries. `build:cli` owns compiled code;
`build:skill` owns docs-only packaging.
+1 -1
View File
@@ -25,7 +25,7 @@ decisions within this:
injecting `better-sqlite3`. This injecting `better-sqlite3`. This
works because the provider→parsing import graph is node:sqlite-free (ADR-0001), works because the provider→parsing import graph is node:sqlite-free (ADR-0001),
so nothing drags `node:sqlite` into the app. The `dist/` from `build:core` so nothing drags `node:sqlite` into the app. The `dist/` from `build:core`
(ADR-0003) remains for the skill artifact; the app does not need it. (ADR-0003) remains for the CLI package; the app does not need it.
- **better-sqlite3 stays the app's binding**, externalized (not bundled) and - **better-sqlite3 stays the app's binding**, externalized (not bundled) and
unpacked from the asar. unpacked from the asar.
- **The app main + preload source is TypeScript with types at its seams**, but - **The app main + preload source is TypeScript with types at its seams**, but
@@ -7,7 +7,7 @@ threw over the primary exception and turned a skippable per-file failure into a
whole-build failure. The masked exception was not preserved, so contention whole-build failure. The masked exception was not preserved, so contention
(`SQLITE_BUSY` / `SQLITE_BUSY_SNAPSHOT`) is the leading explanation rather than (`SQLITE_BUSY` / `SQLITE_BUSY_SNAPSHOT`) is the leading explanation rather than
a proven historical fact. It is plausible because daemon builds, manual a proven historical fact. It is plausible because daemon builds, manual
rebuilds, skill passive-pull indexing, heartbeat writes, and reads share one WAL rebuilds, CLI passive-pull indexing, heartbeat writes, and reads share one WAL
database. database.
`busy_timeout` alone is not a correctness fix. In particular, `busy_timeout` alone is not a correctness fix. In particular,
@@ -35,15 +35,15 @@ layers.
failures propagate. `affectedSessionIds` is updated only after the relevant failures propagate. `affectedSessionIds` is updated only after the relevant
commit. Force cleanup is one atomic, retryable transaction, and finalize is commit. Force cleanup is one atomic, retryable transaction, and finalize is
likewise retried as a complete idempotent transaction. likewise retried as a complete idempotent transaction.
- A fresh `__app_heartbeat__` is policy ownership: while it is fresh, the skill - A fresh `__app_heartbeat__` is policy ownership: while it is fresh, the CLI
opens no write connection and performs no migration, schema setup, checkpoint, opens no write connection and performs no migration, schema setup, checkpoint,
index build, or `attune`. `__app_last_successful_build__` remains an index build, or `attune`. `__app_last_successful_build__` remains an
observability/freshness marker and is not required for ownership. The skill observability/freshness marker and is not required for ownership. The CLI
checks ownership again after acquiring the hard lease to close the TOCTOU checks ownership again after acquiring the hard lease to close the TOCTOU
window. Search/query connections are read-only. window. Search/query connections are read-only.
- A dedicated `.obelisk/writer.lock.sqlite` provides the cross-process safety - A dedicated `.obelisk/writer.lock.sqlite` provides the cross-process safety
mutex on every platform. Acquisition is `BEGIN IMMEDIATE` with non-blocking or mutex on every platform. Acquisition is `BEGIN IMMEDIATE` with non-blocking or
bounded waiting; release is idempotent. App builds and heartbeats, skill builds bounded waiting; release is idempotent. App builds and heartbeats, CLI builds
and attune, app schema/legacy migrations and memory mutations, and manual and attune, app schema/legacy migrations and memory mutations, and manual
rebuild all participate. Manual rebuild's main process owns the lease across rebuild all participate. Manual rebuild's main process owns the lease across
worker build, atomic target replacement, and database reopen; the worker uses worker build, atomic target replacement, and database reopen; the worker uses
@@ -52,7 +52,7 @@ layers.
deferral retains changed paths and schedules a short retry without announcing deferral retains changed paths and schedules a short retry without announcing
a successful build. Service start publishes the ownership heartbeat a successful build. Service start publishes the ownership heartbeat
immediately, then refreshes it periodically. immediately, then refreshes it periodically.
- Index-writer and skill read connections use an explicit 250 ms SQLite busy - Index-writer and CLI read connections use an explicit 250 ms SQLite busy
timeout inside the larger bounded coordination budget. The long-lived app timeout inside the larger bounded coordination budget. The long-lived app
query connection retains a 5 s timeout; heartbeat is deliberately non-blocking query connection retains a 5 s timeout; heartbeat is deliberately non-blocking
(`0 ms`) so it never stalls the Electron main thread. Builds use (`0 ms`) so it never stalls the Electron main thread. Builds use
+4 -3
View File
@@ -1,6 +1,7 @@
// Flat ESLint config for the Obelisk root (Core + skill runtime + tests). // Flat ESLint config for the Obelisk root (Core + CLI + packaging + tests).
// Scope: the ESM/TS sources under packages/core/src/ and tests/. The Electron app has its // Scope: the root ESM/TS sources, including packages/core/src/ and
// own package and toolchain and is intentionally excluded (see docs/adr/0003). // packages/cli/src/. The Electron app has its own package and toolchain and is
// intentionally excluded (see docs/adr/0003).
import js from '@eslint/js'; import js from '@eslint/js';
import globals from 'globals'; import globals from 'globals';
Executable
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
set -eu
PACKAGE='@obelisk-apps/cli'
if ! command -v node >/dev/null 2>&1; then
echo 'Obelisk requires Node.js 22.13 or newer.' >&2
exit 1
fi
if ! node -e "const [major, minor] = process.versions.node.split('.').map(Number); process.exit(major > 22 || (major === 22 && minor >= 13) ? 0 : 1)"; then
echo 'Obelisk requires Node.js 22.13 or newer.' >&2
exit 1
fi
if ! command -v npm >/dev/null 2>&1; then
echo 'Obelisk installation requires npm.' >&2
exit 1
fi
echo "Installing ${PACKAGE}..."
npm install --global "$PACKAGE"
if ! command -v obelisk >/dev/null 2>&1; then
echo 'The CLI was installed, but `obelisk` is not on PATH.' >&2
echo 'Add the npm global bin directory to PATH, then run `obelisk --version`.' >&2
exit 1
fi
obelisk --version
echo 'Obelisk CLI installed. Run `obelisk install` to install the agent skill.'
+15
View File
@@ -214,6 +214,10 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@obelisk-apps/cli": {
"resolved": "packages/cli",
"link": true
},
"node_modules/@obelisk/core": { "node_modules/@obelisk/core": {
"resolved": "packages/core", "resolved": "packages/core",
"link": true "link": true
@@ -1283,6 +1287,17 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"packages/cli": {
"name": "@obelisk-apps/cli",
"version": "0.1.0",
"license": "AGPL-3.0",
"bin": {
"obelisk": "dist/cli/src/obelisk.js"
},
"engines": {
"node": ">=22.13.0"
}
},
"packages/core": { "packages/core": {
"name": "@obelisk/core", "name": "@obelisk/core",
"version": "0.1.0" "version": "0.1.0"
+3 -1
View File
@@ -9,11 +9,13 @@
"packages/*" "packages/*"
], ],
"scripts": { "scripts": {
"pretest": "npm run build:cli",
"test": "node --experimental-test-module-mocks --test tests/*.test.mjs", "test": "node --experimental-test-module-mocks --test tests/*.test.mjs",
"typecheck": "tsc --noEmit && tsc --noEmit -p app/tsconfig.json", "typecheck": "tsc --noEmit && tsc --noEmit -p app/tsconfig.json",
"lint": "eslint .", "lint": "eslint .",
"build:core": "npm run build --workspace @obelisk/core", "build:core": "npm run build --workspace @obelisk/core",
"build:skill": "rm -rf dist/obelisk-skill && tsc -p tsconfig.skill.json && cp packages/core/src/schema.sql dist/obelisk-skill/scripts/ && cp SKILL.md dist/obelisk-skill/ && cp -R references dist/obelisk-skill/references && cp packaging/skill-package.json dist/obelisk-skill/package.json", "build:cli": "npm run build --workspace @obelisk-apps/cli",
"build:skill": "node packaging/build-skill.mjs",
"publish:skill": "npm run build:skill && packaging/publish-skill.sh" "publish:skill": "npm run build:skill && packaging/publish-skill.sh"
}, },
"devDependencies": { "devDependencies": {
+16
View File
@@ -0,0 +1,16 @@
# Obelisk CLI
The local Obelisk runtime used by coding agents. It indexes Claude Code and
Codex transcripts into `~/.obelisk/obelisk.sqlite` and exposes the stable
`build`, `search`, `query`, and `attune` process interface.
```bash
npm install --global @obelisk-apps/cli
obelisk --version
obelisk install
obelisk --query /tmp/query.mjs
```
`obelisk install` installs the separate docs-only agent skill from
`tommy0103/obelisk-skill`. The CLI itself remains daemon-free: each command
refreshes the local index when write ownership is available, then exits.
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@obelisk-apps/cli",
"version": "0.1.0",
"description": "Local Obelisk runtime for coding agents.",
"license": "AGPL-3.0",
"type": "module",
"bin": {
"obelisk": "dist/cli/src/obelisk.js"
},
"files": [
"dist",
"README.md"
],
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"build": "node scripts/build.mjs",
"prepack": "npm run build"
},
"publishConfig": {
"access": "public"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { execFileSync } from 'node:child_process';
import { copyFileSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const cliRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = resolve(cliRoot, '../..');
const outDir = resolve(cliRoot, 'dist');
const tsc = resolve(repoRoot, 'node_modules/typescript/bin/tsc');
rmSync(outDir, { recursive: true, force: true });
execFileSync(process.execPath, [tsc, '-p', resolve(cliRoot, 'tsconfig.build.json')], {
cwd: repoRoot,
stdio: 'inherit',
});
const schemaTarget = resolve(outDir, 'core/src/schema.sql');
mkdirSync(dirname(schemaTarget), { recursive: true });
copyFileSync(resolve(repoRoot, 'packages/core/src/schema.sql'), schemaTarget);
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import {
DB_PATH,
buildIndex,
searchText,
executeQuery,
executeAttune,
} from '../../core/src/core.ts';
async function main() {
const args = process.argv.slice(2);
const fail = (value: unknown): void => {
const error = value instanceof Error ? value : new Error(String(value));
process.stdout.write(JSON.stringify({ error: error.message, stack: error.stack }) + '\n');
process.exitCode = 1;
};
const emit = (value: unknown): void => {
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
};
if (args[0] === '--version' || args[0] === '-v') {
const packageJson = JSON.parse(
readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'),
) as { version: string };
process.stdout.write(`${packageJson.version}\n`);
return;
}
if (args[0] === '--build') {
try {
buildIndex({ force: true });
process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n');
} catch (error) { fail(error); }
return;
}
if (args[0] === '--search' && args[1]) {
try { emit(searchText(args.slice(1).join(' '))); } catch (error) { fail(error); }
return;
}
if (args[0] === '--query' && args[1]) {
try { emit(await executeQuery(readFileSync(resolve(args[1]), 'utf8'))); } catch (error) { fail(error); }
return;
}
if (args[0] === '--attune' && args[1]) {
try { emit(await executeAttune(readFileSync(resolve(args[1]), 'utf8'))); } catch (error) { fail(error); }
return;
}
if (args[0] === 'install') {
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const child = spawnSync(
npx,
['--yes', 'skills', 'add', 'tommy0103/obelisk-skill', ...args.slice(1)],
{ stdio: 'inherit', shell: process.platform === 'win32' },
);
if (child.error) {
process.stderr.write(`Unable to run the skills installer: ${child.error.message}\n`);
process.exitCode = 1;
} else {
process.exitCode = child.status ?? 1;
}
return;
}
process.stderr.write('Usage:\n obelisk install [skills options]\n obelisk --build\n obelisk --search "text"\n obelisk --query <file.js>\n obelisk --attune <file.js>\n');
process.exitCode = 1;
}
void main();
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "..",
"declaration": false,
"rewriteRelativeImportExtensions": true
},
"include": [
"src/**/*.ts",
"../core/src/**/*.ts"
],
"exclude": [
"../core/src/runtime.ts"
]
}
+3 -3
View File
@@ -1,11 +1,11 @@
// Obelisk Core package (see docs/adr/0003-core-typescript-esm-precompiled.md). // Obelisk Core package (see docs/adr/0003-core-typescript-esm-precompiled.md).
// //
// The single shared implementation behind every transport. runtime.js (skill), // The single shared implementation behind every transport. The CLI and later
// and later the CLI and MCP server, are thin shells over these four functions; // the MCP server are thin shells over these four functions;
// none of them re-implement retrieval or own the DB lifecycle. // none of them re-implement retrieval or own the DB lifecycle.
// //
// Authored in TypeScript with erasable-only syntax so Node can run it directly // Authored in TypeScript with erasable-only syntax so Node can run it directly
// via type stripping in development, while the skill artifact ships readable, // via type stripping in development, while the CLI package ships readable,
// non-bundled tsc output. Core source lives in the @obelisk/core workspace. // non-bundled tsc output. Core source lives in the @obelisk/core workspace.
import { createContext, runInNewContext } from 'node:vm'; import { createContext, runInNewContext } from 'node:vm';
+1 -1
View File
@@ -2,7 +2,7 @@
// //
// Provider-agnostic and binding-agnostic: it consumes the IndexRecord stream // Provider-agnostic and binding-agnostic: it consumes the IndexRecord stream
// from any adapter's parse() and writes rows into the injected database handle // from any adapter's parse() and writes rows into the injected database handle
// (node:sqlite for the skill/CLI, better-sqlite3 for the app — they share the // (node:sqlite for the CLI, better-sqlite3 for the app — they share the
// prepare/run/get API). It is the ONLY layer that touches the database and the // prepare/run/get API). It is the ONLY layer that touches the database and the
// only place that knows the schema. Adapters stay pure. // only place that knows the schema. Adapters stay pure.
// //
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env node
// Skill transport: a typed thin CLI shell over the Obelisk Core package.
// It only parses args, reads script files, prints JSON, and owns the uniform
// { error, stack } + exit-1 error envelope. All logic lives in Core.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const fs = require('node:fs');
const path = require('node:path');
import { DB_PATH, buildIndex, searchText, executeQuery, executeAttune } from './core.ts';
async function main() {
const args = process.argv.slice(2);
// Uniform error envelope across all four verbs: a failure is reported as
// { error, stack } on stdout with exit code 1, never a raw crash on stderr.
const fail = (e: unknown): void => {
const error = e instanceof Error ? e : new Error(String(e));
process.stdout.write(JSON.stringify({ error: error.message, stack: error.stack }) + '\n');
process.exitCode = 1;
};
const emit = (r: unknown): void => {
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
};
if (args[0] === '--build') {
try {
buildIndex({ force: true });
process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n');
} catch (e) { fail(e); }
return;
}
if (args[0] === '--search' && args[1]) {
try { emit(searchText(args.slice(1).join(' '))); } catch (e) { fail(e); }
return;
}
if (args[0] === '--query' && args[1]) {
try { emit(await executeQuery(fs.readFileSync(path.resolve(args[1]), 'utf8'))); } catch (e) { fail(e); }
return;
}
if (args[0] === '--attune' && args[1]) {
try { emit(await executeAttune(fs.readFileSync(path.resolve(args[1]), 'utf8'))); } catch (e) { fail(e); }
return;
}
process.stderr.write('Usage:\n node runtime.js --build\n node runtime.js --search "text"\n node runtime.js --query <file.js>\n node runtime.js --attune <file.js>\n');
process.exitCode = 1;
}
main();
+1 -1
View File
@@ -1,6 +1,6 @@
// Binding-agnostic SQLite write plumbing shared from the Core package // Binding-agnostic SQLite write plumbing shared from the Core package
// (docs/adr/0006). The injected db must expose `exec(sql)`; this works for both // (docs/adr/0006). The injected db must expose `exec(sql)`; this works for both
// node:sqlite (skill/CLI) and better-sqlite3 (app), same injection model as // node:sqlite (CLI) and better-sqlite3 (app), same injection model as
// `persist`. // `persist`.
export interface WriteTxDb { export interface WriteTxDb {
+13
View File
@@ -0,0 +1,13 @@
import { cpSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const source = resolve(repoRoot, 'skill-doc');
const target = resolve(repoRoot, 'dist/obelisk-skill');
rmSync(target, { recursive: true, force: true });
mkdirSync(target, { recursive: true });
cpSync(resolve(source, 'SKILL.md'), resolve(target, 'SKILL.md'));
cpSync(resolve(source, 'references'), resolve(target, 'references'), { recursive: true });
cpSync(resolve(repoRoot, 'packaging/skill-package.json'), resolve(target, 'package.json'));
+1 -1
View File
@@ -5,7 +5,7 @@ SKILL_ARTIFACT="dist/obelisk-skill"
SKILL_REPO="dist/obelisk-skill-repo" SKILL_REPO="dist/obelisk-skill-repo"
REMOTE="git@github.com:tommy0103/obelisk-skill.git" REMOTE="git@github.com:tommy0103/obelisk-skill.git"
if [ ! -d "$SKILL_ARTIFACT/scripts" ]; then if [ ! -f "$SKILL_ARTIFACT/SKILL.md" ] || [ ! -d "$SKILL_ARTIFACT/references" ]; then
echo "Error: run 'npm run build:skill' first" >&2 echo "Error: run 'npm run build:skill' first" >&2
exit 1 exit 1
fi fi
+7 -3
View File
@@ -6,9 +6,13 @@ layer over local Claude Code and Codex session history.
## Install ## Install
```bash ```bash
npx skills add tommy0103/obelisk-skill npm install --global @obelisk-apps/cli
obelisk install
``` ```
The CLI is the executable runtime. This repository contains only the agent
instructions and progressive-disclosure references.
Then in any Claude Code session: Then in any Claude Code session:
``` ```
@@ -17,7 +21,7 @@ Then in any Claude Code session:
## Source ## Source
This repository is **auto-published** from the compiled skill artifact of This repository is **auto-published** from the docs-only skill artifact of
[tommy0103/obelisk](https://github.com/tommy0103/obelisk). Do not open pull [tommy0103/obelisk](https://github.com/tommy0103/obelisk). Do not open pull
requests here — contribute to the source repo instead. requests here — contribute to the source repo instead.
@@ -25,5 +29,5 @@ requests here — contribute to the source repo instead.
MIT — see [LICENSE](LICENSE) in this repository. The MIT — see [LICENSE](LICENSE) in this repository. The
[source repository](https://github.com/tommy0103/obelisk) is AGPL-3.0; this [source repository](https://github.com/tommy0103/obelisk) is AGPL-3.0; this
compiled skill artifact is explicitly relicensed under MIT by the copyright skill documentation artifact is explicitly relicensed under MIT by the copyright
holder. holder.
+1 -2
View File
@@ -1,7 +1,6 @@
{ {
"name": "obelisk-skill", "name": "obelisk-skill",
"version": "0.1.0", "version": "0.1.0",
"type": "module", "description": "Obelisk agent skill documentation for the @obelisk-apps/cli runtime. Built by `npm run build:skill`; sources live in the main repo.",
"description": "Obelisk skill artifact — readable compiled Core (providers + persist + runtime) over local Claude Code and Codex history. Built by `npm run build:skill`; sources live in the main repo.",
"license": "MIT" "license": "MIT"
} }
+1 -1
View File
@@ -15,7 +15,7 @@ if [ "$TARGET_DIR" = "/" ] || [ "$TARGET_DIR" = "." ] || [ "$TARGET_DIR" = "$ROO
exit 1 exit 1
fi fi
for required in SKILL.md package.json references scripts; do for required in SKILL.md package.json references; do
if [ ! -e "$ARTIFACT_DIR/$required" ]; then if [ ! -e "$ARTIFACT_DIR/$required" ]; then
echo "Error: skill artifact missing $required at $ARTIFACT_DIR" >&2 echo "Error: skill artifact missing $required at $ARTIFACT_DIR" >&2
exit 1 exit 1
+406
View File
@@ -0,0 +1,406 @@
---
name: obelisk
description: >
Search and query past Claude Code and Codex session history.
Reactive: when the user asks "how did I fix X", "what did we do last time", "find the session where", "上次怎么修的", "之前的session", "历史记录".
Proactive: when the user references past work you lack context for, when you're about to modify a file with complex edit history, when the user says "继续之前的" or "continue where we left off", or when understanding prior decisions would improve your current response.
Memory: when the user says "记住这个", "remember this", "写入记忆", "save this conclusion", or when you determine a retrieval result contains a conclusion worth persisting.
allowed-tools:
- Read
- Bash(obelisk:*)
- Write
---
# obelisk
Search and query Claude Code and Codex session history stored in `~/.claude/`
and `~/.codex/`.
Obelisk indexes sessions, messages, tool calls, tool results, summaries,
subagents, workflows, workflow agents, parent chains, and raw JSONL lines into
SQLite + FTS5.
Obelisk has two transcript sources. Treat both as ordinary sessions by default:
Claude rows use `source='claude'`; Codex rows use `source='codex'` and IDs
prefixed with `codex:`. Use `source` only when provenance matters or the user
asks to scope to one provider. Codex subagent child threads are mapped to the
same `subagents` table; Codex workflow rows may be absent because Codex does not
emit Claude-style workflow metadata.
Obelisk is a CodeAct memory layer: write a small JS query, run it locally, read
the JSON, then answer. Do not turn history into a flat document or browse entire
sessions by default.
## Quick Start
Fast keyword search:
```bash
obelisk --search "keyword"
```
Custom query:
1. Write a bounded JS query to a temp file, for example `/tmp/q.mjs`.
2. Run:
```bash
obelisk --query /tmp/q.mjs
```
3. Parse JSON stdout and answer with concise evidence.
The query file runs inside `(async () => { ... })()`. Use `return` to emit JSON.
Query scripts are read-only: `remember()` and `forget()` are not available, and
`sql()` only accepts read-only SELECT/WITH queries.
## Default First Pass
Start with helpers, not raw SQL. For the first Obelisk query in a task, normally
call `overview({ limit: 6 })` unless the user already gave an exact
`session_id`, message `uuid`, or absolute file path.
For semantic or synthesis tasks, combine orientation, memory recall, and raw
session evidence before deciding whether a detail pass is needed:
```js
const map = overview({ limit: 6 });
const project = map.current.project?.project;
const topic = 'English topic terms translated from the user request';
return {
orientation: map.current_project,
prior_memories: memories({ project, query: topic, limit: 5 }),
session_evidence: search(topic.replace(/[-_]/g, ' '), { project, limit: 8 }),
};
```
Use `sql()` only as an escalation path for exact joins, aggregations, or schema
questions that helpers cannot express cleanly. Do not use raw SQL as a generic
fallback for broad retrieval.
## Intent Routing
Obelisk supports a small intent prefix layer after `/obelisk`. This is for
output intent, not retrieval architecture.
| Intent | Description | Reference |
|---|---|---|
| `recap [target]` | Generate weekly/monthly recap card content for app handoff or share-style output. | `references/recap/overview.md` |
Routing rules:
1. If the first word is `recap`, read `references/recap/overview.md` before the
first query. Everything after `recap` is the recap target.
Common app-generated prompts include `/obelisk recap this week`,
`/obelisk recap last week`, `/obelisk recap this month`, and
`/obelisk recap last month`; interpret these as natural period targets
relative to the current date and timezone.
2. `recap` does not create a separate retrieval layer. It still uses
`overview()`, `memories()`, helpers, and `sql()` only when needed.
3. Follow the overview's card-by-card sequence. Each card has its own retrieval
pattern and writing file; retrieve that card's evidence, read that card's
writing file, update the JSON, then move to the next card. Do not preload all
recap references before the current card is written.
4. If the first word is not `recap`, do not load
`references/recap/overview.md`. Continue with Query Routing below. Do not
infer recap from broad requests for weekly/monthly summaries, charts,
rankings, shareable cards, or playlist-style metaphors.
## Reference Map
Use references by job, not by habit:
| Reference | Use when |
|---|---|
| `references/query-patterns.md` | Broad synthesis, progress summaries, design history, weekly/monthly reviews, approved memory write/archive/update scripts, or questions about what the user did/learned/decided/tried/abandoned. |
| `references/retrieval-semantics.md` | Multi-step retrieval, scoped project/file/session searches, or when scope/artifact/semantic boundaries affect query design. |
| `references/schema.md` | Raw SQL field and join quick reference before writing non-trivial `sql()`. |
| `references/api-reference.md` | Helper signatures, option names, return fields, or exact `remember()` / `forget()` parameter details are unclear. |
| `references/pitfalls.md` | Error recovery, FTS syntax, aliases, ordering, row-shape surprises, or compact/raw tradeoffs. |
| `references/recap/overview.md` | Explicit `/obelisk recap ...` requests only. |
## Query Routing
Before writing a query, classify the task. Progressive disclosure is useful, but
skipping the relevant reference usually costs extra query rounds.
- Read `references/query-patterns.md` before the first query for broad synthesis, progress summaries, design history, ordinary weekly/monthly reviews, or questions that ask what the user did, learned, decided, tried, or abandoned. Start from the first-pass or one-shot synthesis pattern, then run a faceted detail pass if needed.
- Read `references/retrieval-semantics.md` before multi-step retrieval, scoped project/file/session searches, or synthesis/conclusion/history questions. It defines the query design frame.
- Read `references/schema.md` before raw `sql()` unless the needed table/column relationship is already explicit here. It is intentionally short and SQL-focused. Do this before running the SQL, not after a missing-column error. Do not start with raw SQL for broad synthesis unless helpers cannot express the needed aggregation or join.
- Read `references/api-reference.md` when helper option names, return fields, scalar shorthand behavior, or `remember()`/`forget()` details are unclear.
- Read `references/pitfalls.md` after an error or when FTS syntax, aliases, ordering, row shapes, or compact/raw tradeoffs are unclear.
If a helper row shape is unclear, first run a tiny scoped query and return
`Object.keys(row)` or a compact sample. Do not invent field names.
For approved memory mutations, follow the Memory Layer section below first.
Use `references/query-patterns.md` for copyable `--attune` scripts
(`Attune Approved Memory`, `Forget Approved Memory`, `Update Approved Memory`),
and `references/api-reference.md` only for exact parameter semantics.
## Core API
### `search(text, opts?)`
Full-text search across main messages, subagent messages, and workflow-agent
messages.
Returns:
```js
[{ message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd, source },
session: { id, title, project, started_at, source },
rank,
context }]
```
`context` here means temporal neighbors: nearby messages in the same session by
timestamp. It is not the parent chain. Use `context(uuid)` or `trace(uuid)` for
causal/parent-chain context.
Use `message.content_type` to keep evidence boundaries intact:
`text` is user/assistant visible language, `thinking` is trace/debug material,
`tool_use` marks a tool-call message whose details live in `tool_calls`, and
`tool_result` marks a tool-result message whose details live in `tool_results`.
`unknown` is a conservative fallback. Do not treat `thinking` as a user-visible
assistant conclusion. Real user input is `type='user'` plus `content_type='text'`;
do not invent a separate `user_message` content type.
Use `message.is_meta` to separate transcript control-plane material from
conversation evidence. `is_meta=1` marks injected caveats, command envelopes, or
other messages that entered the transcript as user-role content but should not
be treated as the user's request by default. `search()` and `thread()` omit meta
messages unless `includeMeta: true` is passed; `context()` and `trace()` preserve
the original chain and expose `is_meta` on rows.
Opts: `{ limit, sessionId, project, after, before, cwd, source, includeMeta }`.
`project` is a SQL `LIKE` filter over `sessions.project`, not an exact project
identity. Results are already ordered by FTS5 rank; lower rank sorts earlier.
Prefer returned order over manually interpreting numeric rank unless you are
deliberately using FTS5 semantics.
`source` can be `'claude'`, `'codex'`, or omitted. Omitted means search all
indexed sources.
### `context(uuid)`
Returns the full story around one indexed message:
```js
{ message, parentChain, session, subagent, workflow }
```
Use this after `search()` finds a promising message. It is the usual way to
expand vertically from one evidence point without dumping the whole session.
### `sql(query, ...params)`
Read-only SQL SELECT/WITH with `?` placeholders. Returns array rows. SQL is an
escape hatch for exact structured joins and aggregations after the helper-first
surface is insufficient; it is not the default retrieval entry point.
Before writing non-trivial SQL, read `references/schema.md`. It is the raw SQL
field/join quick reference. The executable DDL is CLI-owned and is deliberately
not duplicated in this docs-only skill. Common safe joins:
- `tool_calls` does not have timestamps. Join `messages m ON m.uuid = tc.message_uuid`.
- `tool_results` does not have timestamps. Join `messages m ON m.uuid = tr.message_uuid`.
- For project/session filters, join `sessions s ON s.id = <table>.session_id`.
- Prefer SQL-side `GROUP BY`, `COUNT`, `MAX`, `ORDER BY`, and `LIMIT` over hand-counting in the final answer.
Tables: `sessions`, `messages`, `tool_calls`, `tool_results`, `summaries`,
`memories`, `subagents`, `workflows`, `workflow_agents`, `messages_fts`.
## Structured Helpers
These helpers are convenience accessors over the same SQLite structure. They do
not replace `sql()`, but they are the default first-pass surface. Use `sql()`
when you need an exact aggregation or a join the helper does not expose.
All list helpers accept a bounded `limit`. Many also accept:
`{ project, after, before, sessionId, sessions, branch, source }`. Check
`references/api-reference.md` or a tiny sample before relying on less common
filters or return fields.
- `overview(opts?)` -- compact orientation map. Returns current cwd/project if knowable, global project/source counts, and current-project recent sessions plus memory records. It is a map, not evidence.
- `sessions(opts?)` -- session rows, newest first. `project` is a SQL `LIKE` pattern.
- `recent(n?)` -- shorthand for recent sessions.
- `summaries(opts?)` -- summary rows, newest first: `{ id, session_id, timestamp, source, content, session_title, project }`; here `source` is the summary kind, not the transcript provider.
- `subagents(opts?)` -- subagent metadata plus `messageCount`.
- `workflows(opts?)` -- workflow runs, newest first.
- `workflowTree(runId)` -- workflow row plus parsed `result` and `agents`; may include bulky `script` and `result_json`, so project compact fields.
- `fileHistory(filePath, opts?)` -- Read/Edit/Write tool calls for a file, oldest first; includes many `Read` rows.
- `failures(opts?)` -- failed tool results with tool/session context, newest first.
- `trace(uuid)` -- parent chain from root to message.
- `thread(sessionId, opts?)` -- session messages ordered by timestamp, omitting meta messages by default. Pass `{ includeMeta: true }` when investigating injected context or command envelopes.
- `raw(uuid, opts?)` -- windowed access to the original JSONL line.
- `memories(opts?)` -- recall memory layer. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. Without `query`, returns active memory records newest first. With `query`, searches `summary`/`path` through safe FTS5 tokenization and returns `rank`; lower rank sorts earlier. Records may include nullable JSON `anchors` for explicit recall surfaces such as files. Read the file at `path` for full content.
## Retrieval Contract
Keep queries scoped, bounded, and structural.
- Scope First: classify the locator as scope, artifact, or semantic. Use the narrowest structural locator before FTS; empty scoped results are valid unless the user asks to broaden.
- Orient First: for a new task, normally call `overview({ limit: 6 })` before deeper retrieval unless the user gave an exact session/message/file locator. It is a navigation map; confirm facts with `memories()`, `search()`, helpers, or, only when needed, `sql()`.
- Helper First: prefer `overview()`, `memories()`, `search()`, `sessions()`, `summaries()`, `fileHistory()`, and other helpers for first-pass retrieval. Escalate to raw `sql()` only when helpers cannot express the needed join, grouping, or exact schema-level check.
- Plan Before Probe: for conclusion, broad history, failure investigation, or file evolution, write a bounded retrieval script instead of spending turns on intermediate results.
- Structure Before Text: compute counts, joins, grouping, dedupe, and projection in SQL or JS; keep runtime JSON compact, ideally under 10k-12k chars for synthesis tasks.
- Evidence Before Conclusion: return compact evidence with stable IDs (`session_id`, `uuid`, `tool_call_id`, `run_id`, `agent_id`) and short snippets, then synthesize in the final answer.
- Exclude Meta By Default: `is_meta=1` rows are injected/control-plane transcript material. Helpers hide them by default; raw SQL for ordinary conversation evidence should include `COALESCE(m.is_meta,0)=0` unless meta rows are the investigation target.
- Persist Durable Conclusions: after answering, if retrieval produced a durable conclusion that future sessions are likely to reuse and `memories()` does not already cover it, explicitly offer to write a memory. Keep the offer brief. Do not write the markdown file or run `--attune` until the user approves.
If field, context, ordering, FTS, or helper semantics affect the query, read
`references/retrieval-semantics.md` before coding. If a query errors, read
`references/pitfalls.md` before retrying.
## Memory Layer
Obelisk has a persistent memory layer alongside raw session data. Every
retrieval queries both layers: `memories()` for prior conclusions, `search()`
and helpers for raw session evidence. Use memory as prior notes, not final
authority. If a memory record influences your answer, say naturally that it was
previously recorded, and compare it with raw session evidence when correctness
depends on it. Raw session data is the evidence layer, but one hit is not a
complete truth; query and cite it compactly.
The memory layer is English-indexed. Use English terms in `memories({ query })`
even when the user asks in another language. Write every `remember().summary`
in English, regardless of the current conversation language. The runtime rejects
obvious CJK text in memory queries and summaries as a guardrail.
**Recall:** query `memories({ query: 'English topic terms', project: '...' })`
to find prior conclusions relevant to the current task. Translate non-English
user requests into concise English query terms before calling `memories()`.
Memory recall uses safe FTS5 tokenization over `summary` and `path`, so
hyphens/punctuation are tokenized instead of causing raw `MATCH` syntax errors.
Like other list helpers, passing a string is treated as `sessionId`, and passing
a number is treated as `limit`. Read the file at `path` for full content.
`memories()` returns active memories only. An archived memory is
management/audit data, not recall data.
Good memory candidates include design decisions, project conventions, abandoned
alternatives, repeated failure causes, workflow patterns, and conclusions
synthesized across multiple raw evidence points. Do not propose memory for
one-off lookups, uncertain findings, or conclusions already covered by existing
memories.
**Mutation approvals:** judging whether to use a memory in the current answer is
an agent decision and does not require approval. Persistent memory changes do.
If the user explicitly says a memory is wrong, outdated, should be forgotten, or
should now say something else, that request is the approval to archive or update
the exact matching memory. Do not ask for a second confirmation unless multiple
memories could match. If you notice a possible conflict yourself, explain it
briefly and ask before changing memory state.
**Writing memories:** after a retrieval produces a conclusion worth persisting,
propose writing a memory file. The user must approve. Flow:
1. Write a markdown file using the `Write` tool (user approves).
2. Register it via `remember()` in a narrow memory-registration script:
```js
return remember({
path: '.obelisk/memories/design-decision-x.md',
session_id: 'current-session-id',
message_start: 'uuid-of-first-relevant-msg',
message_end: 'uuid-of-last-relevant-msg',
anchors: [{ kind: 'file', path: 'src/path/to/file.ts' }],
summary: 'Detailed summary: what was decided, why, what alternatives were considered, and what constraints drove the choice.'
})
```
Run the registration script with:
```bash
obelisk --attune /tmp/register-memory.mjs
```
`--attune` exposes only memory mutation helpers: `remember()` and `forget()`.
It does not expose `search()`, `sql()`, `memories()`, or other retrieval
helpers. If you need source IDs or memory IDs, find them first with a normal
`--query` script.
`remember()` validates that `path` already exists and points to a file. Relative
paths are resolved against the source session's `project_path` when
`session_id` is provided, then stored as normalized absolute paths. Prefer
project-relative paths such as `.obelisk/memories/...` plus `session_id`.
Optional `anchors` must be an array of objects and is stored as nullable JSON
text. Use it only for explicit recall surfaces, such as files associated with
the memory.
`summary` must be English and detailed enough that `memories()` results alone
can judge relevance without reading the file. Include the decision, the
reasoning, and the key constraints — not just a title.
The `message_start`/`message_end` range marks where in the conversation this
conclusion was drawn. Use it later to trace back to the original evidence.
**Forgetting memories:** if the user says a memory is outdated, wrong, or should
be forgotten, use normal recall first to identify the exact memory ID. If there
is exactly one clear candidate, the user's request is approval to archive it. If
multiple memories could match, ask which one to forget. Then run an `--attune`
script:
```js
return forget({
id: 'mem-id-to-delete',
reason: 'Outdated by newer project guidance.',
});
```
`forget()` archives the memory record by setting `deleted_at` and
`deleted_reason`. It removes the record from active recall but does not delete
the markdown file. Memory records survive index rebuilds and are never changed
automatically.
**Updating memories:** updating memory is one user-approved operation:
archive the old memory with `forget()`, then write and register a replacement
markdown memory with `remember()`. If the user explicitly corrected the memory,
that correction is approval for the combined archive-plus-write flow. If you
discovered the mismatch yourself, ask first.
## Minimal Patterns
Search, then expand one promising hit:
```js
const hits = search('auth fix', { limit: 5 });
if (!hits.length) return [];
return hits.slice(0, 3).map(h => ({
session_id: h.session.id,
session_title: h.session.title,
uuid: h.message.uuid,
snippet: h.message.text?.slice(0, 240),
}));
```
Check helper fields before assuming names:
```js
const rows = summaries({ project: '%quiet-zero%', limit: 1 });
return rows.length ? Object.keys(rows[0]) : [];
```
Fetch message neighbors without a full thread:
```js
const hit = search('runtime query', { limit: 1 })[0];
return sql(
`SELECT uuid, role, timestamp, substr(text,1,240) AS snippet
FROM messages
WHERE session_id=? AND timestamp>=?
ORDER BY timestamp LIMIT 6`,
hit.session.id,
hit.message.timestamp
);
```
See `references/query-patterns.md` for longer recipes.
## Notes
- First run builds the index. Later runs update incrementally.
- DB location: `~/.obelisk/obelisk.sqlite`; old `~/.claude/obelisk.sqlite` is copied forward if needed.
- Query scripts run in a sandboxed VM with no filesystem or network access from inside the script.
- Indexed text and stored tool inputs/results are truncated to 10k chars. Use `raw(uuid, { offset, limit })` for specific JSONL windows.
@@ -1,7 +1,7 @@
# Obelisk -- Helper API Reference # Obelisk -- Helper API Reference
Detailed reference for globals available inside `runtime.js --query` and Detailed reference for globals available inside `obelisk --query` and
`runtime.js --attune` scripts. `obelisk --attune` scripts.
- Use `references/schema.md` for raw SQL table/field/join checks. - Use `references/schema.md` for raw SQL table/field/join checks.
- Use `references/query-patterns.md` for copyable retrieval plans. - Use `references/query-patterns.md` for copyable retrieval plans.
@@ -16,7 +16,7 @@ memory mutation helpers.
### Read Helpers ### Read Helpers
These globals are available only in `runtime.js --query` scripts: These globals are available only in `obelisk --query` scripts:
```js ```js
sql, search, context, trace, thread, raw, sql, search, context, trace, thread, raw,
@@ -31,7 +31,7 @@ helpers is treated as `sessionId`; passing a number is treated as `limit`.
### Mutation Helpers ### Mutation Helpers
These globals are available only in `runtime.js --attune` scripts: These globals are available only in `obelisk --attune` scripts:
```js ```js
remember, forget remember, forget
@@ -413,7 +413,7 @@ not a counting primitive.
#### `remember(record)` #### `remember(record)`
Register a human-approved markdown memory file. Available only in Register a human-approved markdown memory file. Available only in
`runtime.js --attune` scripts. `obelisk --attune` scripts.
| Param | Type | Description | | Param | Type | Description |
| --- | --- | --- | | --- | --- | --- |
@@ -439,7 +439,7 @@ Returns:
#### `forget(record)` #### `forget(record)`
Archive a human-approved memory record. Available only in Archive a human-approved memory record. Available only in
`runtime.js --attune` scripts. `obelisk --attune` scripts.
| Param | Type | Description | | Param | Type | Description |
| --- | --- | --- | | --- | --- | --- |
@@ -1,6 +1,6 @@
# Obelisk Query Patterns # Obelisk Query Patterns
These are copyable CodeAct patterns for `runtime.js --query` scripts plus These are copyable CodeAct patterns for `obelisk --query` scripts plus
`--attune` memory mutation patterns. They are not new APIs. Adapt them to the `--attune` memory mutation patterns. They are not new APIs. Adapt them to the
user's scope and return compact evidence. user's scope and return compact evidence.
@@ -179,7 +179,7 @@ Use this only after the user approves writing memory and the markdown file
already exists. `remember()` validates the file and stores a normalized absolute already exists. `remember()` validates the file and stores a normalized absolute
path, so keep the script small and return the registered record. path, so keep the script small and return the registered record.
Run this script with `runtime.js --attune <script>`. The `--attune` runtime Run this script with `obelisk --attune <script>`. The `--attune` runtime
exposes only `remember()` and `forget()`, not retrieval helpers. exposes only `remember()` and `forget()`, not retrieval helpers.
```js ```js
@@ -204,7 +204,7 @@ the exact memory ID in a normal `--query` script first. If one candidate clearly
matches the user's request, that request is approval to archive it; if several matches the user's request, that request is approval to archive it; if several
candidates match, ask which one to forget. candidates match, ask which one to forget.
Run the mutation with `runtime.js --attune <script>`: Run the mutation with `obelisk --attune <script>`:
```js ```js
return forget({ return forget({
@@ -3,7 +3,7 @@
Read this before writing non-trivial `sql()` queries. It is a compact field and Read this before writing non-trivial `sql()` queries. It is a compact field and
join map for raw SQL, not the full helper API manual. join map for raw SQL, not the full helper API manual.
- Executable DDL: `scripts/schema.sql` - Canonical executable DDL: [`packages/core/src/schema.sql`](https://github.com/tommy0103/obelisk/blob/main/packages/core/src/schema.sql) in the CLI source repository (not duplicated in this docs-only skill)
- Helper signatures and return shapes: `references/api-reference.md` - Helper signatures and return shapes: `references/api-reference.md`
- Query recipes and synthesis patterns: `references/query-patterns.md` - Query recipes and synthesis patterns: `references/query-patterns.md`
- FTS, alias, ordering, and compactness traps: `references/pitfalls.md` - FTS, alias, ordering, and compactness traps: `references/pitfalls.md`
-90
View File
@@ -1,90 +0,0 @@
// Phase 6 acceptance: `npm run build:skill` must produce a runnable, readable,
// .ts-free skill artifact under dist/obelisk-skill. This guards ADR-0004 (ship
// readable non-bundled compiled JS) and catches import-rewriting / config drift
// that would only surface when the installed skill runs under plain Node (no
// type-stripping, no .ts resolution).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const skillDir = join(repoRoot, 'dist', 'obelisk-skill');
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
test('build:skill produces a runnable, readable, .ts-free skill artifact', () => {
execFileSync('npm', ['run', 'build:skill'], { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
// Structure: compiled Core + schema + docs + package.json.
for (const rel of [
'package.json', 'SKILL.md', 'references/api-reference.md',
'scripts/core.js', 'scripts/persist.js', 'scripts/providers/claude.js',
'scripts/providers/codex.js', 'scripts/runtime.js', 'scripts/indexer.js',
'scripts/db.js', 'scripts/parsing.js', 'scripts/query.js',
'scripts/sqlite-types.js', 'scripts/schema.sql',
]) {
assert.ok(existsSync(join(skillDir, rel)), `artifact missing ${rel}`);
}
assert.equal(JSON.parse(readFileSync(join(skillDir, 'package.json'), 'utf8')).type, 'module');
// Readable, not bundled: emitted files stay ~1:1 with source, and no relative
// import may still point at a .ts file (that would break under plain Node).
const jsFiles = walk(join(skillDir, 'scripts')).filter(f => f.endsWith('.js') || f.endsWith('.mjs'));
assert.ok(jsFiles.length >= 6, 'expected multiple un-bundled script files');
for (const file of jsFiles) {
const src = readFileSync(file, 'utf8');
assert.ok(!/from\s+['"][^'"]*\.ts['"]/.test(src), `${file} still imports a .ts module`);
assert.ok(!/import\(['"][^'"]*\.ts['"]\)/.test(src), `${file} still dynamic-imports a .ts module`);
}
// Runs end to end under plain Node against a fresh HOME (no type-stripping).
const home = mkdtempSync(join(tmpdir(), 'obelisk-skill-artifact-'));
try {
const projDir = join(home, '.claude', 'projects', '-tmp-proj');
mkdirSync(projDir, { recursive: true });
writeFileSync(join(projDir, 'smoke.jsonl'),
JSON.stringify({ uuid: 'm1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/tmp/proj', message: { role: 'user', content: 'hello artifact' } }) + '\n');
const env = { ...process.env, HOME: home };
const runtime = join(skillDir, 'scripts', 'runtime.js');
const build = spawnSync(process.execPath, [runtime, '--build'], { env, encoding: 'utf8' });
assert.equal(build.status, 0, build.stderr || build.stdout);
const search = spawnSync(process.execPath, [runtime, '--search', 'hello artifact'], { env, encoding: 'utf8' });
assert.equal(search.status, 0, search.stderr || search.stdout);
const hits = JSON.parse(search.stdout);
assert.equal(hits[0]?.message?.text, 'hello artifact', 'compiled artifact indexed and found the message');
const memoryPath = join(home, 'artifact-memory.md');
const attunePath = join(home, 'attune.mjs');
writeFileSync(memoryPath, '# Artifact memory\n');
writeFileSync(attunePath, `return remember(${JSON.stringify({
path: memoryPath,
session_id: 'smoke',
summary: 'Artifact release smoke memory',
})});`);
const attune = spawnSync(process.execPath, [runtime, '--attune', attunePath], { env, encoding: 'utf8' });
assert.equal(attune.status, 0, attune.stderr || attune.stdout);
assert.match(JSON.parse(attune.stdout).id, /^mem-/);
const queryPath = join(home, 'query.mjs');
writeFileSync(queryPath, "return memories({ sessionId: 'smoke', query: 'Artifact release smoke' });");
const query = spawnSync(process.execPath, [runtime, '--query', queryPath], { env, encoding: 'utf8' });
assert.equal(query.status, 0, query.stderr || query.stdout);
assert.equal(JSON.parse(query.stdout)[0]?.summary, 'Artifact release smoke memory');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
test('root SKILL.md bootstraps the CLI before installing the official skill', () => {
const source = readFileSync(join(repoRoot, 'SKILL.md'), 'utf8');
assert.match(source, /@obelisk-apps\/cli/);
assert.match(source, /install\.sh/);
assert.match(source, /obelisk --version/);
assert.match(source, /obelisk install/);
assert.doesNotMatch(source, /obelisk --query/);
});
test('install.sh installs and verifies only the CLI', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-install-script-'));
const fakeBin = join(home, 'bin');
const npmCapture = join(home, 'npm-args');
const obeliskCapture = join(home, 'obelisk-args');
mkdirSync(fakeBin, { recursive: true });
const npm = join(fakeBin, 'npm');
writeFileSync(npm, `#!/bin/sh\nprintf '%s\\n' "$@" > "${npmCapture}"\n`);
chmodSync(npm, 0o755);
const obelisk = join(fakeBin, 'obelisk');
writeFileSync(obelisk, `#!/bin/sh\nprintf '%s\\n' "$@" > "${obeliskCapture}"\nprintf '0.1.0\\n'\n`);
chmodSync(obelisk, 0o755);
const result = spawnSync('sh', [join(repoRoot, 'install.sh')], {
cwd: repoRoot,
env: {
...process.env,
HOME: home,
PATH: `${fakeBin}${delimiter}${process.env.PATH || ''}`,
},
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(readFileSync(npmCapture, 'utf8').trim().split('\n'), [
'install',
'--global',
'@obelisk-apps/cli',
]);
assert.deepEqual(readFileSync(obeliskCapture, 'utf8').trim().split('\n'), ['--version']);
});
+122
View File
@@ -0,0 +1,122 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { repoRoot, runCli } from './cli-test-helpers.mjs';
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const cliPackage = JSON.parse(readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'));
test('the packaged obelisk command preserves the runtime query envelope', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-package-'));
const query = join(home, 'query.mjs');
writeFileSync(query, 'return { answer: 42 };');
const result = runCli(['--query', query], { home });
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(result.stderr, '');
assert.equal(result.stdout, '{\n "answer": 42\n}\n');
});
test('obelisk --version reports the installed CLI package version', () => {
const result = runCli(['--version']);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(result.stdout, `${cliPackage.version}\n`);
assert.equal(result.stderr, '');
});
test('obelisk install delegates official skill installation to the skills CLI', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-install-'));
const fakeBin = join(home, 'bin');
const capture = join(home, 'args.json');
const captureScript = join(home, 'capture.mjs');
mkdirSync(fakeBin, { recursive: true });
writeFileSync(captureScript, `import { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.OBELISK_TEST_CAPTURE, JSON.stringify(process.argv.slice(2)));\n`);
if (process.platform === 'win32') {
writeFileSync(
join(fakeBin, 'npx.cmd'),
`@echo off\r\n"${process.execPath}" "${captureScript}" %*\r\n`,
);
} else {
const fakeNpx = join(fakeBin, 'npx');
writeFileSync(fakeNpx, `#!/bin/sh\nexec "${process.execPath}" "${captureScript}" "$@"\n`);
chmodSync(fakeNpx, 0o755);
}
const result = runCli(['install', '--global', '--agent', 'codex'], {
home,
env: {
PATH: `${fakeBin}${delimiter}${process.env.PATH || ''}`,
OBELISK_TEST_CAPTURE: capture,
},
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(JSON.parse(readFileSync(capture, 'utf8')), [
'--yes',
'skills',
'add',
'tommy0103/obelisk-skill',
'--global',
'--agent',
'codex',
]);
});
test('npm pack installs one platform-neutral CLI with its schema resource', () => {
const root = mkdtempSync(join(tmpdir(), 'obelisk-cli-pack-'));
const packDir = join(root, 'pack');
const prefix = join(root, 'prefix');
const npmCache = join(root, 'npm-cache');
const npmEnv = { ...process.env, npm_config_cache: npmCache };
mkdirSync(packDir, { recursive: true });
const packed = JSON.parse(execFileSync(
npmCommand,
[
'pack',
'--workspace',
'@obelisk-apps/cli',
'--pack-destination',
packDir,
'--json',
'--ignore-scripts',
],
{
cwd: repoRoot,
env: npmEnv,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
},
));
const metadata = packed[0];
const paths = metadata.files.map(file => file.path);
assert.ok(paths.includes('dist/cli/src/obelisk.js'));
assert.ok(paths.includes('dist/core/src/schema.sql'));
assert.equal(paths.some(path => path.endsWith('.ts')), false);
const tarball = join(packDir, metadata.filename);
execFileSync(
npmCommand,
['install', '--global', '--prefix', prefix, tarball, '--ignore-scripts'],
{ cwd: repoRoot, env: npmEnv, encoding: 'utf8', stdio: 'pipe' },
);
const installedBin = process.platform === 'win32'
? join(prefix, 'obelisk.cmd')
: join(prefix, 'bin', 'obelisk');
const result = spawnSync(installedBin, ['--version'], {
cwd: repoRoot,
encoding: 'utf8',
shell: process.platform === 'win32',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(result.stdout.trim(), cliPackage.version);
});
+18
View File
@@ -0,0 +1,18 @@
import { spawnSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const cliEntry = join(repoRoot, 'packages', 'cli', 'dist', 'cli', 'src', 'obelisk.js');
export function runCli(args, { home, env = {}, cwd = repoRoot } = {}) {
return spawnSync(process.execPath, [cliEntry, ...args], {
cwd,
env: {
...process.env,
...(home ? { HOME: home, USERPROFILE: home } : {}),
...env,
},
encoding: 'utf8',
});
}
+4 -7
View File
@@ -8,18 +8,15 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { runCli } from './cli-test-helpers.mjs';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
function runRuntime(args, home) { function runRuntime(args, home) {
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], { return runCli(args, { home });
cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8',
});
} }
const ID = '019ed000-0000-7000-8000-000000000001'; const ID = '019ed000-0000-7000-8000-000000000001';
+1 -1
View File
@@ -28,7 +28,7 @@ import { createQueryApi, createAttuneApi } from '../packages/core/src/query.ts';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8'); const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
const API_REFERENCE = readFileSync(new URL('../references/api-reference.md', import.meta.url), 'utf8'); const API_REFERENCE = readFileSync(new URL('../skill-doc/references/api-reference.md', import.meta.url), 'utf8');
// Every key asserted below is recorded here so the doc-sync guard can confirm // Every key asserted below is recorded here so the doc-sync guard can confirm
// references/api-reference.md still documents it. // references/api-reference.md still documents it.
+6 -23
View File
@@ -3,14 +3,13 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path'; import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { acquireWriterLease, writerLockPathFor } from '../packages/core/src/writer-lease.ts'; import { acquireWriterLease, writerLockPathFor } from '../packages/core/src/writer-lease.ts';
import { runCli } from './cli-test-helpers.mjs';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
const repoRoot = resolve(new URL('..', import.meta.url).pathname);
test('a passive query does not mutate the index while a fresh daemon owns writes', () => { test('a passive query does not mutate the index while a fresh daemon owns writes', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-daemon-arbitration-')); const home = mkdtempSync(join(tmpdir(), 'obelisk-daemon-arbitration-'));
@@ -27,11 +26,7 @@ test('a passive query does not mutate the index while a fresh daemon owns writes
const queryPath = join(home, 'query.mjs'); const queryPath = join(home, 'query.mjs');
writeFileSync(queryPath, "return 'read-only';"); writeFileSync(queryPath, "return 'read-only';");
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], { const result = runCli(['--query', queryPath], { home });
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(JSON.parse(result.stdout), 'read-only'); assert.equal(JSON.parse(result.stdout), 'read-only');
@@ -55,11 +50,7 @@ test('attune refuses to mutate the index while a fresh daemon owns writes', () =
const attunePath = join(home, 'attune.mjs'); const attunePath = join(home, 'attune.mjs');
writeFileSync(attunePath, 'return true;'); writeFileSync(attunePath, 'return true;');
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--attune', attunePath], { const result = runCli(['--attune', attunePath], { home });
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
assert.equal(result.status, 1); assert.equal(result.status, 1);
assert.match(JSON.parse(result.stdout).error, /daemon owns index writes/i); assert.match(JSON.parse(result.stdout).error, /daemon owns index writes/i);
@@ -86,11 +77,7 @@ test('a passive query stays read-only when another process holds the writer leas
try { try {
const queryPath = join(home, 'query.mjs'); const queryPath = join(home, 'query.mjs');
writeFileSync(queryPath, "return 'writer-busy';"); writeFileSync(queryPath, "return 'writer-busy';");
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], { const result = runCli(['--query', queryPath], { home });
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(JSON.parse(result.stdout), 'writer-busy'); assert.equal(JSON.parse(result.stdout), 'writer-busy');
} finally { } finally {
@@ -120,11 +107,7 @@ test('a passive query fails closed when daemon ownership cannot be read', () =>
try { try {
const queryPath = join(home, 'query.mjs'); const queryPath = join(home, 'query.mjs');
writeFileSync(queryPath, "return 'ownership-unknown';"); writeFileSync(queryPath, "return 'ownership-unknown';");
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], { const result = runCli(['--query', queryPath], { home });
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
assert.equal(result.status, 1, result.stderr || result.stdout); assert.equal(result.status, 1, result.stderr || result.stdout);
assert.match(JSON.parse(result.stdout).error, /no such column: mtime/i); assert.match(JSON.parse(result.stdout).error, /no such column: mtime/i);
} finally { } finally {
+4 -4
View File
@@ -10,18 +10,18 @@ async function readExecutableSchema() {
} }
async function readSchemaReference() { async function readSchemaReference() {
return readFile(new URL('../references/schema.md', import.meta.url), 'utf8'); return readFile(new URL('../skill-doc/references/schema.md', import.meta.url), 'utf8');
} }
async function readApiReference() { async function readApiReference() {
return readFile(new URL('../references/api-reference.md', import.meta.url), 'utf8'); return readFile(new URL('../skill-doc/references/api-reference.md', import.meta.url), 'utf8');
} }
async function readSkill() { async function readSkill() {
return readFile(new URL('../SKILL.md', import.meta.url), 'utf8'); return readFile(new URL('../skill-doc/SKILL.md', import.meta.url), 'utf8');
} }
test('db module loads the executable schema from scripts/schema.sql', async () => { test('db module loads the executable schema from packages/core/src/schema.sql', async () => {
const source = await readFile(new URL('../packages/core/src/db.ts', import.meta.url), 'utf8'); const source = await readFile(new URL('../packages/core/src/db.ts', import.meta.url), 'utf8');
assert.match(source, /schema\.sql/); assert.match(source, /schema\.sql/);
+4 -7
View File
@@ -10,18 +10,15 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync, rmSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { runCli } from './cli-test-helpers.mjs';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
function runRuntime(args, home) { function runRuntime(args, home) {
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], { return runCli(args, { home });
cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8',
});
} }
function line(uuid, type, ts) { function line(uuid, type, ts) {
+1 -3
View File
@@ -23,12 +23,10 @@ test('skill release staging produces the npx skills repository layout', () => {
const target = join(root, 'repo'); const target = join(root, 'repo');
try { try {
mkdirSync(join(artifact, 'references'), { recursive: true }); mkdirSync(join(artifact, 'references'), { recursive: true });
mkdirSync(join(artifact, 'scripts'), { recursive: true });
mkdirSync(join(target, '.git'), { recursive: true }); mkdirSync(join(target, '.git'), { recursive: true });
writeFileSync(join(artifact, 'SKILL.md'), '---\nname: obelisk\ndescription: test\n---\n'); writeFileSync(join(artifact, 'SKILL.md'), '---\nname: obelisk\ndescription: test\n---\n');
writeFileSync(join(artifact, 'package.json'), '{"type":"module"}\n'); writeFileSync(join(artifact, 'package.json'), '{"type":"module"}\n');
writeFileSync(join(artifact, 'references', 'api-reference.md'), '# API\n'); writeFileSync(join(artifact, 'references', 'api-reference.md'), '# API\n');
writeFileSync(join(artifact, 'scripts', 'runtime.js'), 'export {};\n');
writeFileSync(join(target, '.git', 'keep'), 'preserved\n'); writeFileSync(join(target, '.git', 'keep'), 'preserved\n');
writeFileSync(join(target, 'stale.txt'), 'remove me\n'); writeFileSync(join(target, 'stale.txt'), 'remove me\n');
@@ -44,7 +42,6 @@ test('skill release staging produces the npx skills repository layout', () => {
'SKILL.md', 'SKILL.md',
'package.json', 'package.json',
'references/api-reference.md', 'references/api-reference.md',
'scripts/runtime.js',
]) { ]) {
assert.equal(existsSync(join(target, 'skills', 'obelisk', relativePath)), true); assert.equal(existsSync(join(target, 'skills', 'obelisk', relativePath)), true);
} }
@@ -61,4 +58,5 @@ test('CI and local publish use the same skill repository staging step', () => {
assert.match(workflow, /packaging\/stage-skill-repo\.sh/); assert.match(workflow, /packaging\/stage-skill-repo\.sh/);
assert.match(localPublish, /packaging\/stage-skill-repo\.sh/); assert.match(localPublish, /packaging\/stage-skill-repo\.sh/);
assert.doesNotMatch(localPublish, /SKILL_ARTIFACT\/scripts/);
}); });
+33 -33
View File
@@ -15,7 +15,7 @@ const cards = [
]; ];
test('skill routes only the explicit recap intent to the split recap overview', async () => { test('skill routes only the explicit recap intent to the split recap overview', async () => {
const skill = await read('SKILL.md'); const skill = await read('skill-doc/SKILL.md');
assert.match(skill, /## Intent Routing/); assert.match(skill, /## Intent Routing/);
assert.match(skill, /references\/recap\/overview\.md/); assert.match(skill, /references\/recap\/overview\.md/);
@@ -35,8 +35,8 @@ test('README lists the recap folder without making recap the core retrieval path
assert.match(readme, /references\/recap\/overview\.md/); assert.match(readme, /references\/recap\/overview\.md/);
for (const [n, name] of cards) { for (const [n, name] of cards) {
assert.match(readme, new RegExp(`references/recap/pattern${n}-${name}\\.md`)); assert.match(readme, new RegExp(`skill-doc/references/recap/pattern${n}-${name}\\.md`));
assert.match(readme, new RegExp(`references/recap/writing${n}-${name}\\.md`)); assert.match(readme, new RegExp(`skill-doc/references/recap/writing${n}-${name}\\.md`));
} }
assert.match(readme, /optional .*\/obelisk recap/i); assert.match(readme, /optional .*\/obelisk recap/i);
assert.match(readme, /explicit `\/obelisk recap` intent/); assert.match(readme, /explicit `\/obelisk recap` intent/);
@@ -44,8 +44,8 @@ test('README lists the recap folder without making recap the core retrieval path
}); });
test('old recap references are thin redirects to the split docs', async () => { test('old recap references are thin redirects to the split docs', async () => {
const retrieval = await read('references/recap-patterns.md'); const retrieval = await read('skill-doc/references/recap-patterns.md');
const writing = await read('references/recap-writing.md'); const writing = await read('skill-doc/references/recap-writing.md');
assert.match(retrieval, /compatibility/i); assert.match(retrieval, /compatibility/i);
assert.match(retrieval, /references\/recap\/overview\.md/); assert.match(retrieval, /references\/recap\/overview\.md/);
@@ -58,7 +58,7 @@ test('old recap references are thin redirects to the split docs', async () => {
}); });
test('recap overview defines the card-by-card retrieval and writing loop', async () => { test('recap overview defines the card-by-card retrieval and writing loop', async () => {
const ref = await read('references/recap/overview.md'); const ref = await read('skill-doc/references/recap/overview.md');
assert.match(ref, /Highest Priority: Phase Loop/i); assert.match(ref, /Highest Priority: Phase Loop/i);
assert.match(ref, /Spotify Wrapped-like/i); assert.match(ref, /Spotify Wrapped-like/i);
@@ -77,7 +77,7 @@ test('recap overview defines the card-by-card retrieval and writing loop', async
}); });
test('recap overview stays narrow and leaves card details to per-card files', async () => { test('recap overview stays narrow and leaves card details to per-card files', async () => {
const ref = await read('references/recap/overview.md'); const ref = await read('skill-doc/references/recap/overview.md');
assert.ok(ref.split('\n').length < 90); assert.ok(ref.split('\n').length < 90);
assert.match(ref, /The per-card files own retrieval details/i); assert.match(ref, /The per-card files own retrieval details/i);
@@ -102,8 +102,8 @@ test('recap overview stays narrow and leaves card details to per-card files', as
test('each recap card has a separate retrieval pattern and writing reference', async () => { test('each recap card has a separate retrieval pattern and writing reference', async () => {
for (const [n, name] of cards) { for (const [n, name] of cards) {
const pattern = await read(`references/recap/pattern${n}-${name}.md`); const pattern = await read(`skill-doc/references/recap/pattern${n}-${name}.md`);
const writing = await read(`references/recap/writing${n}-${name}.md`); const writing = await read(`skill-doc/references/recap/writing${n}-${name}.md`);
assert.match(pattern, new RegExp(`# Card ${n} .* Retrieval`)); assert.match(pattern, new RegExp(`# Card ${n} .* Retrieval`));
assert.match(pattern, /Read this card's writing file immediately after/i); assert.match(pattern, /Read this card's writing file immediately after/i);
@@ -123,8 +123,8 @@ test('each recap card has a separate retrieval pattern and writing reference', a
}); });
test('cover and closing writing own JSON initialization and final save rules', async () => { test('cover and closing writing own JSON initialization and final save rules', async () => {
const cover = await read('references/recap/writing1-cover.md'); const cover = await read('skill-doc/references/recap/writing1-cover.md');
const closing = await read('references/recap/writing5-closing.md'); const closing = await read('skill-doc/references/recap/writing5-closing.md');
assert.match(cover, /First JSON Write/i); assert.match(cover, /First JSON Write/i);
assert.match(cover, /schema_version: "obelisk\.recap\.v1"/); assert.match(cover, /schema_version: "obelisk\.recap\.v1"/);
@@ -137,8 +137,8 @@ test('cover and closing writing own JSON initialization and final save rules', a
}); });
test('cover card retrieval and writing choose one dominant human claim', async () => { test('cover card retrieval and writing choose one dominant human claim', async () => {
const pattern = await read('references/recap/pattern1-cover.md'); const pattern = await read('skill-doc/references/recap/pattern1-cover.md');
const writing = await read('references/recap/writing1-cover.md'); const writing = await read('skill-doc/references/recap/writing1-cover.md');
assert.match(pattern, /dominant claim/i); assert.match(pattern, /dominant claim/i);
assert.match(pattern, /persona/i); assert.match(pattern, /persona/i);
@@ -153,8 +153,8 @@ test('cover card retrieval and writing choose one dominant human claim', async (
}); });
test('cover card schema uses claim instead of subtitle', async () => { test('cover card schema uses claim instead of subtitle', async () => {
const pattern = await read('references/recap/pattern1-cover.md'); const pattern = await read('skill-doc/references/recap/pattern1-cover.md');
const writing = await read('references/recap/writing1-cover.md'); const writing = await read('skill-doc/references/recap/writing1-cover.md');
const component = await read('app/src/renderer/src/components/recap/CoverCard.vue'); const component = await read('app/src/renderer/src/components/recap/CoverCard.vue');
const detail = await read('app/src/renderer/src/views/RecapDetail.vue'); const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
const list = await read('app/src/renderer/src/views/RecapList.vue'); const list = await read('app/src/renderer/src/views/RecapList.vue');
@@ -176,8 +176,8 @@ test('cover card schema uses claim instead of subtitle', async () => {
}); });
test('thinking card retrieval searches for turns instead of implementation timeline', async () => { test('thinking card retrieval searches for turns instead of implementation timeline', async () => {
const pattern = await read('references/recap/pattern2-thinking.md'); const pattern = await read('skill-doc/references/recap/pattern2-thinking.md');
const writing = await read('references/recap/writing2-thinking.md'); const writing = await read('skill-doc/references/recap/writing2-thinking.md');
assert.match(pattern, /turning points/i); assert.match(pattern, /turning points/i);
assert.match(pattern, /user question/i); assert.match(pattern, /user question/i);
@@ -195,8 +195,8 @@ test('thinking card retrieval searches for turns instead of implementation timel
}); });
test('thinking path schema uses turn instead of outcome', async () => { test('thinking path schema uses turn instead of outcome', async () => {
const pattern = await read('references/recap/pattern2-thinking.md'); const pattern = await read('skill-doc/references/recap/pattern2-thinking.md');
const writing = await read('references/recap/writing2-thinking.md'); const writing = await read('skill-doc/references/recap/writing2-thinking.md');
const component = await read('app/src/renderer/src/components/recap/PathCard.vue'); const component = await read('app/src/renderer/src/components/recap/PathCard.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json'); const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -211,8 +211,8 @@ test('thinking path schema uses turn instead of outcome', async () => {
}); });
test('vibe card retrieval finds small user voice, not a correction audit', async () => { test('vibe card retrieval finds small user voice, not a correction audit', async () => {
const pattern = await read('references/recap/pattern3-vibe.md'); const pattern = await read('skill-doc/references/recap/pattern3-vibe.md');
const writing = await read('references/recap/writing3-vibe.md'); const writing = await read('skill-doc/references/recap/writing3-vibe.md');
assert.match(pattern, /catchphrases/i); assert.match(pattern, /catchphrases/i);
assert.match(pattern, /visible user messages/i); assert.match(pattern, /visible user messages/i);
@@ -229,7 +229,7 @@ test('vibe card retrieval finds small user voice, not a correction audit', async
}); });
test('vibe card schema uses voice_lines instead of observations', async () => { test('vibe card schema uses voice_lines instead of observations', async () => {
const writing = await read('references/recap/writing3-vibe.md'); const writing = await read('skill-doc/references/recap/writing3-vibe.md');
const component = await read('app/src/renderer/src/components/recap/VibeCard.vue'); const component = await read('app/src/renderer/src/components/recap/VibeCard.vue');
const detail = await read('app/src/renderer/src/views/RecapDetail.vue'); const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json'); const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -245,8 +245,8 @@ test('vibe card schema uses voice_lines instead of observations', async () => {
}); });
test('workflow card retrieval scopes real workflow runs and user reactions', async () => { test('workflow card retrieval scopes real workflow runs and user reactions', async () => {
const pattern = await read('references/recap/pattern4-workflow.md'); const pattern = await read('skill-doc/references/recap/pattern4-workflow.md');
const writing = await read('references/recap/writing4-workflow.md'); const writing = await read('skill-doc/references/recap/writing4-workflow.md');
assert.match(pattern, /workflows\.timestamp/); assert.match(pattern, /workflows\.timestamp/);
assert.match(pattern, /workflows\(\{ project: .* after, before/i); assert.match(pattern, /workflows\(\{ project: .* after, before/i);
@@ -271,8 +271,8 @@ test('workflow card retrieval scopes real workflow runs and user reactions', asy
}); });
test('workflow card uses reaction instead of outcome for row copy', async () => { test('workflow card uses reaction instead of outcome for row copy', async () => {
const pattern = await read('references/recap/pattern4-workflow.md'); const pattern = await read('skill-doc/references/recap/pattern4-workflow.md');
const writing = await read('references/recap/writing4-workflow.md'); const writing = await read('skill-doc/references/recap/writing4-workflow.md');
const component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue'); const component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json'); const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -287,7 +287,7 @@ test('workflow card uses reaction instead of outcome for row copy', async () =>
}); });
test('workflow card schema uses deck instead of summary for the visible line', async () => { test('workflow card schema uses deck instead of summary for the visible line', async () => {
const writing = await read('references/recap/writing4-workflow.md'); const writing = await read('skill-doc/references/recap/writing4-workflow.md');
const component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue'); const component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue');
const detail = await read('app/src/renderer/src/views/RecapDetail.vue'); const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json'); const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -303,8 +303,8 @@ test('workflow card schema uses deck instead of summary for the visible line', a
}); });
test('closing card retrieval and writing keep a small personal receipt', async () => { test('closing card retrieval and writing keep a small personal receipt', async () => {
const pattern = await read('references/recap/pattern5-closing.md'); const pattern = await read('skill-doc/references/recap/pattern5-closing.md');
const writing = await read('references/recap/writing5-closing.md'); const writing = await read('skill-doc/references/recap/writing5-closing.md');
assert.match(pattern, /same period and source scope/i); assert.match(pattern, /same period and source scope/i);
assert.match(pattern, /streak/i); assert.match(pattern, /streak/i);
@@ -321,8 +321,8 @@ test('closing card retrieval and writing keep a small personal receipt', async (
}); });
test('closing card schema uses receipts instead of stats', async () => { test('closing card schema uses receipts instead of stats', async () => {
const pattern = await read('references/recap/pattern5-closing.md'); const pattern = await read('skill-doc/references/recap/pattern5-closing.md');
const writing = await read('references/recap/writing5-closing.md'); const writing = await read('skill-doc/references/recap/writing5-closing.md');
const component = await read('app/src/renderer/src/components/recap/ClosingCard.vue'); const component = await read('app/src/renderer/src/components/recap/ClosingCard.vue');
const detail = await read('app/src/renderer/src/views/RecapDetail.vue'); const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json'); const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
@@ -340,9 +340,9 @@ test('closing card schema uses receipts instead of stats', async () => {
}); });
test('split recap writing keeps mixed-language rhythm and plain speech', async () => { test('split recap writing keeps mixed-language rhythm and plain speech', async () => {
const overview = await read('references/recap/overview.md'); const overview = await read('skill-doc/references/recap/overview.md');
const writingDocs = await Promise.all( const writingDocs = await Promise.all(
cards.map(([n, name]) => read(`references/recap/writing${n}-${name}.md`)), cards.map(([n, name]) => read(`skill-doc/references/recap/writing${n}-${name}.md`)),
); );
const combined = [overview, ...writingDocs].join('\n'); const combined = [overview, ...writingDocs].join('\n');
+3 -14
View File
@@ -2,7 +2,7 @@
// //
// These lock the four-verb CLI I/O envelope at the process boundary so the // These lock the four-verb CLI I/O envelope at the process boundary so the
// upcoming TypeScript / runtime-core refactor cannot silently change what an // upcoming TypeScript / runtime-core refactor cannot silently change what an
// agent (or the skill/CLI/MCP transports) observes on stdout: // agent (through the CLI or a future MCP transport) observes on stdout:
// --build -> { ok: true, db } // --build -> { ok: true, db }
// --search -> JSON array // --search -> JSON array
// --query -> pretty-printed JSON result, or { error, stack } + exit 1 on throw // --query -> pretty-printed JSON result, or { error, stack } + exit 1 on throw
@@ -16,19 +16,9 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); import { runCli as runRuntime } from './cli-test-helpers.mjs';
function runRuntime(args, { home }) {
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
}
function tempHome() { function tempHome() {
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-envelope-')); const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-envelope-'));
@@ -118,4 +108,3 @@ test('--search tolerates FTS-special input via safe tokenization', () => {
assert.equal(result.status, 0, result.stderr || result.stdout); assert.equal(result.status, 0, result.stderr || result.stdout);
assert.ok(Array.isArray(JSON.parse(result.stdout)), 'search must return a JSON array'); assert.ok(Array.isArray(JSON.parse(result.stdout)), 'search must return a JSON array');
}); });
+3 -12
View File
@@ -3,22 +3,13 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { runCli as runRuntime } from './cli-test-helpers.mjs';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
function runRuntime(args, { home }) {
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
cwd: repoRoot,
env: { ...process.env, HOME: home },
encoding: 'utf8',
});
}
function tempHome() { function tempHome() {
const home = mkdtempSync(join(tmpdir(), 'obelisk-runtime-home-')); const home = mkdtempSync(join(tmpdir(), 'obelisk-runtime-home-'));
mkdirSync(join(home, '.claude'), { recursive: true }); mkdirSync(join(home, '.claude'), { recursive: true });
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const artifact = join(repoRoot, 'dist', 'obelisk-skill');
test('build:skill produces a docs-only skill that delegates execution to the CLI', () => {
execFileSync(npmCommand, ['run', 'build:skill'], {
cwd: repoRoot,
encoding: 'utf8',
stdio: 'pipe',
});
assert.equal(existsSync(join(artifact, 'SKILL.md')), true);
assert.equal(existsSync(join(artifact, 'references', 'api-reference.md')), true);
assert.equal(existsSync(join(artifact, 'package.json')), true);
assert.equal(existsSync(join(artifact, 'scripts')), false, 'skill must not ship a second runtime');
const skill = readFileSync(join(artifact, 'SKILL.md'), 'utf8');
const schema = readFileSync(join(artifact, 'references', 'schema.md'), 'utf8');
assert.match(skill, /Bash\(obelisk:\*\)/);
assert.match(skill, /obelisk --query \/tmp\/q\.mjs/);
assert.match(skill, /obelisk --attune \/tmp\/register-memory\.mjs/);
assert.doesNotMatch(skill, /\$SKILL_DIR\/scripts\/runtime\.js/);
assert.doesNotMatch(`${skill}\n${schema}`, /scripts\//);
});
+1 -1
View File
@@ -16,7 +16,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true
}, },
"include": ["packages/core/src/**/*", "tests/**/*"], "include": ["packages/core/src/**/*", "packages/cli/src/**/*", "tests/**/*"],
"exclude": [ "exclude": [
"node_modules", "node_modules",
"app", "app",
-11
View File
@@ -1,11 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist/obelisk-skill/scripts",
"rootDir": "packages/core/src",
"declaration": false,
"rewriteRelativeImportExtensions": true
},
"include": ["packages/core/src/**/*.ts"]
}