npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hasna/projects

v1.3.1

Published

High-level project management CLI, MCP server, SDK, and AI SDK agent loop for coding agents

Readme

@hasna/projects

High-level project management CLI, MCP server, and SDK for AI coding agents. A project can be any repository, app, docs folder, scaffold, experiment, or remote-intended project in any folder. Projects tracks roots, recipes, agents, tmux profiles, immutable project events, and prompt-driven AI agent runs.

Project Resources

Install

bun install -g @hasna/projects

CLI

The app and binary are named projects, and the public command surface is project-first.

# Prompt mode through AI SDK + OpenRouter
projects --dry-run --json "Plan a new open source project named Log Tools in /tmp/log-tools with tmux"
projects --yes "Create a docs project in /home/me/docs/new-docs and write a marker"
projects --model openai/gpt-4o-mini --max-steps 8 "Import this folder as a project"

# Roots
projects roots add --name "Open Source" --path /home/me/opensource --kind open-source --path-template "{slug}"
projects roots list --json
projects roots match --path /home/me/opensource/my-app --json
projects roots update open-source --github-org hasna --visibility public

# Recipes
projects recipes add --name "TypeScript Library" --kind open-source --tags typescript,library
projects recipes list
projects recipes built-ins
projects recipes seed-defaults --json

# Agents
projects agents add --name "Codex" --kind ai --provider openrouter --model openai/gpt-4o-mini
projects agents list

# Tmux profiles
projects tmux-profiles add --name "Dev" --slug dev --session-template "{slug}-dev" \
  --windows-json '[{"name":"editor"},{"name":"server","command":"bun run dev"}]'
projects tmux-profiles apply dev my-project --dry-run --json

# Projects
projects create --name "My App" --path /path/to/my-app --stage active --priority high \
  --owner hasna --launch-profile dev --start-agent claude --start-command "claude --resume" \
  --start-session-policy error-if-running \
  --start-windows-json '[{"name":"server","command":"bun run dev"}]' \
  --todos-project-id todo_123 --brief-id brief_123 --mkdir --git-init --marker --json
projects create --name "Planned App" --path /tmp/planned --mkdir --dry-run --json
projects create --name "Store Work" --kind project --mkdir --marker --json  # defaults to $HASNA_PROJECTS_HOME/workspaces/<id>
projects start                              # from inside a registered repo
projects start notes                  # by slug/id/name/path
projects start --json                      # operational structured output
projects start --render-spec               # validated JSON Render spec
projects start my-app --agent sumi
projects start /path/to/existing --agent claude
projects start my-app --windows-json '[{"name":"editor","command":"code ."},{"name":"server","command":"bun run dev"}]'
projects start /path/to/new-folder --tags family,security --metadata-json '{"domain":"home-security"}' --dry-run --json
projects start my-app --profile dev --agent claude --new
projects start my-app --error-if-running --agent none
projects start --rename-report --agent sumi
projects sessions my-app --unrenamed --json
projects start --bulk my-app docs-site service-api --agent opencode --dry-run --json
projects start --bulk-file ./project-targets.json --agent claude --dry-run --json
projects start --label kind:work-project --dry-run --json
projects status my-app --profile dev --json
projects store inspect my-app --json
projects canvases list my-app --ensure-default --render-spec
projects canvases create my-app --name "Research Board" --nodes-json '[{"id":"note","position":{"x":0,"y":0},"data":{"title":"Note"}}]'
projects canvases upsert my-app --slug planning-board --nodes-json '[{"id":"summary","type":"projectPanel","position":{"x":0,"y":0},"data":{"title":"Summary"}}]'
projects canvases compose my-app --spec-json '{"slug":"directory","blocks":[{"id":"summary","title":"Summary"},{"id":"table","title":"Table","columns":["name","role"],"rows":[{"name":"Ada","role":"Lead"}]}],"links":[{"source":"summary","target":"table"}]}'
projects dashboard snapshot my-app --write --json
projects dashboard render my-app --json
projects dashboard validate my-app --json
PROJECTS_DASHBOARD_TOKEN="<token>" projects dashboard serve my-app --host 0.0.0.0 --port 3344
projects reports serve --port 3345
PROJECTS_REPORTS_TOKEN="<token>" projects reports serve --host 0.0.0.0 --port 3345
projects loops link my-app daily-check --name "Daily Check" --role maintenance
projects loops list my-app --json
projects import /path/to/existing --json
projects import-github hasna/example --root open-source --clone --dry-run --json
projects scan-roots --root open-source --repo-prefix project- --clone --json
projects sync-roots --root open-source --repo-prefix project- --tags open-source,project --json
projects import /path/to/root --bulk --dry-run --json
projects list --query app --tags web,ts
projects list --label org:hasnaxyz --json
projects list --limit 50 --verbose
projects list --query app --tags web,ts --json
projects list --query app --json --detail compact
projects list --query app --json --detail compact --fields slug,status,path
projects show my-app --json
projects show my-app --verbose
projects events list my-app --limit 10 --verbose
projects get my-app --json
projects update my-app --description "New description" --tags web,ts --priority critical --launch-profile dev
projects tag my-app security cameras
projects untag my-app cameras
projects labels add my-app org:hasnaxyz kind:work-project client:foo
projects labels list my-app
projects labels remove my-app client:foo
projects oss matrix --root /home/me/opensource --prefix open- --json
projects oss matrix --root /home/me/opensource --limit 50 --no-prs --no-tasks
projects store inspect my-app --json
projects store ensure my-app --json
projects store ensure wks_exact_stable_id --dry-run --json  # required target form on the hosted backend
projects store migrate my-app --json          # dry-run plan
projects store migrate my-app --apply --json  # explicit move/update
projects link my-app --github-url https://github.com/hasna/my-app --todos-project-id todo_123 --todos-task-list-id list_123
projects unlink my-app --todos --brief
projects locations add my-app /path/to/another-folder --label docs
projects locations list my-app
projects archive my-app
projects unarchive my-app
projects delete my-app

# Budgets
projects budgets set --project my-app --max-usd 5 --max-total-tokens 100000 --json
projects budgets remaining --project my-app --json
projects --budget-project my-app --run-budget-tokens 2000 --json "Plan the next release"

# Project checks
projects doctor my-app --fix --dry-run --json

# Agent evals
projects agent-eval --json
projects agent-eval --mock --json
projects agent-eval --case create-explicit-path,tmux-apply-existing --fail-on-error

# Agent assist — help coding agents orient, decide, and continue
projects context my-app --for-agent        # one-shot priming bundle
projects next my-app --for-agent           # suggested next actions
projects why my-app --for-agent            # resolution trace + fix tips
projects handoff my-app --for-agent        # cross-agent/machine handoff bundle
projects runs list my-app --for-agent      # prompt-agent run ledger
projects runs show <run-id> my-app         # full run detail + tool-call trace
# All six also emit JSON with -j/--json and are available as MCP tools:
#   projects_context, projects_next, projects_why, projects_handoff,
#   projects_runs_list, projects_runs_show

# Conversations channel linkage — every project has one channel
projects channel my-app                    # print the project's channel name
projects channel my-app --json             # full resolution (class, linked, source)
projects channel my-app --ensure           # create the channel if it does not exist
# The channel name lives on the project record as
# integrations.conversations_channel; when unset it IS the project slug,
# normalized. This CLI adds no prefix of its own and strips none — it does not
# own the fleet naming convention and will not carry a copy of it. (That is a
# statement about authority, not conformance: plenty of registry slugs do not
# match the convention. Repairing those is a project-rename decision, not a
# derivation one.) A project whose channel is not named after its slug says so
# by setting integrations.conversations_channel, which always wins.
#
# Ensure creates the channel but never writes the link back: pinning a derived
# name would outrank derivation forever and survive a revert. The link is set at
# project creation, or deliberately by an operator.
#
# A pinned channel must be a channel: `create`/`link --conversations-channel`,
# `update --integrations-json`, the MCP projects_create / projects_update /
# projects_link tools, the prompt-agent's own projects_update / projects_create
# / projects_link tools (projects_agent_prompt), the project step of the
# prefix migration, and a typed conversations channel resource link (whose
# labels.channel_name is projected onto integrations.conversations_channel —
# a stale label after a rename is refused, in both the local and the hosted
# store) refuse a name the conversations app has no channel for (BUG-0063). A
# name that only resolves as
# an agent DM silently sent project posts to the DM lane, or failed closed with
# HTTP 400. The check runs only when the write actually sets or changes the
# channel, only where the conversations CLI is reachable, and never on an
# unreadable channel listing (skip with HASNA_PROJECTS_CHANNEL_VERIFY=0).
# `projects doctor` reports an already-pinned bad channel as
# WORKSPACE_CHANNEL_MISSING. The hosted store applies the same rule at its own
# writes — POST /v1/workspaces, PATCH/PUT /v1/workspaces/{id}, POST
# /v1/workspaces/{id}/guarded-metadata and the resource-link projection — so a
# direct HTTP/SDK caller that never passes a client-level check is refused too
# (BUG-0076); the rule is db-free and its probe is the same one-shot CLI call,
# so a server box without the conversations CLI still cannot invent a refusal.
# Still not covered by design: a channel DERIVED at create rather than pinned
# by a caller (the local path ensures it through `projects channel --ensure`
# after the write; the hosted path has no ensure).
#
# The channel class comes from the project record too:
# integrations.conversations_channel_class if set, else the project kind, else
# unset — in which case no --class is sent and conversations picks the default.
# Most projects (kind `generic`) fall in that last bucket.
# `projects create` and `projects start` ensure the channel automatically
# (disable with PROJECTS_CHANNEL_ENSURE=0); MCP tool: projects_channel
# A newly created coding-agent session also posts an online notice to this
# channel so subscribed agents see it in chat. This is enabled by default;
# disable it with PROJECTS_AGENT_ONLINE_NOTIFICATIONS=0. Reusing an existing
# session does not post another online notice.

# Shell completion, including workon
eval "$(projects completion)"
eval "$(projects completion --shell zsh)"

Terminal UI (projects ui)

A full-screen Solid/OpenTUI project manager: browse and search every project, open a project detail view with its events, locations, and agent assignments, and manage roots, recipes, and agents — with create, edit, archive, unarchive, and delete dialogs for projects. Registry state is the same store seam the CLI uses, so the TUI shows the exact same projects as projects list.

projects ui
projects ui --project <id-or-slug>   # open straight into a project's detail view
bun run dev:ui                       # run from the source tree without a build

The TUI needs a real terminal: it refuses to start when stdin or stdout is not a TTY (non-interactive runs exit non-zero — use projects list, projects show, or projects create instead). projects ui opens the alternate screen and restores your terminal on exit.

Navigation uses the standard OpenTUI controls, and most actions are also available as visible buttons or through the shortcuts palette (Ctrl+P):

| Key | Action | | --- | --- | | Ctrl+P | Open the Keyboard Shortcuts / command palette | | Ctrl+R | Refresh the project list | | Esc | Back (project detail → list); exit from the list view | | Ctrl+C | Close a dialog; exit when nothing is open | | ↑/↓ | Move the selection | | Tab/Enter | Focus / activate the selected control |

The data source is exactly the CLI's: a credential from any tier of the @hasna/contracts resolver (Keychain, ~/.hasna/projects/config/credentials, or HASNA_PROJECTS_API_KEY) serves the hosted registry, and the explicit HASNA_PROJECTS_LOCAL=1 opt-in serves the on-box SQLite registry — see Credentials and Connection below. A station with neither fails closed, just like the CLI.

Theme follows auto/light/dark: force it with PROJECTS_TUI_THEME, or let it detect your terminal (COLORFGBG, macOS appearance). See docs/tui.md for the full design, keymap reference, and theming.

Screenshot: projects ui in a dark terminal, showing the sidebar and the project list with status badges.

Goal-continue Cursor hook (ralph-style)

A stop hook lives in .cursor/hooks.json + .cursor/hooks/goal-continue.sh. When an agent stops, it checks for an active goal and, if incomplete, blocks the stop with a continuation prompt that includes projects next suggestions — modeled on the codewith /goal slash command but driven as a Cursor hook.

Goal sources (first wins): ./.hasna/goal.md, $HASNA_GOAL_FILE, ~/.hasna/goal.md. Mark a goal complete by adding a <!-- done --> line.

Env knobs: HASNA_GOAL_SKIP=1 (disable), HASNA_GOAL_CONTINUE=0 (status only, no continuation), HASNA_GOAL_MAX_SUGGESTIONS=N. The hook never fails closed.

Projects stores high-level management fields directly on the project record: stage, priority, owner, launch_profile, start_agent, start_command, start_session_policy, start_windows, todos links, and brief links. projects start and projects status use those launch defaults unless the command passes an explicit override. By default, projects start detects the current repo/project, creates or reuses the project tmux session, and ensures base windows named 01 and 02. Window 01 is the managed coding-agent/work window when a start command is launched; 02 is the secondary workspace. Existing unrelated tmux windows are left alone. Pass --windows-json or the MCP/API windows field to request the exact tmux window names for a single start/status operation.

Machine-readable outputs for projects list, projects show/projects get, projects status, projects start, projects sessions, projects roots list, and projects recipes list can emit validated JSON Render specs with --render-spec. Existing --json payloads preserve their operational fields and render specs are available from --render-spec and matching projects_render_* MCP tools. Claude starts are annotated with --name when safe. Codewith, Cursor, and OpenCode rename support is reported as manual or unsupported unless a stable programmatic rename path is available; Hasna Projects does not force text into unknown panes. Use projects start --rename-report or projects sessions <project> --unrenamed to inspect rename status. Detailed execution still belongs in todos; long-form specs and decisions still belong in brief.

Workspace Store

Projects has one canonical physical workspace store under HASNA_PROJECTS_HOME, defaulting to ~/.hasna/projects (resolved by the in-package resolver to the XDG data home ~/.local/share/hasna/projects once the store has been migrated there or HASNA_DATA_HOME is set):

  • canonical workspace path: $HASNA_PROJECTS_HOME/workspaces/<workspace_id>/
  • runtime data path: $HASNA_PROJECTS_HOME/data/<workspace_id>/
  • common runtime children: project.db, logs/, artifacts/, and context/

Rootless new projects default to the canonical workspace path. Explicit --path, registered roots, imports, and GitHub checkout roots keep their requested paths for compatibility until a user runs an explicit store migration. Slugs, names, and org labels are mutable metadata and never define the canonical folder name.

projects store inspect reports the canonical workspace/data paths and whether the current primary path is canonical. projects store ensure creates missing workspace/data directories, initializes the machine-local project.db (local mode only), and only sets the canonical path as primary when the project had no primary path. On the hosted backend, ensure requires the complete stable wks_... id, reads the full project through the producer-bounded guarded endpoint, takes its mutation lock through /v1/locks, and uses the guarded conditional update/receipt path for a missing primary path. It provisions the FOLDER layout only: the per-project project.db is on-box SQLite, which a hosted run never opens or creates (owner ruling 2026-09-07, hasna/apps#1720), so the result carries app_store: null; store inspect likewise reports app_store: null plus app_store_unavailable naming the opt-in. Slugs and partial ids are refused before transport; the station-local store is never created from an unbounded or mismatched registry response. projects store migrate remains local-only and is dry-run by default; it requires --apply or --yes to move an existing primary folder into workspaces/<id>, writes a migration plan under data/<id>, preserves git history by moving the directory, records the old path as a non-primary location, updates workspaces.primary_path through the normal location API, rewrites the marker, and verifies the canonical primary path exists.

Labels are project metadata/query filters stored in the existing normalized tag list. Use labels such as org:hasnaxyz, kind:work-project, and client:foo; they do not create canonical folders and are safe to add, remove, or rename.

Per-project app data lives in the canonical runtime data path at $HASNA_PROJECTS_HOME/data/<workspace_id>/project.db. That project database stores project-specific canvases, custom JSON data models/records, and OpenLoops links. projects canvases * --render-spec emits a JSON Render contract for a TypeScript React surface using Tailwind, shadcn components, and React Flow as an infinite canvas; a project may have multiple canvases. Use projects canvases upsert <project> --slug <slug> when an agent or script needs idempotent create-or-update behavior for raw React Flow nodes/edges. Use projects canvases compose <project> for higher-level generic block specs: blocks become ProjectCanvasCard nodes, links become React Flow edges, and table-like blocks can carry columns/rows without creating a one-off domain-specific command.

Generic block specs are intentionally domain-neutral. The same shape can model a reporting hierarchy, a project map, a directory table, a roadmap, or a handoff board:

{
  "slug": "directory",
  "name": "Directory",
  "layout": { "direction": "grid", "columns": 2 },
  "blocks": [
    {
      "id": "summary",
      "title": "Summary",
      "kind": "summary",
      "metrics": [{ "label": "People", "value": 2, "tone": "info" }]
    },
    {
      "id": "table",
      "title": "Directory Table",
      "kind": "table",
      "columns": ["name", "role"],
      "rows": [{ "name": "Ada", "role": "Lead" }]
    }
  ],
  "links": [{ "source": "summary", "target": "table", "label": "details" }]
}

Hosted-backend support uses the same connection rule as Credentials and Connection below: any resolved credential routes all registry reads and writes to the hosted API. This does not move per-project canvases, data records, loop links, or asset files out of $HASNA_PROJECTS_HOME/data/<workspace_id>/. See the Credentials and Connection section and docs/hosted-backend-readiness-contract.md for the connection contract.

projects dashboard * is the Projects-owned viewer surface for agent-managed project folders. The only project-layout convention is the canonical per-workspace store (~/.hasna/projects/workspaces/<workspace_id>/, see src/lib/project-store-paths.ts). The retired in-project singular layout is migrated once into that store by projects doctor --fix and is never read again. projects dashboard * collects provider panels from todos, files, mailery, conversations, knowledge, mementos, and reports, adds a read-only actions panel, validates the result as hasna.project_snapshot.v1, and renders it as a React Flow Canvas. Snapshot, render, and validate are read-only unless --write is passed. projects dashboard serve <project> serves /dashboard plus JSON APIs with an HTTP-only same-origin cookie; binding to a non-loopback host requires --token, PROJECTS_DASHBOARD_TOKEN, or an explicit --trust-network choice. Do not put dashboard access tokens in URLs, render specs, reports, or task evidence. Dashboard JSON never carries raw private document bodies or arbitrary shell commands.

projects reports serve serves registered project report files from each project reports/ directory. It binds to 127.0.0.1 by default. Binding reports to a non-loopback host requires --token, PROJECTS_REPORTS_TOKEN, or an explicit --trust-network choice; token mode uses an HTTP-only same-origin cookie or Authorization: Bearer header and does not accept tokens in URLs.

OpenLoops integration uses the @hasna/loops SDK as an optional peer. Runtime commands that need live loop state load @hasna/loops/sdk dynamically, so Open Projects can still manage projects when OpenLoops is not installed.

OSS Routing Matrix

projects oss matrix emits a compact routing matrix for direct child repositories under an OSS workspace root. It is designed for orchestration prompts and dispatch loops that need a capped, machine-readable snapshot without walking an entire monorepo tree.

projects oss matrix \
  --root /home/hasna/Workspace/hasna/opensource \
  --prefix open- \
  --json

Each row includes the repo name/path, package name/version/bin metadata from package.json, git branch/dirty/ahead/behind/remote state, tmux session/window hints, latest task refs from todos, and latest pull request refs from gh when those tools are available. The default limit is 25 repos, with an enforced maximum of 200. Use --limit, --no-prs, --no-tasks, --no-tmux, and --timeout-ms to keep routing scans fast in large workspaces or offline contexts.

Credentials and Connection

Projects resolves its credential and its service URL through ONE resolver — the @hasna/contracts client seam that every hosted Hasna CLI uses. The CLI, the MCP server and the ./sdk export all answer "which key, from where, against which service" identically, and the answer is recomputed on every call, so a rotated key is picked up without restarting a shell.

The five credential tiers, in precedence order

| # | Tier | Where it comes from | | --- | --- | --- | | 1 | argument | an explicit key or profile passed to createProjectsClientFromEnv() / resolveProjectStore() | | 2 | deliberate env pointer | HASNA_PROJECTS_API_KEY_OVERRIDE, HASNA_PROFILE, HASNA_PROJECTS_API_KEY_REF (a secrets-vault ITEM KEY, never a value) | | 3 | macOS Keychain | generic-password item hasna.credentials.projects.api-key, account HASNA_STATION, else hostname -s, else USER | | 4 | disk | ~/.hasna/projects/config/credentials, mode 0400 or 0600, read at call time | | 5 | process env | HASNA_PROJECTS_API_KEY |

Tier 5 is legitimate, not deprecated, and prints no notice. It sits BELOW disk on purpose: a station wrapper that injects the key into one child process is correct, while a shell export goes stale at the next rotation — so when both exist, the file on disk wins and the rotation heals immediately.

Tiers 1 and 2 are DELIBERATE: if the identity they name cannot be honoured the command fails rather than authenticating as somebody else.

HASNA_HOME replaces the ~/.hasna root and HASNA_CONFIG_HOME replaces the config root (giving <HASNA_CONFIG_HOME>/projects/credentials). XDG variables are never read. The retired locations ~/.hasna/fleet-env/, ~/.hasna/cloud/, ~/.config/hasna/ and any *-cloud.env are not inputs.

The service URL

The authority follows the same ladder and is optional:

  1. HASNA_PROJECTS_API_URL
  2. the Keychain api-url item (hasna.credentials.projects.api-url)
  3. the credentials file
  4. the default fleet gateway https://api.hasna.com/projects

The client appends /v1. URLs never need configuring: a key from any tier is enough to reach the fleet. When two sources disagree the command refuses rather than sending a credential written for one authority to another.

The unprefixed PROJECTS_API_URL / PROJECTS_API_KEY names are still accepted as a silent alias for one release; the canonical HASNA_PROJECTS_* names always work and win when both are set.

Environment variables

| Variable | Purpose | | --- | --- | | HASNA_PROJECTS_API_KEY | Tier-5 credential (process env); below disk on purpose | | HASNA_PROJECTS_API_URL | Authority override; default is the fleet gateway | | HASNA_PROJECTS_API_KEY_OVERRIDE | Tier-2 deliberate override; never resolved around | | HASNA_PROJECTS_API_KEY_REF | Tier-2.5 secrets-vault pointer (an item KEY, never a value) | | HASNA_PROFILE | Tier-2 profile selector on the disk credential tiers | | HASNA_STATION | Keychain account; else hostname -s, else USER | | HASNA_HOME | Replaces the ~/.hasna root (disk tier and local store) | | HASNA_CONFIG_HOME | Replaces the config root (<root>/projects/credentials) | | HASNA_PROJECTS_LOCAL / PROJECTS_LOCAL | Explicit opt-in to the on-box SQLite registry; must be set with no env-declared authority or credential, prints "local" on stderr | | HASNA_PROJECTS_DB_PATH | Local registry path (default ~/.hasna/projects/projects.db) | | HASNA_PROJECTS_HOME | Primary workspace store and per-project runtime data root |

XDG_CONFIG_HOME is never read, and the retired locations ~/.hasna/fleet-env/, ~/.hasna/cloud/, ~/.config/hasna/ and any *-cloud.env are not inputs.

# Nothing else is needed — the gateway is the default authority.
export HASNA_PROJECTS_API_KEY="<API key with projects:read and projects:write>"

# Or keep it out of the environment entirely:
security add-generic-password -U -a "$(hostname -s)" \
  -s hasna.credentials.projects.api-key -w '<key>'

# Or on disk, owner-only:
install -m 600 /dev/null ~/.hasna/projects/config/credentials
printf 'HASNA_PROJECTS_API_KEY=%s\n' '<key>' > ~/.hasna/projects/config/credentials

Hosted, local, or failed closed — routed on URL and key only

There is no *_STORAGE_MODE switch, no flag, no blank API URL, and no implicit local fallback.

  • A credential resolves → hosted. Every registry command goes to <authority>/v1 with the key as a bearer token. The client carries only the API key — never a database DSN — and the value is never logged, returned, or embedded in output.
  • An authority is declared but no credential resolves → the command FAILS LOUD: non-zero exit, an error naming every place the resolver looked, no local SQLite store opened and no local-fallback event.
  • Nothing configures the fleet and there is no opt-in → the command FAILS the same way. A silent station must not quietly serve a local dataset that looks like the hosted one (owner ruling 2026-09-04, hasna/apps#1720).
  • Local mode — only by explicit opt-in. Set HASNA_PROJECTS_LOCAL=1 (alias PROJECTS_LOCAL) with no authority and no credential declared in the environment: the on-box SQLite registry (HASNA_PROJECTS_DB_PATH, else ~/.hasna/projects/projects.db) serves the run, and exactly one line on stderr says it is local. The opt-in is answered before the resolver runs, so it never touches the Keychain or a credentials file; any env-declared authority or credential outranks it, and a half-configured opt-in run (URL set, no key) still fails loud.
  • Under a hosted credential, no on-box SQLite is opened — ever. The moment the ambient environment resolves a hosted authority, the process refuses every open of projects.db and of a per-project project.db. Commands whose data lives only in those files — budgets *, tmux-profiles *, loops *, project data models/records, the app-store half of store inspect, sessions (no target), cleanup-evals, agent-eval, the register-full manifest ledger — fail with REMOTE_COMMAND_UNSUPPORTED naming HASNA_PROJECTS_LOCAL=1 instead of reading or creating ~/.hasna/projects/*.db (there is no hosted /v1 route for them yet). store ensure provisions the folder layout only, and a hosted create --dry-run previews against an in-memory scratch registry. update --canonical-machine is checked against the machines registry first and names the registered slugs; a server refusal now carries the server's reason (… -> 400: Machine not found: <slug>) instead of a bare status.

The hosted connection moves the global project registry only. Machine-local side effects (tmux sessions, git operations, directory creation, rendering) and per-project data stay on-box either way: canvases, data records, loop links, and asset files live in $HASNA_PROJECTS_HOME/data/<workspace_id>/.

The server side of the hosted connection is projects-serve. It runs against PostgreSQL and requires HASNA_PROJECTS_DATABASE_URL (or PROJECTS_DATABASE_URL / DATABASE_URL), failing fast without it. API keys are verified per request and scoped: projects:read for reads, projects:write for writes. See the HTTP API section below for endpoints and key issuance.

projects create --dry-run is a true no-write creation plan. It returns planned DB writes, filesystem actions, tmux actions, verification steps, locks, and rollback records without writing rows or files. Creation cleanup remains available through MCP as projects_cleanup_create, removing only safe creation artifacts such as the project row, marker file, .git, and empty created directory.

MCP Server

Add to an MCP client config:

{
  "mcpServers": {
    "projects": {
      "command": "projects-mcp"
    }
  }
}

Streamable HTTP (MCP)

MCP uses stdio by default. A long-lived Streamable HTTP transport is also available on 127.0.0.1:

projects-mcp --http              # default port 8871
MCP_HTTP=1 MCP_HTTP_PORT=8871 projects-mcp

Endpoints: GET /health → {"status":"ok","name":"projects"}, MCP at POST/GET /mcp.

Project Tools

| Tool | Purpose | | --- | --- | | projects_roots_list / projects_roots_add / projects_roots_show / projects_roots_update / projects_roots_delete / projects_roots_match | Register, inspect, score, update, and delete root folders/path templates | | projects_recipes_list / projects_recipes_add | Manage recipe defaults for project creation | | projects_agents_list / projects_agents_add | Register human, CLI, service, and AI agents | | projects_tmux_profiles_list / projects_tmux_profiles_add / projects_tmux_profiles_apply | Manage reusable tmux sessions/windows | | projects_list / projects_show | Legacy-compatible full project listing and project detail | | projects_search | Compact bounded project discovery with fields, query scopes, and continuation metadata | | projects_render_list / projects_render_show / projects_render_start / projects_render_status / projects_render_sessions / projects_render_roots / projects_render_recipes | Emit validated JSON Render specs for project surfaces | | projects_store_inspect | Inspect canonical project storage and the per-project app store under $HASNA_PROJECTS_HOME/data/<workspace_id>/project.db | | projects_canvases_list / projects_canvases_create / projects_canvases_upsert / projects_canvases_compose / projects_render_canvas | Manage, compose, update, and render per-project React Flow canvas records | | projects_loops_link / projects_loops_list | Link project stores to OpenLoops loops and summarize them through @hasna/loops | | projects_locations_list / projects_locations_add | Inspect and register additional folder locations for a project | | projects_create | Plan or create a project anywhere on disk | | projects_start | Open or reuse a tmux session, ensure default 01/02 windows, and launch Sumi, Claude, OpenCode, Cursor, or no tool, with optional exact tmux windows | | projects_tmux_status | Inspect expected and current tmux session/window status for a project | | projects_cleanup_create | Clean up DB/files created by a project creation run using rollback records | | projects_import / projects_scan_local_roots | Import existing folders as projects | | projects_import_github / projects_scan_roots / projects_sync_roots | Import GitHub repos as local or remote-only projects, including configured GitHub root scans/syncs | | projects_github_publish / projects_github_unpublish | Publish/unlink GitHub project metadata | | projects_link | Merge external service IDs into project integrations | | projects_unlink | Clear external service IDs from project integrations | | projects_update | Update project metadata with audit events | | projects_tag / projects_untag | Add or remove project tags without replacing the full tag list | | projects_archive / projects_unarchive / projects_delete | Change project lifecycle status | | projects_doctor | Validate markers, paths, locations, references, and failed runs | | projects_events_list / projects_event_record | Inspect or record project audit events | | projects_lock / projects_unlock / projects_locks | Coordinate project mutations | | projects_agent_eval | Run prompt-agent eval cases and return success/confidence | | projects_agent_prompt | Run the AI SDK/OpenRouter project agent loop | | projects_context / projects_next / projects_why / projects_handoff | Agent-assist bundles: orientation, next-action suggestions, resolution trace, cross-agent handoff | | projects_runs_list / projects_runs_show | Read the prompt-agent run ledger (list + full detail with tool-call trace) |

Workspace-named MCP aliases are removed from the public contract.

Prompt Mode

Prompt mode uses AI SDK with OpenRouter. Configure the key with OPENROUTER_API_KEY, PROJECTS_OPENROUTER_API_KEY, or the local secrets vault. The default model is openai/gpt-4o-mini.

Mutations require --yes. Without approval, mutating tools return structured plans/previews. --dry-run forces no-write behavior even if --yes is present.

Prompt mode currently owns a machine-local agent-run ledger and therefore requires explicit HASNA_PROJECTS_LOCAL=1. Under a hosted Projects authority it fails closed before opening SQLite; use the regular CLI or MCP start surfaces for hosted project starts.

The prompt agent can inspect roots, recipes, agents, tmux profiles, and projects; create/update/tag/untag/archive/delete/import projects; import/publish GitHub repos; link/unlink external integrations; start projects with saved launch defaults; and plan/apply tmux profiles. Machine-local prompt-agent runs remain local-only; shared project mutations use the selected Projects store.

projects agent-eval seeds temporary project fixtures into an isolated SQLite database under the eval base path and runs a repeatable prompt suite over root registration/matching, recipe and agent planning, project listing/show/events, create/deduplication, import/scan, update, archive/unarchive, delete/hard-delete, cleanup, verification, tmux planning, GitHub publish/unpublish/import, and integration linking. Live mode uses OpenRouter; --mock runs deterministic create-path coverage and skips live-only cases. The JSON summary reports success_rate, confidence, and db_path.

Normal projects list output hides prompt-agent eval fixtures. Use projects list --include-evals to inspect old fixtures and projects cleanup-evals --dry-run --json followed by projects cleanup-evals --apply to remove them.

Compact output defaults

Human terminal output is compact by default to avoid filling agent context with large records. List/history commands cap rows, truncate long text and paths, and print a hint for the next detail command. Use --limit <n> to raise the row cap, --verbose for extra table columns, full paths, and diagnostic checks, show/events list for detail workflows, and --json for stable machine-readable records.

Legacy projects list --json remains a complete, full-record array for script compatibility. New agent and machine callers should opt into the bounded envelope with --detail compact; it defaults to 25 rows, minified JSON, and the fields id,slug,name,status,kind,path. path is the public compact name for the stored primary_path. Use --fields, --limit/--offset, and the returned next_offset/next_arguments to page; use --all explicitly for a complete compact population, --detail full for a bounded full-record page, --max-bytes to lower or raise the 32 KiB page ceiling, and --pretty only for interactive formatting. Explicit --all uses a 256 KiB ceiling when none is supplied and refuses rather than claiming completeness if the population still cannot fit. Byte-clipped pages report truncated, omitted_from_page, and a continuation that resumes at the first omitted row. Hosted compact reads require the server's projects.list.v2 filter attestation; an older deployment that could ignore query/tag/eval filters is refused rather than returning a mislabeled page. Deploy the matching projects-serve before rolling out a client that uses these additive compact surfaces. The same fail-closed attestation applies whenever --query-scope is explicit, including human, JSON-array, and render-spec list output; no CLI path may send the new scope and silently accept a legacy producer's unscoped answer.

Searches made through the compact contract default to --query-scope discovery (name, slug, description, and tags), so a common parent folder does not make nearly every project match. Select identity, structured, or the path-inclusive all scope explicitly when needed. Omitting --query-scope outside the additive compact contract preserves legacy search membership.

Compact terminal defaults cover the noisy project registry commands plus smaller registry lists such as projects roots list, projects recipes list, projects agents list, projects tmux-profiles list, projects locks, projects locations list, projects budgets list, and projects doctor. Examples:

projects list --limit 25
projects list --query projects --json --detail compact
projects list --query projects --query-scope all --json --detail compact --limit 25
projects list --json --detail compact --fields slug,status,path --all
projects roots list --limit 10 --verbose
projects doctor --limit 20 --verbose
projects events list my-app --limit 10 --verbose

MCP tools keep their existing full-record defaults for client compatibility. Where supported, pass compact: true to receive compact summaries; compact MCP calls accept limit, and verbose: true returns full records. Prompt-agent tools use compact project/event summaries by default; agent tools expose verbose: true for explicit detail retrieval.

For non-empty MCP discovery queries, prefer projects_search. It is compact and bounded by default, uses discovery-scoped matching, returns truthful total and continuation fields, accepts a small field projection, and emits minified JSON. Keep projects_list for legacy or unfiltered enumeration; its existing contract is intentionally unchanged.

Data Model

Core internal tables:

  • roots: named base folders, tags, path templates, default kind/recipe/tmux profile, GitHub defaults
  • recipes: reusable creation metadata, variables, default tags, and scaffold steps
  • agents: human, CLI, service, and AI actors with provider/model/permissions metadata
  • workspaces: storage records backing projects with kind, status, root, recipe, path, tags, integrations, and metadata
  • workspace_locations: machine-local paths for a project
  • workspace_events: immutable project audit events for mutations and runtime actions
  • agent_runs: prompt-loop run ledger with tool calls and results
  • tmux_profiles / tmux_profile_windows: reusable tmux session/window templates
  • workspace_locks: short-lived mutation locks
  • workspace_migration_map: one-time legacy project-to-workspace mapping

Global registry DB path: ~/.hasna/projects/projects.db (derived from the projects home, so it follows the in-package resolver once the XDG data home is adopted)

Per-project app data path: ~/.hasna/projects/data/<workspace_id>/project.db

Hosted-backend readiness contract: docs/hosted-backend-readiness-contract.md

Per-project app tables:

  • project_canvases: React Flow-compatible dashboard/canvas records
  • project_data_models: custom JSON data model definitions and render hints
  • project_data_records: custom per-model JSON records
  • project_loop_links: links to @hasna/loops loop ids/names

Global registry override: HASNA_PROJECTS_DB_PATH

Per-project app store root override: HASNA_PROJECTS_HOME

Projects home: ~/.hasna/projects

Override: HASNA_PROJECTS_HOME

SDK

The public SDK exports project-named functions and types. Storage-layer modules still use workspace names internally, but the package boundary is project-first.

import {
  createProject,
  updateProject,
  planProjectCreation,
  executeProjectCreation,
  runProjectAgentPrompt,
  startProject,
  ensureProjectStore,
  ensureDefaultProjectCanvas,
  listProjectCanvases,
  linkProjectLoop,
} from "@hasna/projects";

Per-project store helpers are also available from @hasna/projects/project-store.

HTTP API (projects-serve) & hosted SDK

projects-serve is the HTTP surface for @hasna/projects. It talks directly to PostgreSQL with no local cache or sync in the service.

# apply migrations, then serve
HASNA_PROJECTS_DATABASE_URL=<postgres-connection-string> projects-serve migrate
HASNA_PROJECTS_DATABASE_URL=<postgres-connection-string> HASNA_PROJECTS_API_SIGNING_KEY=<signing-secret> projects-serve   # :8080

Endpoints:

  • GET /health, GET /ready, GET /version → {status, version} (unauthenticated)
  • GET /openapi.json → the OpenAPI 3.1 document
  • /v1/* (API key required, header x-api-key or Authorization: Bearer):
    • GET|POST /v1/projects, GET|PATCH|DELETE /v1/projects/{id}, POST /v1/projects/{id}/archive|unarchive, GET /v1/projects/{id}/events
    • GET|POST /v1/roots, GET|PATCH|DELETE /v1/roots/{id}
    • GET|POST /v1/agents, GET /v1/agents/{id}
    • GET /v1/machines (read-only canonical-machine registry)
    • GET|POST /v1/recipes, GET /v1/recipes/{id}

Reads require the projects:read scope; writes require projects:write. Issue a key with contracts issue-key --app projects --scopes projects:*.

The typed client is generated from the serve OpenAPI (bun run sdk:generate):

import { ProjectsClient, createProjectsClientFromEnv } from "@hasna/projects/sdk";

// Credential and authority come from the five-tier @hasna/contracts resolver
// (never a DSN). With no credential in any tier this THROWS — the SDK has no
// local fallback.
const projects = createProjectsClientFromEnv();
const created = await projects.createProject({ name: "My Project", tags: ["demo"] });
const list = await projects.listProjects({ tag: "demo" });
const discovery = await projects.listProjects({ query: "billing", query_scope: "discovery", tags: ["web", "ts"], exclude_evals: true, limit: 25 });
const machines = await projects.listMachines();

For projects start --actor <id-or-slug> and the equivalent MCP start tools, hosted mode resolves the explicit actor through GET /v1/agents/{id-or-slug} and sends the returned immutable agent ID on the start event and project update. An omitted actor remains unattributed rather than inventing a local identity. Hosted actor resolution never opens the on-box Projects database. The configured authority remains the app base https://api.hasna.com/projects; clients append /v1 exactly once.

Architecture

src/
├── cli/
│   ├── index.ts                 # project CLI and prompt entrypoint
│   └── commands/
│       ├── workspaces.ts         # project, roots, recipes, agents, tmux profiles
│       └── completion.ts         # project shell completion
├── db/
│   ├── database.ts               # SQLite init/path resolution
│   ├── project-store.ts          # per-project project.db helpers
│   ├── schema.ts                 # migrations
│   └── workspaces.ts             # workspace/root/recipe/agent/tmux services
├── lib/
│   ├── workspace-agent.ts        # AI SDK/OpenRouter prompt loop
│   ├── workspace-github.ts       # GitHub import/publish and integration linking
│   ├── workspace-plan.ts         # deterministic creation plans/executor
│   ├── workspace-runtime.ts      # directory/git/marker/tmux runtime actions
│   ├── workspace-import.ts       # arbitrary folder and registered-root import scanner
│   ├── workspace-migration.ts    # legacy migration dry-run, backup, and reports
│   └── workspace-doctor.ts       # marker/path/reference validation
├── mcp/
│   └── index.ts                  # project-first MCP server
├── project-store.ts              # per-project store SDK subpath exports
├── types/
│   └── workspace.ts              # internal storage/project domain types
└── index.ts                      # SDK exports

License

Apache-2.0

Immutable ProductProjection source receipts

projects projection issue <project-id> --request request.json issues a historical factual snapshot; it does not authorize a Deployment plan, approval or execution. The matching SDK methods are issueProductProjection / readProductProjection; MCP tools are projects_projection_issue / projects_projection_get. The OpenAPI document describes both routes and required bounds.

The request contains only a UUID request_id, the exact guarded read's expected_revision, project_digest (SHA-256 of recursively key-sorted JSON of its complete project), resource_link_collection_digest, and positive resource_link_max_items, response_byte_limit, time_budget_ms bounds. Use the existing guarded project read to obtain the source and link digest. Only complete stable project IDs are accepted. Reuse the exact request after an unknown response; a changed request under the same identity conflicts. No CLI or SDK retry creates a replacement request identity automatically. Read one receipt with projects projection get <project-id> <receipt-id> --response-byte-limit <n> --time-budget-ms <n>.

This optional surface is disabled unless the server receives a valid ProjectionPolicy through ServeAppOptions.projectionPolicy or the server-only HASNA_PROJECTS_PROJECTION_POLICY_FILE or HASNA_PROJECTS_PROJECTION_POLICY_JSON. Configure exactly one environment input; setting both, including an empty value, disables this optional surface. The JSON input contains the reviewed non-secret policy document itself, limited to 262144 UTF-8 bytes before parsing. Invalid JSON, schema or digest disables the surface without logging the policy or parser errors. The strict schema and projectionPolicyDigest helper in serve/projection-policy.ts own configuration. The file must be a bounded regular single-link file, owned by root or the service UID, not writable by group/others; the final component cannot be a symlink. The operator must also verify parent custody and preserve/read back config revisions. Both environment inputs are read once at startup: activation/revocation requires a reviewed service configuration activation, not an implied hot reload.

Use the service's owning configuration/deployment mechanism to deliver a reviewed JSON revision when a policy file cannot be mounted. The input carries no bearer credential and does not supply provenance by itself: the same exact authority, evidence and subject bindings are required in either mode. Normal routes stay available when the optional policy is absent or invalid.

The policy declares an exact project and canonical UUID tenant, exact verified app/agent subjects (optionally constrained to a credential ID), issue/read operations, authority-qualified owner references, a repository reference and Projects workspace reference, and reviewed evidence with URI/digest. Policy IDs, positive revisions and canonical content digests are persisted with receipts. Repository/workspace bindings come from that reviewed policy, never a caller's assertions or editable project metadata. A missing policy, a null tenant, wrong scope/subject or ambiguous binding fails closed. Normal projects routes retain their existing authentication and output. No real target is enrolled by installing this package or applying its migration.

Each issue transaction serializes its idempotency identity, reads the project and complete ordered ResourceLinks in one SQL snapshot, checks all source preconditions, and persists a creation-only receipt. Numeric revision and sourceRevision are the same database-issued safe positive ordinal for this immutable snapshot, not the mutable project's timestamp. Sequence gaps are allowed; exhaustion refuses. The projection digest binds the snapshot and policy digests through its evidence references. Full historical source, request, verified principal and policy provenance are preserved in the receipt; later project edits do not alter it. The table rejects update/delete/truncate, receives no cascading project FK, and the existing DML runtime gets only select/insert and sequence usage. Migration remains the normal separate migrator operation. Receipts are factual evidence, not tenant namespace mappings or release grants.

Source values that the existing Projects redactor would change are refused, not silently rewritten into digest-bearing receipts. Correct such source through its normal owner before retrying under a new exact source precondition.

The issuer's canonical JSON retains every own JSON property, including nested __proto__ and constructor keys. It uses its scoped projectionCanonicalJson helper rather than changing legacy guarded-mutation digest behavior. Project precondition digests must use that lossless recursively sorted representation. First issuance, exact replay and authorized readback serialize the same receipt. ResourceLinks still satisfy their existing closed schema; unsupported label keys refuse before any receipt instead of being silently omitted.

Immutable receipts conservatively refuse URLs containing userinfo, a nonempty query or fragment, including embedded, bare-host and percent-encoded forms in project or ResourceLink strings. Ordinary plain URLs remain allowed. This is a bounded immutable-source admission rule, not a claim that the display redactor recognizes every capability. Accepted source is never normalized or redacted. Refusals use fixed PROJECTION_SOURCE_REQUIRES_REDACTION diagnostics without printing source values; inspect/correct the source through its authorized owner.

Issuer URL inspection uses native WHATWG parsing for absolute, protocol-relative, and implicit-HTTPS bare authorities (including numeric, bracketed IPv6, single-label, Unicode and userinfo forms). ASCII tab/newline controls are removed for inspection at every percent-decoding stage; accepted source bytes are never rewritten. Own JSON names receive the same checks as values. Inspection refuses strings over 1,048,576 UTF-16 code units, more than 16,384 URL candidates per string, more than 16,777,216 cumulative parser-input code units per string, or values still changing after eight decoding stages. Complete trailing text is also parsed so spaces within URL paths or queries cannot hide capabilities. Quote, apostrophe, angle and backtick characters do not terminate that input. These are fixed, value-free issuer refusals; they do not change legacy project reads or redaction.