hermes project show alpha and hermes project show nofolder listed the same primary. Both lines were the absolute path of one directory, LAB/repo. A third project, linkproj, listed LAB/link. That path was a symlink. readlink printed LAB/repo. stat -L reported inode 1750873 for the link and for the directory. stat without -L reported inode 1750871 for the symlink itself. Create had accepted the real path and the link as two projects. Add-folder had then stored the real path on the project that started with no folder. List printed four rows and did not say that two of those primaries were one string, or that the third string reached the same inode.
That is the check to make before you bind a board, or before you treat the duplicate error as a lock on the workspace. On this install, create compares a normalized path string. It does not compare the directory that string reaches.
What this run used
The binary was Hermes Agent v0.22.5+471.g5894e75, local commit 5894e750c3db, on Linux x86_64. hermes --version reported Python 3.14.9. The project commands below were the installed checkout’s projects_command handler, the function hermes project dispatches once the launcher has finished, run under that 3.14.9 interpreter. HERMES_HOME pointed at an empty directory created for the check. The live profile’s projects.db mtime stayed 2026-09-02 21:58:56 -0400. No model turn was started. Nothing was installed over the checkout.
The stock launcher is a different gate. With HERMES_HOME set to a new directory, it exited 1 before any project line. That failure is described after the path results. A reader who stops at the launcher exit will not see the path lock at all.
The public command reference, read on 8 November 2026, says a project is a named workspace that can span folders, that state is per-profile, and that a kanban task linked to a project gets a deterministic worktree and branch under the project’s primary folder by default, when the project has one. Archive is listed as recoverable. That page does not say what “the same folder” means, and it does not mention symlinks. The checkout copy of website/docs/reference/cli-commands.md at commit 5894e750c3db is the shorter form of the same section: workspaces, per-profile state, archive is recoverable. hermes project --help on this install repeats the workspace sentence and lists the subcommands. The duplicate sentence comes from the command, not from that help text.
Spellings create treats as one path
The first create was hermes project create Alpha LAB/repo. It exited 0 and printed Created project alpha (p_9d1066da). The stored primary was the absolute path, with no trailing slash. A second create on that same absolute path exited 2. Stderr was one line:
project: folder already belongs to project 'alpha' (p_9d1066da); switch to it instead of creating a duplicate
Three other spellings of that directory also exited 2 and named alpha. LAB/repo/ with a trailing slash was refused. LAB/repo/nested/.. was refused. repo, run with the working directory set to LAB, was refused. Those are string normalizations. The trailing slash is stripped. .. is collapsed. A relative path is made absolute from the working directory. None of those creates wrote a second row. If a wrapper passes a trailing slash, a path with .., or a relative path from the directory that holds the repo, create will see the project that is already there.
An empty name is a different exit 2: project: project name must not be empty. A slug with a space, passed as --slug with the value Bad Slug, is also exit 2. The error names the slug rule: 1 to 64 characters, lowercase alphanumerics, hyphens, or underscores, and it must not start with - or _. A missing project on show is exit 1, project: no such project: replacement, not exit 2. A script that treats every non-zero status as one failure will confuse a usage error with a missing row.
The checkout’s own database test, test_create_dedups_by_primary_path, already expects the trailing-slash spelling to be a duplicate. This run matched that. The same test does not cover a symlink, and it does not cover add-folder.
The symlink create accepts
LAB/link was created with ln -s pointing at LAB/repo. Create on that path exited 0 and printed Created project linkproj (p_c2471829), with primary LAB/link. The duplicate line did not print. Show stored the symlink path, not the target. The directory inode, followed with stat -L, was 1750873 for both paths. Create’s refusal is a comparison of the stored string after absolute-path normalization. It does not follow the symlink, and it does not compare inodes.
That split matters if a board, a worktree, or a session is opened through a link and the project was created through the real path, or the other way around. set-primary on alpha with the link path, after that spelling had been removed from alpha’s folder list, exited 1:
project: 'LAB/link' is not a folder of alpha; add it first with `hermes project add-folder`.
The directory was already the primary. The spelling was not in the folder table, so set-primary refused it. Add-folder of that spelling onto alpha had exited 0 earlier and printed Added LAB/link to alpha. Remove of that same spelling then exited 0. The folder table keeps the string you passed, after the same absolute-path normalization, and remove only matches that string.
If you create the project through the path you will actually open, the later show line and the session path stay the same string. If you create it through one spelling and open it through another, create will not tell you the project exists, and set-primary will not move the marker until you add the spelling you are holding.
add-folder does not apply the create lock
hermes project create Nofolder, with no folder argument, exited 0. Show printed the slug and the name and no primary. That is a legal project. The next command was hermes project add-folder nofolder LAB/repo. It exited 0 and printed Added LAB/repo to nofolder. Show then listed that path as the primary, marked with *. Show on alpha still listed LAB/repo as its primary. List printed both rows. Neither was archived.
Create’s error says the folder already belongs to a project. Add-folder of that folder onto a project that had no primary did not consult that rule. It stored the string and, because the project had no primary yet, made it the primary. A later create on LAB/repo, after alpha had been archived in that same database, exited 2 and named nofolder, not alpha. The check looks for a live project whose primary string matches. An archived row is not in that search. A second live primary, written by add-folder, is.
Do not read list’s folder count as a uniqueness check. After that add, alpha and nofolder each reported one folder. The counts were true. They were not a statement that the directories differed. List also does not print the path, so two rows with the same folder count can still be one directory, two directories, or a directory and a missing path.
Archive frees the string, and restore puts it back
A second empty home held one project, created on LAB/repo2. Archive exited 0 and printed Archived alpha. List then printed the empty-list sentence and exited 0. The row was not gone. On the first home, after the same archive, hermes project list --all still printed alpha with (archived). hermes project show alpha printed (archived) and still listed the primary. Show does not require --all.
Create of Replacement on LAB/repo2 then exited 0 and stored the same primary string. Restore of alpha exited 0 and printed Restored alpha. List printed both rows. Show on each listed LAB/repo2 as primary. A third create on that path exited 2 and named alpha, the older live row, id p_ed4e0620. Restore does not check whether a live project already holds the string. The duplicate error comes back only on the next create.
The checkout test test_create_dedup_ignores_archived_and_other_paths already says an archived project no longer blocks the path. This run matched that, and then restored the archived row beside the replacement. The test does not cover that restore. The CLI reference says archive is recoverable. It does not say that recovering the row can recreate a primary the create command would now refuse.
hermes project use alpha, run while alpha was archived and omitted from the default list, exited 0 and printed Active project: alpha. After restore, list marked that row with *. Use will point the active marker at a slug the default list is not showing. If you archive a project and then look only at list, you will not see that the marker still names it.
A missing directory is still a primary
hermes project create Missing LAB/missing-dir exited 0. The directory was not created. A later test for that directory failed. Show still printed the path as primary, with no note that the directory was absent. Create does not stat the path. A typo, a repo that has not been checked out, or a path on a filesystem that is not mounted is stored the same way as a directory you just listed.
That is useful if you want the row before the clone. It is a poor lock if a successful create was supposed to mean the workspace was there. Show the path, then test the directory yourself. The command will not do that test for you.
Test the strings before you bind a board
Run show, not only list. List prints the slug, the name, an archived flag when you asked for it, and a folder count. It does not print the path. Show prints the primary and every folder string, and it marks the primary with *.
For each primary you intend to treat as the workspace, resolve it and compare the inode:
hermes project show alpha
readlink -f LAB/repo
stat -L -c '%i %n' LAB/repo
test -d LAB/repo
If two slugs resolve to the same inode, they are one directory with two project rows. Create will not merge them. If readlink -f and the stored string differ, create on the other spelling will succeed, and set-primary of the other spelling will fail until you add that spelling with add-folder.
Before restore, run list --all and show the archived row. If a live project already stores that primary string, restore will not refuse. You will have two live rows. The next create will name one of them and exit 2. It will not tell you about the other. If the older row is the one you archived on purpose, restore is how it becomes the row the next create names.
Before you trust an exit 1 from a fresh HERMES_HOME, read stderr. On this host the stock launcher never reached project list when the home was a new directory. The first stderr line said it was preparing dependencies for this data root, and that the checkout’s updates belong to the existing home. The build failed while compiling python-olm because clang++ was not on PATH. The process exited 1 and said no dependency environment is committed for this install, and to run hermes pm repair. Stdout had no project line. The default home’s hermes project list exited 0, printed the empty-list sentence, and wrote nothing to stderr. An exit 1 that mentions dependency prep is not the duplicate lock, and it is not an empty database.
The handler runs above used an empty HERMES_HOME and the installed interpreter so the database was not the live profile, and so the launcher’s build did not have to succeed first. On a host where the launcher can prepare a new data root, the same hermes project subcommands are the ones to run. Compare show’s primary strings, then follow the symlinks. The refusal create prints is a lock on one spelling. The directory, the link to it, and a restored row are separate questions, and show is the command that answers them.
Sources
- Installed Hermes Agent v0.22.5+471.g5894e75, commit
5894e750c3dba980d43b25aa29e3958149ba2c61,hermes_cli/projects_cmd.pyandhermes_cli/projects_db.py: the create, add-folder, archive, and restore behavior exercised above - Checkout CLI reference at that commit,
website/docs/reference/cli-commands.md, sectionhermes project - Public command reference, read 8 November 2026: https://hermes-agent.nousresearch.com/docs/reference/cli-commands
hermes project --helpon this install, which describes projects as multi-folder workspaces and does not mention path identity