Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4cdd1a3
feat(sessions): record git workspace metadata
OutThisLife Jun 25, 2026
8a45ce2
feat(projects): add per-profile project store
OutThisLife Jun 25, 2026
e781134
feat(kanban): link tasks to project worktrees
OutThisLife Jun 25, 2026
4e023f5
feat(gateway): build authoritative project tree
OutThisLife Jun 25, 2026
4ffdedd
feat(tools): add project workspace tools
OutThisLife Jun 25, 2026
cb3f8ec
fix(tools): isolate per-session worktree cwd
OutThisLife Jun 25, 2026
86e748d
fix(agent): require code for coding posture
OutThisLife Jun 25, 2026
e2b8018
feat(desktop): add git worktree and review IPC
OutThisLife Jun 25, 2026
3444158
feat(desktop): add shared project UI primitives
OutThisLife Jun 25, 2026
74352a1
feat(desktop): add project and coding stores
OutThisLife Jun 25, 2026
488ae37
feat(desktop): render backend-authoritative projects sidebar
OutThisLife Jun 25, 2026
7a7f9a5
feat(desktop): add composer coding rail and worktree flow
OutThisLife Jun 25, 2026
68680db
feat(desktop): add Codex-style review pane
OutThisLife Jun 25, 2026
62af32e
feat(desktop): keep active sessions aligned with cwd
OutThisLife Jun 25, 2026
b8d220f
feat(desktop): wire project settings and shell chrome
OutThisLife Jun 25, 2026
a391523
i18n(desktop): add project and worktree strings
OutThisLife Jun 25, 2026
890e890
chore(desktop): update package lock
OutThisLife Jun 25, 2026
9f3aa16
fix(cli): register project command beside MoA
OutThisLife Jun 25, 2026
19ca295
fix(desktop): clarify branch convert actions
OutThisLife Jun 25, 2026
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
61 changes: 60 additions & 1 deletion agent/coding_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,59 @@
# Agent-instruction files surfaced separately from manifests in the snapshot.
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")

# Source-file extensions that make a git repo a *code* workspace even with no
# manifest. Without this, `git init` on a notes/writing/research folder (a huge
# non-coding use case) would flip the whole session into the coding posture just
# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
_CODE_EXTENSIONS = frozenset({
".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h",
".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl",
".hs", ".clj", ".erl", ".pl",
})

# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
_CODE_SCAN_SKIP_DIRS = frozenset({
".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
"target", ".next", ".turbo", "vendor",
})

# Bounded sweep: a code workspace reveals itself in the first handful of entries.
_CODE_SCAN_MAX_ENTRIES = 500


def _has_code_files(root: Path) -> bool:
"""Cheap, bounded check for source files in a repo's top two levels.

Lets a git repo of loose scripts (no manifest) still read as a code
workspace while a bare notes/writing repo does not. Scans the root and its
immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats —
a handful of readdirs at session start, not a full walk.
"""
seen = 0
stack = [(root, True)]
while stack:
directory, is_root = stack.pop()
try:
with os.scandir(directory) as entries:
for entry in entries:
seen += 1
if seen > _CODE_SCAN_MAX_ENTRIES:
return False
name = entry.name
try:
if entry.is_file():
if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
return True
elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
stack.append((Path(entry.path), False))
except OSError:
continue
except OSError:
continue
return False

# Lockfile → package manager, checked in priority order.
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
_JS_LOCKFILES = (
Expand Down Expand Up @@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
return GENERAL_PROFILE.name
cwd = Path(cwd_str)
# A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
# workspace on its own — cheap stat checks, no scan.
if _marker_root(cwd) is not None:
return CODING_PROFILE.name
git_root = _git_root(cwd)
if git_root is not None and git_root == _home():
git_root = None # dotfiles repo at $HOME — not a code workspace
if git_root is not None or _marker_root(cwd) is not None:
# A bare git repo only counts when it actually holds code, so `git init` on a
# notes/writing/research folder stays in the general posture.
if git_root is not None and _has_code_files(git_root):
return CODING_PROFILE.name
return GENERAL_PROFILE.name

Expand Down
5 changes: 4 additions & 1 deletion agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,10 @@ def _strip_yaml_frontmatter(content: str) -> str:
"- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind "
"with no `.git`, `git worktree add <path> "
"${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then "
"cd there.\n"
"cd there. For a project-linked task the workspace is a fresh "
"`<repo>/.worktrees/<task-id>` and `$HERMES_KANBAN_BRANCH` a deterministic "
"`<project-slug>/<task-id>` — the main repo is two levels up, so run "
"`git worktree add` from there.\n"
"- **Deliverables.** Files a human wants go in "
"`kanban_complete(artifacts=[<absolute paths>])` (top-level param; paths in "
"`metadata` are NOT uploaded). Files must exist at completion.\n"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"iconLibrary": "tabler"
}
98 changes: 98 additions & 0 deletions apps/desktop/electron/git-repo-scan.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use strict'

// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.

const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')

const fsp = fs.promises

// Shallow on purpose: real projects live a few levels under home
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
// you actually use but keep deeper still surface via session-derived discovery,
// so this only prunes noise, never repos with history.
const DEFAULT_MAX_DEPTH = 3
const MAX_CONCURRENCY = 32

// Big trees that are never themselves repos and would waste the walk. Anything
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
// only needs the non-hidden heavyweights.
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])

async function mapLimit(items, limit, fn) {
let cursor = 0

async function worker() {
while (cursor < items.length) {
const index = cursor
cursor += 1
await fn(items[index])
}
}

await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
}

/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/
async function scanGitRepos(roots, options = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()

async function walk(dir, depth) {
if (depth > maxDepth) {
return
}

let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
return // unreadable / permission denied
}

// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
// FILE is a linked worktree or submodule — those belong to their parent
// repo as lanes, not as separate projects, so we don't list them (and we
// keep descending in case a real repo sits deeper). This is what kills the
// worktree/eval-repo duplicate explosion.
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
const root = dir.replace(/[/\\]+$/, '')
found.set(root, path.basename(root) || root)

return
}

const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
continue
}

subdirs.push(path.join(dir, entry.name))
}

await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
}

await mapLimit(
searchRoots.map(root => String(root || '').trim()).filter(Boolean),
MAX_CONCURRENCY,
root => walk(root, 0)
)

return [...found.entries()].map(([root, label]) => ({ label, root }))
}

module.exports = { scanGitRepos }
Loading
Loading