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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "projectstore",
"displayName": "projectstore",
"description": "📚 Your project's knowledge base, written by your AI agent — ADRs · epics · stories · runbooks · research. An Obsidian-friendly markdown vault, agent-maintained, you approve every write. Like Karpathy's LLM Wiki, but for engineering project artifacts.",
"version": "0.9.0",
"version": "0.10.0",
"author": {
"name": "Evgenii Konev",
"email": "ekonev@smartandpoint.com",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "projectstore",
"displayName": "projectstore",
"version": "0.9.0",
"version": "0.10.0",
"description": "Opinionated project-management paradigms (ADR / epics / stories / kanban / runbooks) for agentic development. Markdown-first, git-portable, human-readable.",
"author": {
"name": "Evgenii Konev @ SmartAndPoint",
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,15 @@ PreCompact [...pre-compact.mjs] completed successfully: {

The packet contains the vault path, the command list, the last 15 vault touches, and the newest in-flight ADR / epic / story / research. The post-compact agent picks up drafting from where the previous one left off, no manual rehydration.

## What's in the box (v0.9)
## What's in the box (v0.10)

- **13 commands** — `bind`, `scaffold`, `status`, `adr`, `epic`, `story`, `kanban`, `research`, `concept`, `meeting`, `runbook`, `search`, `review`
- **14 commands** — `bind`, `scaffold`, `status`, `adr`, `epic`, `story`, `kanban`, `research`, `concept`, `meeting`, `runbook`, `search`, `review`, `statusline`
- **3 passive skills** — `decision-detector`, `story-completion`, `peer-reviewer`. They suggest commands; they never write directly.
- **3 bundled agents** — `projectstore-critic`, `code-planner`, `code-reviewer`. Opus, max-effort, read-only, independent fresh-context passes. See [Bundled agents](#bundled-agents).
- **1 layout** — `engineering` (`adr/`, `epics/<id>/stories/`, `research/`, `concepts/`, `meetings/`, `ops/`, `diagrams/`)
- **9 templates** — opinionated markdown with frontmatter (English + Russian variants)
- **3 hooks** — `SessionStart` (vault map + multi-session warning), `PreToolUse` (per-session activity log), `PreCompact` (survival packet)
- **1 status line** — opt-in HUD line with the current epic & story, composed above an existing status line (e.g. oh-my-claudecode). See [Status line](#status-line).

## Philosophy

Expand Down Expand Up @@ -201,11 +202,28 @@ For high-stakes artifacts (ADR / research / epic), `/projectstore:review <path>`

`/projectstore:review` uses `projectstore-critic` as its default critic. Because plugin agents resolve at the **lowest** priority, a same-named agent in `.claude/agents/` (project) or `~/.claude/agents/` (user) transparently overrides the bundled one — so your own tuned version always wins where you have one.

## Status line

See the epic and story the agent is working on this session, right in Claude Code's status line — composed **above** your existing HUD, not replacing it:

![projectstore status line: the 📚 epic › story line sitting above an existing oh-my-claudecode HUD](docs/images/statusline-hud.png)

**It does not overwrite your current status-line settings.** `statusLine` is a single slot and not plugin-declarable, so projectstore ships `scripts/statusline.mjs` plus a `/projectstore:statusline on|off` command that wires it into the project's `.claude/settings.local.json` (local scope, this-project-only, reverts cleanly; conventionally git-ignored). Rather than clobber your HUD, the script **composes**: it re-runs whatever base `statusLine` command you already have — for example [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode)'s HUD, read from `~/.claude/settings.json` — prints its output verbatim, and adds the `📚 <epic> › <story> (<status>)` line above it (position via `projectstore.json` → `statusline.position`, default `above`). With no base command it renders a standalone line: `<model> · <dir> · ⎇ <branch> · 📚 …`.

So in a projectstore project you keep your full [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode) context / rate-limit / session HUD **and** gain the current epic & story on top. The epic/story is derived from this session's `recent_activity` log — it appears once you touch an epic or story, and stays transparent (base HUD only) otherwise.

```
/projectstore:statusline on # wire it into this project (project-local)
/projectstore:statusline off # remove it
/projectstore:statusline status # current state + the base HUD it composes over
```

## Roadmap

| Version | What ships | Status |
|---|---|---|
| **v0.9** | Bundled review agents (`projectstore-critic`, `code-planner`, `code-reviewer`) | ✅ current |
| **v0.10** | Status line — current epic & story in the HUD, composes with an existing status line | ✅ current |
| v0.9 | Bundled review agents (`projectstore-critic`, `code-planner`, `code-reviewer`) | ✅ |
| v0.8 | Russian (`ru`) templates | ✅ |
| v0.7 | First-run welcome (SessionStart one-shot), auto-update follow-up in `/projectstore:bind` | ✅ |
| v0.6 | Session isolation (Claude `session_id`), safer rebind, PreCompact `systemMessage` | ✅ |
Expand Down
55 changes: 55 additions & 0 deletions commands/statusline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
description: Enable/disable the projectstore status line (current epic & story in the HUD). Composes above an existing status line (e.g. oh-my-claudecode) instead of replacing it.
argument-hint: "on | off | status"
---

You are configuring the projectstore status line for THIS project. It shows the epic & story the agent is touching this session, composed **above** any existing status line: `scripts/statusline.mjs` delegates to the base `statusLine` command in your `settings.json`, prints its output verbatim, and adds one `📚 <epic> › <story>` line.

`statusLine` is **not** plugin-declarable, so this writes a `statusLine` entry into the **project's `.claude/settings.local.json`** (local scope, highest precedence, conventionally git-ignored — it overrides a global HUD ONLY in this project, and reverts cleanly). If the project tracks `.claude/settings.local.json` in git, tell the user (the baked absolute path is machine-specific and shouldn't be committed).

## 1. Parse `$ARGUMENTS`

- `on` (or empty) — install / refresh the status line.
- `off` — remove it.
- `status` — report current state; modify nothing.

## 2. Resolve paths

```bash
echo "SCRIPT=$CLAUDE_PLUGIN_ROOT/scripts/statusline.mjs"
echo "SETTINGS=${CLAUDE_PROJECT_DIR:-$PWD}/.claude/settings.local.json"
```

The `statusLine.command` MUST be a baked absolute path — `${CLAUDE_PLUGIN_ROOT}` is not expanded in `statusLine.command` (unlike in hooks). Build the command as `node "<SCRIPT>"` and **keep the inner quotes** — the plugin path may contain spaces, and `statusLine.command` runs in a shell.

## 3. `on`

1. Read `SETTINGS` if it exists and `JSON.parse` it; keep **all** existing keys. If absent, start from `{}`.
2. Inspect the existing `statusLine`:
- **Absent** → proceed.
- **Already ours** (`command` contains `scripts/statusline.mjs`) → tell the user it's already on; offer `off`. Stop.
- **A different local status line** → warn: the wrapper composes over a base command found in `~/.claude/settings.json` or `<project>/.claude/settings.json`, but NOT one already sitting in `settings.local.json`; replacing it here drops that local line. Ask via AskUserQuestion: **Replace / Cancel**.
3. Set (preserving other keys) — note the escaped inner quotes around the path:
```json
"statusLine": { "type": "command", "command": "node \"<SCRIPT>\"" }
```
4. **Preview** the full resulting file + path. **AskUserQuestion**: Yes / No.
5. On **Yes** → create `.claude/` if needed and Write the file.
6. Report: "Status line wired. `📚 <epic> › <story>` will appear above your existing HUD once this session touches an epic/story. statusLine loads at session start — if it doesn't show, restart Claude Code in this project."

## 4. `off`

1. Read `SETTINGS`. No `statusLine` → say "already off"; stop.
2. If `statusLine` is **not** ours → AskUserQuestion before removing (**Remove / Cancel**).
3. Remove only the `statusLine` key; keep the rest. Preview + AskUserQuestion, then Write. (If the object becomes `{}`, leaving `{}` is fine.)
4. Confirm removed; note a restart may be needed.

## 5. `status`

Read `SETTINGS` and report: is `statusLine` present, and is it ours (`command` contains `scripts/statusline.mjs`)? Then read `~/.claude/settings.json` → `statusLine.command` and report the base HUD it will compose over (or "none — standalone line").

## Notes

- Every settings write goes through AskUserQuestion. Never write without approval.
- The status line works without a bound vault (it shows the base line / passes the base HUD through); the `📚 epic › story` segment appears only in a projectstore-bound project after the session touches an epic/story.
- Position of our line (`above` / `below` the base HUD) is read from `.claude/projectstore.json` → `statusline.position` (default `above`).
Binary file added docs/images/statusline-hud.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
265 changes: 265 additions & 0 deletions scripts/statusline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
#!/usr/bin/env node
// projectstore — statusline.mjs
//
// Renders the Claude Code status line for a projectstore-bound project.
// Wired manually (or via /projectstore:statusline) into a project's
// .claude/settings.local.json — statusLine is NOT a plugin-declarable
// capability, so it lives in settings.json with an absolute command path.
//
// COMPOSING, not clobbering: the statusLine slot is single. Rather than
// replace an existing status line (e.g. oh-my-claudecode's HUD), this script
// DELEGATES to the base statusLine command already configured in
// ~/.claude/settings.json (or the project's .claude/settings.json), prints
// its output verbatim, and adds ONE projectstore line — "📚 epic › story
// (status)" — above it (position configurable via projectstore.json:
// statusline.position = "above" | "below", default "above"). When no base
// command exists (e.g. a user without a custom HUD), it renders a standalone
// base line instead: <model> · <dir> · ⎇ <branch> · 📚 epic › story.
//
// Consumes hook-style JSON on stdin (fields used):
// session_id — Claude's session id (matches the session file)
// workspace.project_dir — the project root (statusLine gets NO
// CLAUDE_PROJECT_DIR, so we must feed it ourselves)
// cwd — fallback project dir
// model.display_name — e.g. "Opus" (standalone mode only)
//
// Everything is best-effort. On any missing piece we drop that segment; on
// any error we emit whatever we have and exit 0. A status line script must
// never crash or block the UI. The base command is re-run with the same
// stdin; if it fails or is absent we fall back to the standalone line.

import { readFileSync, statSync } from "node:fs";
import { join, basename, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { readConfig, readSessionActivity, parseFrontmatter } from "./lib.mjs";

const SELF = fileURLToPath(import.meta.url);
const SEP = " · ";
const BRANCH = "⎇ ";
const BOOK = "📚 ";
const ARROW = " › ";

// Claude Code cancels an in-flight statusLine by closing our stdout pipe when
// a newer update arrives. A write to a closed pipe emits an async 'error'
// event that the outer try/catch (sync-only) cannot catch → Node would exit 1
// with a stack trace. Guard the pipe so we honour the never-crash contract.
process.stdout.on("error", () => process.exit(0));

function escRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function readRawStdin() {
try {
return readFileSync(0, "utf8");
} catch {
return "";
}
}

// Current git branch by reading .git/HEAD directly — no process spawn.
// Handles worktree/submodule .git-file pointers and detached HEAD.
function gitBranch(projectDir) {
try {
const dotgit = join(projectDir, ".git");
let gitDir;
if (statSync(dotgit).isDirectory()) {
gitDir = dotgit;
} else {
const m = readFileSync(dotgit, "utf8").trim().match(/^gitdir:\s*(.+)$/);
if (!m) return null;
gitDir = resolve(projectDir, m[1].trim());
}
const head = readFileSync(join(gitDir, "HEAD"), "utf8").trim();
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
if (ref) return ref[1];
if (/^[0-9a-f]{7,40}$/i.test(head)) return head.slice(0, 7); // detached
return null;
} catch {
return null;
}
}

function relToVault(abs, vault) {
if (typeof abs !== "string") return null;
if (abs.startsWith(vault + "/")) return abs.slice(vault.length + 1);
return null;
}

// The "📚 epic › story (status)" segment from this session's recent activity.
// Newest-first: first entry under epics/<id>/ is the current epic; the most
// recent story file under that epic is the current story. At most one file
// read (the story, for its status). Returns null on any miss.
function bookSegment(cfg, input) {
if (!cfg || !cfg.vault_path) return null;
const sid = input.session_id;
if (!sid) return null;

const vault = cfg.vault_path;
const activity = readSessionActivity(vault, sid);
if (!activity.length) return null;

let epicId = null;
for (const e of activity) {
const rel = relToVault(e && e.path, vault);
if (rel) {
const m = rel.match(/^epics\/([^/]+)\//);
if (m) {
epicId = m[1];
break;
}
}
}
if (!epicId) return null;

let storyFile = null;
let storyPath = null;
const storyRe = new RegExp(`^epics/${escRe(epicId)}/stories/([^/]+\\.md)$`);
for (const e of activity) {
const rel = relToVault(e && e.path, vault);
if (rel) {
const m = rel.match(storyRe);
if (m) {
storyFile = m[1];
storyPath = e.path;
break;
}
}
}

let seg = BOOK + epicId;
if (storyFile) {
const label = storyFile.replace(/\.md$/, "");
let status = null;
try {
const { data } = parseFrontmatter(readFileSync(storyPath, "utf8"));
if (data.status) status = String(data.status).toLowerCase();
} catch {}
seg += ARROW + label + (status ? ` (${status})` : "");
}
return seg;
}

function safeBook(cfg, input) {
try {
return bookSegment(cfg, input);
} catch {
return null;
}
}

// The base statusLine command we compose with — the highest-precedence one
// BELOW our own local entry: project .claude/settings.json, else user
// ~/.claude/settings.json. Skips a command that points back at us (recursion).
function discoverBaseCommand(projectDir) {
const candidates = [
join(projectDir, ".claude", "settings.json"),
join(homedir(), ".claude", "settings.json"),
];
for (const p of candidates) {
try {
const cmd = JSON.parse(readFileSync(p, "utf8"))?.statusLine?.command;
if (
typeof cmd === "string" &&
cmd.trim() &&
!cmd.includes(SELF) &&
!cmd.includes("statusline.mjs")
) {
return cmd;
}
} catch {}
}
return null;
}

function runBase(cmd, rawStdin, env) {
try {
const r = spawnSync(cmd, {
shell: true,
input: rawStdin,
encoding: "utf8",
timeout: 2000,
maxBuffer: 1 << 20,
env,
});
if (r.status !== 0) return null;
const out = (r.stdout || "").replace(/\s+$/, "");
return out || null;
} catch {
return null;
}
}

function standaloneLine(input, projectDir, book) {
const parts = [];
const model = input.model && input.model.display_name;
if (model) parts.push(model);
if (projectDir) parts.push(basename(projectDir));
const branch = gitBranch(projectDir);
if (branch) parts.push(BRANCH + branch);
if (book) parts.push(book);
return parts.join(SEP);
}

function main() {
const raw = readRawStdin();
let input = {};
try {
input = JSON.parse(raw) || {};
} catch {
input = {};
}

// statusLine is spawned with no CLAUDE_PROJECT_DIR and an unspecified cwd,
// so feed the project dir from stdin BEFORE readConfig() (which resolves
// via CLAUDE_PROJECT_DIR || cwd). Mutates only this short-lived process.
const projectDir =
(input.workspace && input.workspace.project_dir) || input.cwd || process.cwd();
// Snapshot CLAUDE_PROJECT_DIR before we inject it, so the composed base
// command can be given the env it would have as the top-level statusLine
// (which is NOT handed this var).
const hadCPD = "CLAUDE_PROJECT_DIR" in process.env;
const prevCPD = process.env.CLAUDE_PROJECT_DIR;
if (projectDir) process.env.CLAUDE_PROJECT_DIR = projectDir;

let cfg = null;
try {
cfg = readConfig();
} catch {
cfg = null;
}

const book = safeBook(cfg, input);
const baseCmd = discoverBaseCommand(projectDir);
// The base HUD is re-executed on EVERY render (300ms debounce); a heavy base
// may want its own caching / refreshInterval. Hand it the same env a real
// top-level statusLine would have (no injected CLAUDE_PROJECT_DIR).
const baseEnv = { ...process.env };
if (hadCPD) baseEnv.CLAUDE_PROJECT_DIR = prevCPD;
else delete baseEnv.CLAUDE_PROJECT_DIR;
const baseOut = baseCmd ? runBase(baseCmd, raw, baseEnv) : null;

const lines = [];
if (baseOut) {
// Compose: keep the base HUD intact, add only our 📚 line.
const position =
(cfg && cfg.statusline && cfg.statusline.position) || "above";
if (book && position === "above") lines.push(book);
lines.push(baseOut);
if (book && position !== "above") lines.push(book);
} else {
// No base HUD — render our own standalone line.
lines.push(standaloneLine(input, projectDir, book));
}

process.stdout.write(lines.join("\n") + "\n");
}

try {
main();
} catch {
// A status line must never crash. Emit nothing rather than an error.
process.stdout.write("\n");
}