Commit graph

10 commits

Author SHA1 Message Date
Gergő Magyar
ad5c7364e1
feat(storage): share one index store across linked worktrees and sibling clones (#3374)
* feat(storage): resolve the shared sibling-store identity and layout (#3352)

Linked worktrees of one repository resolve to one store under
GITNEXUS_HOME/stores/<key>, keyed by the canonical git common dir. The
resolver reads the .git entry directly, so hot paths spawn no git. Slot
naming moves to a leaf module so storage-resolver and shared-store do not
import each other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(storage): resolve a shared checkout's graph and existing store slot (#3352)

getStoragePaths reads a flat slot's recorded graphPath only for checkout
slots under the stores directory; other paths keep <storagePath>/lbug with
no I/O. A recorded path outside the store's commit graphs is ignored.
resolveStoragePath falls back to an existing store slot for an
unregistered checkout, so reads never move to an empty slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(analyze): share one immutable commit graph across clean worktrees (#3352)

A linked worktree writes its own slot in the shared store. A new slot
is seeded with a pointer to the nearest commit graph, so a clean
checkout at an indexed commit takes the up-to-date path and writes no
graph. A checkout with local changes gets a copy-on-write private
graph before its first write. After a successful run, a clean checkout
at HEAD publishes its graph into commits/ under a store lock, or drops
it when that commit graph already exists. Commit graphs are never
written after publish.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(analyze): seed a worktree's graph from the nearest index (#3352)

A new shared slot is seeded from the store's commit graph nearest to
HEAD, else from this checkout's or the main checkout's
repository-local index (copied under that index's lock, source left in
place). The run that follows is up to date or incremental instead of a
full build. If a pointed-at shared graph has been removed, analyze
falls back to a full build instead of an incremental update over a
missing baseline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(analyze): keep one parse cache per shared store (#3352)

Linked worktrees read and write the parse cache and durable ParsedFile
store under the store's caches/ directory. Before pruning, a run folds
in the chunk keys recorded by every member slot and commit graph, and
the fold, prune and save run under a store-wide cache lock so one
member never evicts another's live chunks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(clean): remove only what no shared-store member references (#3352)

Deleting a shared checkout slot (clean, clean --all, remove, and the
server delete route) recounts references under the store's publish
lock and deletes commit graphs no member points at, then the store
itself once empty. A graph that cannot be deleted (open on Windows) is
reported and kept for the next pass. clean --gc also drops member slots
whose worktree is gone or no longer resolves to the store.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(analyze): let a clone opt in to a shared store with --share-with (#3352)

An independent clone joins a linked worktree's shared store only with
analyze --share-with <repo>, and only when its normalized origin URL
matches that member's (credentials stripped, as #2054 compares). The
registry remembers the choice. --no-share moves an opted-in clone back
to its own .gitnexus and reclaims its old slot; linked worktrees always
share and are pointed at GITNEXUS_SHARED_STORE=off instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): count a shared checkout's commit graph as its code index (#3352)

A clean shared checkout reads a commit graph and owns no graph file, so
the code-index presence check made status report it unindexed and
registry validation skip it. The check now follows the slot's
validated graphPath.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(storage): adopt existing worktree indexes and report shared-store state (#3352)

A shared checkout gets <repo>/.gitnexus/store.json pointing at its store
slot; resolution follows it only when it names that checkout's own slot.
An existing local index seeds the slot and is left in place. status
(text and --json) and doctor report the store, whether the graph is
shared or private, and any leftover local index, which
clean --local-index removes while keeping the pointer. With
GITNEXUS_SHARED_STORE=off a previously shared checkout indexes into its
own .gitnexus again and never writes a commit graph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(mcp): read the graph a shared checkout points at (#3352)

MCP, the HTTP API, group sync, augmentation, and the Claude hook (all
three byte-identical copies) resolve a flat slot's graph through
resolveGraphPath instead of joining 'lbug' onto the storage path, so
checkouts at one commit share one open database. The embeddings writers
(embeddings sync and the server embed job) take a private copy first
and never write an immutable commit graph. The post-analyze settle
probe accepts fresh metadata that points at an existing commit graph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: describe the shared worktree index store (#3352)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(storage): reuse helpers across the shared-store code (#3352)

One graph-clone helper replaces two copy-then-rename blocks; clean and
status reuse formatSlotSize; leaving a store reuses
removeSharedStorePointer; withStoreLock is imported from its own module
instead of a re-export.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address review findings in the shared store (#3352)

- Never publish a graph whose build saw dirty files, and never trust a
  local-index seed on the up-to-date path; it may hold reverted edits.
- Record slot pointers and reclaim under the publish lock, and reclaim
  right after each publish, so a commit graph is never deleted between
  publish and pointer save and superseded graphs don't pile up.
- clean --gc decides membership from the registry, so opted-in clones
  and a main checkout without worktrees are not dropped.
- Leaving a store re-registers first, so an up-to-date run cannot leave
  the registry pointing at a deleted slot.
- Drop pinned branch summaries when an entry moves into a store slot.
- Keep run.cjs and the AGENTS.md runner path inside the checkout.
- MCP handles follow the slot's current graph, and branch scoping reads
  the slot's own metadata.
- Cache resolveGraphPath by metadata file identity; look up opted-in
  entries with canonical registry paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): localize help for the shared-store flags (#3352)

--help renders option text from the i18n catalog, so --share-with,
--no-share, clean --gc and clean --local-index need keys in both
locales, not just index.ts literals.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): lock the embed-job graph copy and skip it on forced rebuilds (#3352)

The server embed job copies a shared checkout's graph under the slot's
index lock, so a CLI analyze in another process cannot interleave. A
forced rebuild with no embeddings to carry over drops the pointer
without copying a graph it would discard unread.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(agents): bump AGENTS.md version for the shared store notes (#3352)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): keep shared-store file reads inside checked paths (#3374)

Address CodeQL js/path-injection and js/file-system-race on
shared-store.ts: every filesystem read rebuilds its path under a fixed
parent with an inline path.relative barrier, the .git probe is one read
(EISDIR marks a directory) instead of stat-then-read, and the graph
pointer cache stats and reads through one file descriptor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address review feedback on the shared store (#3374)

- Hold the slot index lock for the whole server embedding job, not
  just the graph copy.
- --no-share re-registers and deletes the old slot under that slot's
  index lock, so a running shared analyze cannot re-register it.
- clean --gc previews without --force, like every other destructive arm.
- status reports a pinned branch index as private.
- Slot names prefix Windows device names that carry an extension.
- A slot or store named ..<name> is a legal direct child.
- Shared-store suites run in the serialized lbug-db vitest project and
  clear an inherited GITNEXUS_SHARED_STORE.
- Doc and test accuracy fixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(storage): pin shared-store behavior for worktrees of a bare repository (#3374)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address second review round on the shared store (#3374)

- Reject --no-share in a linked worktree before taking any lock or
  indexing anything.
- clean --all and remove also delete each shared checkout's pointer.
- Delete an empty store only while also holding its cache lock, and
  re-check emptiness under it.
- A store pointer is trusted only when the slot's own metadata names
  this checkout; the editable pointer file just says where to look.
- Device names with an extension are prefixed on Windows only, so POSIX
  slot names stay stable.
- Test fixtures use the gitnexus-test- prefix the stale-sidecar sweep
  recognizes; help text and the private-graph label are accurate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address third review round on the shared store (#3374)

- clean --gc never collects a slot whose index lock is held: an analyze
  holds it until it registers the checkout, so a seeded but not yet
  registered slot is busy, not orphaned.
- Reclaim compares absolute paths, so a relative GITNEXUS_HOME does not
  make live commit graphs look unreferenced.
- graphPath is followed only into a published <commit>-<featureKey>
  dir, never .publish-* staging (TS and all three hook copies).
- clean --gc fails on an unreadable stores root instead of reporting
  nothing to collect.
- --no-share help states it is for opted-in clones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): land the English --no-share help and private-graph label (#3374)

These two strings were meant for e2a9467 and d746675 but were left out
of the commit; the zh-CN side landed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address fifth review round on the shared store (#3374)

- analyze --no-share leaves the shared store even when routed to a
  branch sub-index (gate on sharedStore, not placement.branch)
- clean --gc aborts a store whose member listing is unreadable instead
  of treating it as empty, and skips non-directory entries under stores/
- reusing an existing commit graph no longer fails a finished analyze
  when the redundant private graph cannot be wiped
- a store pointer holding JSON null, a number, a string, or an array is
  treated as invalid
- clean, remove, and DELETE /api/repo hold the checkout slot's index
  lock while deleting the slot
- the server analyze launcher waits on the checkout slot the worker
  actually writes
- sync the Factory hook copy; isolate hook tests from storage overrides;
  use a junction for the Windows worktree alias; the relative
  GITNEXUS_HOME test now sets a relative home

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address sixth review round on the shared store (#3374)

- clean, remove, and analyze --no-share remove the checkout's store
  pointer while still holding the slot's index lock, so an analyze that
  takes the lock next cannot have its new pointer deleted
- clean --gc does not follow a symlink under stores/ (lstat)
- removing a store pointer keeps the checkout's .gitnexus directory when
  it cannot be listed, instead of treating it as empty

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address seventh review round on the shared store (#3374)

- clean --gc aborts when the registry cannot be read instead of treating
  every member as orphaned; with no registry file it collects only
  members whose checkout directory is gone
- seeding from a repository-local index re-reads its metadata under the
  index lock, so the copied graph and the saved metadata match
- clean --gc skips a store another collector removed meanwhile, and
  reports "no shared stores" when stores/ holds only stray files

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address eighth review round on the shared store (#3374)

- removing a checkout's storage unregisters it and removes its store
  pointer before deleting the slot directory, which the file lock
  backend uses for its lock file; withCheckoutSlotLock becomes
  removeCheckoutStorage, used by remove, clean, and DELETE /api/repo,
  and analyze --no-share follows the same order
- the clean --gc preview skips a slot whose index lock is held, matching
  what --force would drop

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(storage): share the index store between clones automatically (#3352)

Clones of one repository now share like linked worktrees. A clone whose
normalized origin URL matches another registered, still-present clone
joins that clone's store, or founds one (keyed on its own path) that the
sibling joins on its next analyze. A lone clone keeps its own .gitnexus.
Graphs stay keyed by commit and feature key, so clones only ever share a
graph built from the same commit with the same settings.

`analyze --no-share` now records a lasting opt-out (`shareOptOut` on the
registry entry, preserved across re-registration); `--share-with` clears
it. The analyze worker reports the storage it wrote over IPC so the
server settles a clone's first shared slot.

A query-time base-plus-overlay graph stays out: LadybugDB reads one
database per query. Instead, private graph copies record whether the
filesystem cloned them copy-on-write (sharing unchanged pages on disk)
or made a full copy, and `status` reports it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): never follow a symlinked checkout .gitnexus (#3374)

A checkout whose `.gitnexus` is a symlink (e.g. `.gitnexus -> ..`) made
the shared-store pointer helpers operate on whatever it pointed at:
findLegacyLocalIndex listed the target as a "legacy index", so
`clean --local-index --force` recursively deleted the checkout's
siblings; writeSharedStorePointer wrote store.json/.gitignore there; and
removeSharedStorePointer deleted store.json there and could rm -r the
target directory.

All four now go through probePointerDir, which lstats `.gitnexus` and
accepts it only when it is a real directory whose realpath is
realpath(checkout)/.gitnexus (mirroring stale-branch-slots.ts). Anything
else is left untouched: no legacy index is reported or removed, and no
pointer is written or removed. removeLegacyLocalIndex re-probes after
sizing, just before its delete loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): never share a graph from a checkout that hides files (#3374)

Trigger: a sparse checkout, a skip-worktree/assume-unchanged entry, or an
uninitialized submodule leaves `git status` clean, and the run's
indexCoverage.dirtyPaths drops paths with no file hash, so publishSharedGraph
published a graph missing those files as the commit graph. seedSharedSlot
then copied the seed's lastCommit into the new pointer slot, so the next
analyze of a sibling hit the up-to-date fast path without hashing.

Fix: add isWorkingTreePristine (storage/git.ts): the unfiltered
listWorkingTreeDirtyPaths must be empty (null fails closed) and every
gitlink in the index must have a checked-out `.git`. publishSharedGraph
requires it instead of isWorkingTreeDirty. seedSharedSlot keeps the seed's
lastCommit only when the seed is at HEAD and the checkout is pristine, so a
clean sibling still fast-paths onto the shared graph; any other seeded
pointer gets an empty lastCommit, like seedFromLocalIndex, and its next run
hash-diffs, then re-points at the commit graph on publish.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): keep graphs with pending embeddings out of the shared store (#3374)

Trigger: an analyze that finished with embeddings still owed (meta carries
embeddingCheckpoint) was published as the commit graph, because the
feature key ignores the checkpoint and publish never checked it. The
published meta kept the checkpoint while the graph could never change. The
next run healed the embeddings privately, found the commit graph already
there, wiped its own healed graph and pointed back at the partial one.

Fix: publishSharedGraph treats a checkpointed graph as not shareable, so it
stays private and no published commit graph carries a checkpoint. When the
target commit graph exists but records a checkpoint, has fewer
stats.embeddings than the private graph, or has unreadable metadata, the
checkout keeps its private graph instead of re-pointing. The commit graph
is left as is because other checkouts may read it. Embeddings sync and the
server embed job already privatize a pointer slot before writing
(ensurePrivateSharedGraph).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): rebuild a shared slot whose graph is missing (#3374)

A publish interrupted between moving the checkout's graph into
`.publish-<uuid>` staging and renaming staging onto the commit dir left
slot metadata at HEAD with no graph and no graphPath; the next reclaim
deleted the orphaned staging. The up-to-date fast path never checked that
the graph exists, so every later analyze reported "Already up to date"
over a checkout with no index. A failed restore in publishSharedGraph's
catch had the same outcome, and also deleted the staging dir holding the
only copy of the graph.

- run-analyze: for a shared-store slot, stat the graph the checkout reads
  (resolveGraphPath for the flat slot, the branch slot's own lbug
  otherwise) before the fast path; if it is missing, force a full
  rebuild. An incremental run would diff nothing into a fresh, empty
  database. Private .gitnexus indexes are unchanged: they only lose their
  graph by hand, and metadata-only fast-path fixtures rely on that path.
- publishSharedGraph: when moving the staged graph back fails and it is
  still in staging, keep the staging dir, log its path, and skip this
  run's reclaim, which would otherwise delete it. The next analyze finds
  no graph and rebuilds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): fail safe on unreadable slot metadata during reclaim (#3374)

Trigger: reclaim counted commit-graph references with loadMeta, which
returns null for a torn or unreadable gitnexus.json as well as for a
missing one. A member whose metadata could not be read therefore
"referenced nothing", and the graph it still used was deleted. Separately,
in `clean --gc` an orphan slot that fs.rm could not delete threw out of
reclaim, aborting the collection of every store after it.

Fix: the reference loop reads slot metadata with a local loadMetaStrict.
Only absent metadata (ENOENT/ENOTDIR, same legacy-mirror fallback as
loadMeta) means no reference; any other read error or a parse failure
throws with the file path, like listDirStrict. Every caller already
treats a reclaim throw as best effort (analyze logs "skipped cleanup",
slot removal ignores it) or surfaces it (clean --gc). A failed orphan
slot delete is now kept: it stays a member, so the graph it names is
kept too, and it is reported in ReclaimResult.keptMembers and by a new
clean --gc line. loadMeta itself is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(server): unregister under the slot lock and answer 409 on DELETE /api/repo (#3374)

DELETE /api/repo called removeCheckoutStorage(storagePath) with no
unregister callback and no checkout path, swallowed every error, and
unregistered later outside the slot lock. For a shared-store slot this
left the checkout's store.json pointer behind, let an analyze re-register
between the slot removal and the unregister, and reported {deleted} even
when the slot lock could not be taken because an analyze held it.

The handler now calls removeCheckoutStorage(storagePath,
() => unregisterRepo(entry.path), entry.path), matching `gitnexus remove`
and `gitnexus clean`, so the unregister and the pointer removal run under
the slot lock; non-shared storage is still removed, then unregistered.
The later standalone unregister is gone. An IndexLockTimeoutError answers
409 and leaves the entry registered; any other failure propagates to the
handler's 500, also with the entry kept so the delete can be retried,
instead of unregistering over leftover index files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): give concurrent sibling clones one deterministic founder key (#3374)

Trigger: two registered clones of one origin, both still on local storage,
analyzing at the same time each saw no sibling store yet and founded a store
keyed on their own checkout path. registeredStore() then kept each clone in
its own store for good, so they never shared a graph.

Fix: when no sibling store exists, siblingCloneStore keys the new store on
the canonical path that sorts first among this clone and its registered
siblings (case-folded on Windows, like registryPathEquals), so every sibling
computes the same key. An existing sibling store still wins. Clones that
already diverged into two stores are not migrated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): keep subdirectories of a clone out of shared stores (#3374)

Trigger: `analyze --skip-git <clone>/pkg` inside a clone with a registered
sibling of the same origin joined, or founded, a clone store. getRemoteUrl
answers from any subdirectory, so siblingCloneStore saw the enclosing
clone's remote. That broke the shared-store invariant that only tree roots
participate.

Fix: resolveOptedInStore now returns undefined for a path with no `.git`
entry. It uses hasGitDir, which accepts a directory or a linked-worktree
file, the same as resolveSharedStore's readCommonDir gate and run-analyze's
repoHasGit. `--share-with` from such a path throws a clear error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(hooks): prefix Windows device names with an extension in hook slot names (#3374)

Trigger: on Windows, storage-slot.ts sanitizeSlotBasename prefixes a
device-name basename that has an extension (`CON.txt` -> `repository-CON.txt`),
but the hook's copy in registry-query.cjs only matched the bare device name.
With GITNEXUS_STORAGE_ROOT set, a checkout named e.g. `con.txt` got a
different slot from the hook than from the CLI, so the hook could not see its
index.

Fix: mirror the platform branch (extension form on win32, bare form
elsewhere) in all four byte-identical registry-query.cjs copies, and point
their header comments at storage-slot.ts. Add a hook-vs-TS parity test over
device-name basenames on both stubbed platforms, and fix the byte-identity
test title to say four copies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(storage): tidy the shared-store fix series (#3374)

Explain the keptStaging early return where it is checked, reuse the
exported EmbeddingCheckpoint type in the publish test, and drop
review-step labels from test comments. No behavior change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(storage): address ninth review round on the shared store (#3374)

- removeCheckoutStorage: keep the lock-safe order (unregister and drop the
  pointer under the slot lock, delete the slot last), but when the final
  rm fails, throw an error that says the checkout was already unregistered,
  names the leftover slot, and points at `gitnexus clean --gc --force`.
- clean --gc preview no longer sweeps staging files: acquireIndexLock
  takes `sweep: false`, which the dry-run reclaim passes.
- listStoreMetaRoots reports completeness; a store directory that cannot
  be listed (other than missing) makes the parse-cache prune retain every
  chunk instead of evicting keys other members still use.
- clean.shared.kept says "could not be removed" rather than "still open".
- ARCHITECTURE.md describes the stores/<key>/ layout and store.json
  pointer; README lists every GITNEXUS_SHARED_STORE off value and that it
  also stops clone sharing.
- Tests: close the direct Ladybug connection in finally; fix a fixture
  JSDoc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(storage): pin the publish gate for cone and sparse-index checkouts (#3374)

isWorkingTreePristine already rejects every sparse mode, because each one
marks left-out entries skip-worktree and `git ls-files -v` expands a sparse
index. Only no-cone was tested; cover cone and cone with a sparse index too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(storage): name the checks that answer recurring review findings (#3374)

Comment-only. The review bot re-derives findings from the code on every
push, so put the refuting fact next to each flagged line: the pristine
check's hidden-path coverage, sparse checkouts being skip-worktree, the
lock-safe unregister-then-delete order, the hook parity test for device
names, and why the stores/ lstat is not a race guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:20:12 +01:00
Joseph Yared
b92c14cdd0
feat: add Factory AI (Droid) integration (#2543)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* feat(setup): add Factory Droid (MCP + skills) to gitnexus setup

Register 'droid' in the editor-targets abstraction so `gitnexus setup -c droid`
writes the MCP server to ~/.factory/mcp.json and installs skills to
~/.factory/skills/ from the single canonical skills/ source (no per-editor
copies). uninstall.ts is target-driven, so removal is covered automatically.
Adds unit + round-trip coverage.

* feat(plugin): add gitnexus-factory-plugin for droid plugin install

* docs: add Factory Droid to editor support table and setup docs

* fix(factory-plugin): guard augment hook against fan-out and DB contention

Reuse the Claude adapter's acquireHookSlot and LadybugDB owner probe
(bundled byte-identical, kept in lockstep by a drift test) instead of
running an unguarded augment. Add direct tests for the hook and manifests.

* docs: align Factory row in editor support table

* fix(factory-plugin): honor GITNEXUS_HOOK_CLI_PATH so augment runs on Windows

* docs(hooks): point bundled guard copies at their drift tests

* docs(factory-plugin): note the Execute tokenizer's quoting limit

* docs(readme): clarify the Full tier and group the Factory row

* docs(hooks): trim drift note to a single line

* test(ci): run factory-plugin tests on the windows cross-platform lane

* refactor(hooks): drop the drift-note comments, the tests already enforce it

* fix(factory-plugin): pin CLI version and parse quoted shell patterns

- Pin mcp.json and the hook's npx fallback to gitnexus@<version> from
  the plugin manifest, registered with the release sync script so a
  mutable @latest can never execute on MCP connect or augment fallback
- Port the #2938 shell tokenizer (tokenizeShellWords + parseRgGrepPattern)
  so quoted, backslash-escaped, --regexp=, -eVALUE, and -- patterns survive
- Add the #2938 regression matrix and pin assertions to factory-plugin.test.ts

* docs: add Factory Droid to published npm README

* fix(factory-plugin): wire marketplace so droid installs the Factory plugin

Add .factory-plugin/marketplace.json sourcing ./gitnexus-factory-plugin.
Droid reads it before .claude-plugin/marketplace.json, so
`droid plugin install` now delivers the Factory plugin (Execute matcher,
pinned mcp.json) instead of the translated Claude plugin (Bash matcher,
gitnexus@latest). Register the surface in the version-sync script and
cover the wiring in the factory and sync test suites.

* fix(factory-plugin): use registry lookup for index resolution

Bundle registry-query.cjs so external indexes resolve (#3060); re-pin to 1.6.12.

* fix(factory-plugin): sync Execute parser with Cursor hook

Fixes echo-rg and -f false positives; tighten test env isolation.

* fix(factory-plugin): stop no-match augment from re-running via npx

A PATH `gitnexus` that finds no match exits 0 with empty stderr, which
fell through to a second `npx -y gitnexus@<pin> augment` with its own 8s
timeout (16s worst case vs the 10s hook budget). Fall through to npx only
when the PATH launcher is missing (ENOENT); any launched PATH binary,
including a timeout or non-zero exit, now ends the augment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(factory-plugin): filter augment stderr to the [GitNexus] block

runAugment returned raw child stderr, so npm/Node/LadybugDB warnings leaked
into additionalContext and noise-only stderr counted as success. Port the
Claude adapter's extractAugmentContext (verbatim, with isDebugEnabled) and
apply it on every launch tier before the success decision. Adds a drift test
against the Claude copy and PATH-tier noise/noise-only behavior tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(factory-plugin): quote DROID_PLUGIN_ROOT in hook command

An unquoted plugin root containing spaces (e.g. a Windows user profile
path) split into multiple argv words, so the PostToolUse hook silently
never ran. Quote it like the Claude plugin does, and pin the exact
quoted command in the hooks.json wiring test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(release): stage Factory plugin manifests in the release commit

The rc release job stages only the original four manifest surfaces in the
detached release commit, so the v<version> tag tree carried the Factory
plugin.json, mcp.json and marketplace.json at the previous version while
--check (working tree) passed. Stage them too, and guard the git add block
against the synced surfaces in sync-plugin-manifests.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(factory-plugin): simplify hook gates, spawn tiers and tests

- main(): resolve the repo only after the tool-name and pattern gates,
  matching the Claude/Cursor hook order (skips fs/git work on no-op calls).
- runAugment(): share one spawnAugment helper between the
  GITNEXUS_HOOK_CLI_PATH and npx tiers; PATH tier ENOENT logic unchanged.
- factory-plugin test: pre-filter comment lines instead of `continue`.
- sync-plugin-manifests test: hoist EXECUTABLE_MCP_FILES and derive
  TOTAL_SURFACES from its length.
- fnSource(): throw when the function or its closing brace is not found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): list Factory Droid in localized setup help

`localizeCliHelp` overwrites the `setup` command description with the
`help.command.setup.description` i18n key, so the literal edited in
index.ts never reached `gitnexus setup --help`. Add Factory Droid to the
en and zh-CN keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback (#2543)

- factory hook: run every augment tier under the bundled Unix timeout
  guard (npx tier group-kills), keeping exactly-one-tier fall-through
- hook-db-lock-probe: trim GITNEXUS_HOOK_{LSOF,PS}_PATH once so a padded
  override is used, not silently replaced (all 3 copies)
- hook-lock: evict a stale slot via rename-to-tombstone + identity check,
  so a concurrently recreated fresh lock is never deleted (all 4 copies)
- registry-query: a set-but-invalid storage override resolves no repo
  instead of falling back to the registry storagePath (all 4 copies)
- publish.yml: stage the ten skill mcp.json manifests in the rc release
  commit; the staging test now requires every synced surface

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback round 2 (#2543)

- hook-lock: replace rename-to-tombstone eviction with an O_EXCL per-slot
  `.evicting` marker plus an identity re-check before unlink, so a live
  lock is never moved, and a crashed evictor leaves only a self-expiring
  marker (all 4 copies)
- hook-db-lock-probe: clamp GITNEXUS_HOOK_PROC_CMDLINE_MAX to a named
  256 KiB ceiling and require an integer, so an oversized override can
  no longer fail the buffer allocation and miss a live owner (all 3 copies)
- registry-query: treat an empty GITNEXUS_STORAGE_PATH/ROOT as set but
  invalid, matching the CLI's `!== undefined` rule (all 4 copies); the
  factory test env now deletes those keys instead of blanking them

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback round 3 (#2543)

- hook-db-lock-probe: a capped /proc cmdline read stops early only once
  both the GitNexus token and the mcp/serve mode are present (or at EOF,
  the ceiling, or the budget), so a mode word such as `--require mcp`
  before the GitNexus path no longer hides a live owner (all 3 copies)
- registry-query: correct the override comment; a filesystem root is
  invalid only for GITNEXUS_STORAGE_PATH, not GITNEXUS_STORAGE_ROOT
  (all 4 copies, comment only)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback round 4 (#2543)

- hook-db-lock-probe: an fd-directory read error other than ENOENT or
  ENOTDIR on an identified server candidate now fails closed ('timeout')
  instead of reporting not-owned (EMFILE/ENFILE/ENOMEM/EINTR)
- hook-db-lock-probe: resolve GITNEXUS_HOOK_TIMEOUT_PATH to an absolute
  path before validating and caching it, so callers that spawn with a
  request cwd can still execute the guard
- hook-db-lock-probe: document the chunked cmdline read's actual stop
  conditions (all 3 copies)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Harden hook-lock eviction marker lifecycle (#2543)

Per the chosen option (B) for the stale-slot eviction race:
- `.evicting` markers carry a per-call owner token (pid + random hex)
- an evictor re-reads its token immediately before the slot identity
  check and unlink; a stalled evictor whose marker was broken backs off
- `finally` removes the marker only while it still holds our token
- an orphaned marker is broken only if, re-checked just before unlink,
  its bigint identity and token are unchanged from when judged stale
- doc comment states the two remaining two-syscall windows (slot
  lstat->unlink, marker token->unlink); POSIX has no conditional
  unlink, and the worst case is one extra concurrent augment

All four byte-identical hook-lock copies updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fix CodeQL file-system race in hook-lock orphan-marker check (#2543)

breakOrphanedMarker stat'd the marker by path and then read it by path,
which CodeQL flags (js/file-system-race): the file could be replaced
between the two calls. Take the stat and the token from one open
descriptor (readMarkerSnapshot, O_NOFOLLOW where available) for both the
"judged stale" snapshot and the pre-unlink re-check. All four hook-lock
copies updated.

The replaced-marker test injected its swap via a readFileSync(path) spy,
which no longer fires; it now swaps the marker just before its second
open, counting opens of the marker path only (a per-path counter fired
early on slot-0 and let a mutant pass).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Unregister hook-lock exit listener on release (#2543)

Each acquireHookSlot registered `release` as a process 'exit' listener
that was never removed, so a long-lived process acquiring and releasing
slots repeatedly would accumulate listeners (MaxListenersExceededWarning)
and retain every closure. release() now removes itself. All four
hook-lock copies updated; a test asserts 12 acquire/release cycles leave
the 'exit' listener count unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:12:40 +01:00
mengkaka
79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wrap the bridge stamp predicate so prettier --check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a quiet interval before stamping a settled bridge file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse shared storage and settle helpers instead of local copies.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00
guyua9
b7850e6695
fix(cursor): preserve quoted shell search patterns (#2938)
* fix(cursor): preserve quoted shell search patterns

* fix: preserve backslashes in quoted shell patterns

* fix(cursor): parse attached regexp options

* fix(cursor): parse attached regexp options

* fix(cursor): honor rg end-of-options marker

* fix(cursor): scan repeated regexp options (#2938)

Keep parsing after short explicit patterns so later eligible regexps are selected without mistaking path operands for search terms.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): harden shell search pattern parsing (#2938)

Keep unquoted Windows backslashes so rg.exe paths still parse, skip pattern-file operands, and treat grep -r as recursive rather than a valued replace flag.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: luyua9 <luyua9@foxmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 11:33:22 +00:00
Livio Gamassia
d546fa3cce
fix(storage): rename index metadata to gitnexus.json with dual-write compatibility (#2363) 2026-07-03 19:32:59 +01:00
Minidoracat
0054496323
fix(hooks): wrap the augment CLI child in the orphan guard (#2163) (#2169)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Devcontainer Smoke / Build devcontainer image (push) Has been cancelled
* fix(hooks): wrap the augment CLI child in the orphan guard (#2163)

Follow-up invited by the maintainer on #2165: the augment child
(7s local / 12s npx) was the longest-lived unwrapped subprocess, exposed
to the same SIGKILL-orphan mechanism fixed for lsof/ps.

- Export resolveUnixGuardTimeout from the probe module (both copies,
  byte-identical); adapters share the same module instance, so the memo
  and lazy self-test still run at most once per hook process.
- Wrap every CLI-executing branch of runGitNexusCli in the three
  probe-equipped adapters with the guard: budget ceil(inner/1000)+1
  seconds with -k 1, strictly above each branch's inner spawnSync
  timeout, so the supervised path is unchanged and the wrapper only
  matters once the hook itself is SIGKILLed. Windows and no-guard hosts
  keep byte-identical argv. The plugin adapter's PATH-direct gitnexus
  branch (its most common production path) is wrapped too; the cheap
  which/where probe is not.
- Cursor integration: debug-gated 'augment skipped: hook slots
  saturated' on the slot-starved early return. Its augment child stays
  unwrapped for now — that integration does not install the probe
  sibling (the 'cursor probe' item on the #2163 follow-up list).
- Reaping tests get a guard-availability precheck with an explicit
  failure message (assertion, not skipIf, so a coreutils-less Linux
  host fails diagnosably instead of going silently green).
- Tests: orphaned-augment reaping (CJS + Plugin, red without the wrap,
  ~9.1s reap measured), disabled-sentinel degradation equivalence,
  source pinning for all three adapters (exact per-branch budget-formula
  counts) + probe export + cursor debug line.

Note: pre-commit typecheck skipped; remaining tsc errors are
pre-existing on main (none in files touched here).

* fix(hooks): group-SIGKILL the npx arm, prove guard exit propagation (#2169 review)

Addresses the tri-review findings on #2169:

- [P2] npx-arm containment: the CLI is the guard's grandchild there —
  at budget expiry coreutils timeout TERMs the group, npx (the obedient
  direct child) dies, timeout returns, and -k never fires, so a
  SIGTERM-immune grandchild escaped unbounded. The npx arm's wrapper now
  uses -s KILL: an unignorable group SIGKILL at budget that reaps the
  grandchild (kept -k 1 as a harmless belt; direct-exec arms keep
  TERM-first). CHANGELOG, adapter docblocks, and the test comment now
  state the per-arm semantics honestly. New behavioral test: a staged
  hook with a PATH-injected fake npx spawning a SIGTERM-immune
  grandchild is SIGKILLed; the grandchild must be reaped (red without
  -s KILL), with a route self-proof marker pinning the npx arm.
- [P3] guard self-test now proves exit-status propagation
  (sh -c 'exit 42' must yield status 42), so an always-exit-0 stub like
  /bin/true is rejected and resolution falls through to the built-in
  candidates instead of silently killing the augment feature. New test:
  stub guard rejected, augment still emits context.
- [P3] cleanup SIGKILLs in the reaping tests re-check the
  /proc/<pid>/cmdline identity immediately before firing (PID-reuse
  guard), applied consistently to the two pre-existing #2165 spots and
  both new tests.
- Review notes: source pins now constrain wrapper argv order and exact
  per-arm counts; adapters degrade to unwrapped on probe version skew
  (typeof check) instead of a swallowed TypeError; export JSDoc wording
  fixed for relative env paths; debug-gated diagnostic when no guard is
  available (e.g. macOS without coreutils), with the CHANGELOG entry
  qualified accordingly.

Note: pre-commit typecheck skipped; remaining tsc errors are
pre-existing on main (none in files touched here).

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-12 15:17:30 +01:00
ManniX-ITA
39e9b40136
fix(windows): pass windowsHide:true to every child_process spawn-family call (#1794)
* fix(hooks): pass windowsHide:true to every spawnSync to suppress flashing console windows on Windows

On Windows, every PostToolUse and Stop event from Claude Code (and
the Cursor integration variant) cold-spawns ``node`` / ``npx.cmd`` /
``git`` / ``lsof`` through ``child_process.spawnSync``. Without
``windowsHide: true`` in the options, Node's child_process module
asks ``CreateProcess`` to use ``STARTF_USESHOWWINDOW`` with
``SW_SHOWDEFAULT``, and a black console window flashes onto the
user's desktop for the duration of the call. Under active
editor / agent use this means a near-continuous stream of pop-up
windows — unusable in practice (reported live on a Windows 11
workstation running the gitnexus Claude plugin against an active
project; the flashes stack on the taskbar and steal focus from the
editor).

The Node fix is one option flag per spawnSync:

    spawnSync(cmd, args, {
        encoding: 'utf-8',
        timeout,
        cwd,
        stdio: ['pipe', 'pipe', 'pipe'],
        windowsHide: true,            // <-- new
    });

``windowsHide`` is a no-op on macOS/Linux (Node docs: "Hide the
subprocess console window that would normally be created on Windows
systems"), so the patch is platform-neutral and zero-risk on the
other two majors.

This commit touches every ``spawnSync`` call in the three sources
that ship the hook layer:

* gitnexus/hooks/claude/gitnexus-hook.cjs            (4 sites)
* gitnexus/hooks/claude/hook-db-lock-probe.cjs       (3 sites)
* gitnexus-claude-plugin/hooks/gitnexus-hook.js      (6 sites)
* gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs (3 sites)
* gitnexus-cursor-integration/hooks/gitnexus-hook.cjs (3 sites)

Total: 19 spawn sites guarded. ``hook-lock.cjs`` / ``hook-lock.js``
don't spawn subprocesses; nothing else in the hooks/ dirs touches
``child_process``.

Verified on Windows 10 22H2 / Node 22.21 / gitnexus 1.6.5 by
installing the locally-built tarball and running an active Claude
Code session against a large mixed-language repo — no console
window appears for any hook fire (pre-fix: ~2-3 visible flashes per
edit). No behavioural change on Linux/macOS hosts.

* test(hooks): regression — every hook spawnSync paired with windowsHide:true

Source-level assertion that every ``spawnSync`` invocation in the
hook layer has a matching ``windowsHide: true`` in its options
object. Without the flag, Node's child_process module asks
CreateProcess to use STARTF_USESHOWWINDOW with SW_SHOWDEFAULT and
a black console window flashes onto the user's desktop for the
duration of each call — see the parent fix commit.

The check is source-level rather than behavioural because:

* the flag's effect is observable only on Windows;
* GitHub Actions runs vitest on Linux for the hook tests;
* regressing this is easy (every new spawnSync site has to remember
  to add the flag), and a runtime check on a Windows-only CI leg
  would still let a PR land on the main branch first.

Counts spawnSync occurrences and windowsHide:true occurrences per
file (in code, ignoring comments) and asserts equality. Five files
covered:

* gitnexus/hooks/claude/gitnexus-hook.cjs
* gitnexus/hooks/claude/hook-db-lock-probe.cjs
* gitnexus-claude-plugin/hooks/gitnexus-hook.js
* gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
* gitnexus-cursor-integration/hooks/gitnexus-hook.cjs

Adding a new hook file requires updating the HOOK_FILES tuple. A
sanity assertion ``spawnCount > 0`` catches accidental deletion of
all spawn calls in a future refactor (would otherwise silently make
the count-equality assertion trivially true).

Sits next to the existing "no shell: true" and ".cmd extension"
regression tests in test/unit/hooks.test.ts — same shape, same
spirit.

* fix(src): extend windowsHide:true to every spawn-family call in cli/core/mcp/server

Companion to the hook-layer fix in this branch's first commit. The
same Windows console-window flash bug applies to every
``spawn`` / ``spawnSync`` / ``execFile`` / ``execFileSync`` /
``execFileAsync`` / ``execSync`` call in the source tree — not just
the hooks. The MCP local backend
(``src/mcp/local/local-backend.ts``) and the ``gitnexus serve`` git
helpers (``src/server/git-clone.ts``) are particularly bad because
they run from daemonized processes that have no parent console; the
spawned child auto-allocates one and it pops onto the user's
desktop. The CLI sites are less visible (the user is at a terminal
with an existing console; ``stdio: 'inherit'`` shares it) but the
flag is harmless there — windowsHide only suppresses NEW console
allocation, an inherited parent console is untouched. The visible
output of ``gitnexus analyze`` and friends is preserved verbatim.

The pre-existing fix at ``src/core/lbug/extension-loader.ts:96``
established the convention in this codebase. This commit applies it
uniformly.

Sites covered (21 new):

| File | Sites |
|---|---|
| src/cli/analyze.ts           | 1 |
| src/cli/setup.ts             | 2 |
| src/cli/wiki.ts              | 3 |
| src/core/embeddings/embedder.ts | 1 |
| src/core/git-staleness.ts    | 3 |
| src/core/run-analyze.ts      | 1 |
| src/core/wiki/cursor-client.ts | 2 |
| src/core/wiki/generator.ts   | 3 |
| src/mcp/local/local-backend.ts | 2 |
| src/server/git-clone.ts      | 2 |
| src/core/lbug/extension-loader.ts | (already had it, untouched) |

Combined with the 19 hook sites from the first commit + the 1
pre-existing extension-loader site, the codebase now has uniform
``windowsHide: true`` on every spawn-family call.

Behavioural notes:

* ``windowsHide`` is documented by Node as a no-op on POSIX —
  Linux/macOS hosts see byte-identical behaviour.
* ``stdio: 'inherit'`` callers (e.g. ``cli/wiki.ts:522`` opens the
  editor in the user's terminal) keep their interactive UX. The
  child inherits the parent's stdio handles; no new console is
  allocated; the flag has nothing to hide.
* Piped callers (``stdio: ['pipe',…]``) continue to deliver every
  byte of stdout/stderr back to the parent for the parent to log
  / process / re-print. No output is swallowed.
* ``execSync`` / ``execFileSync`` callers that previously had no
  ``stdio`` option (e.g. ``generator.ts:887`` ``execSync('git
  rev-parse HEAD', { cwd })``) keep their default pipe semantics
  (``.toString()`` still works) — windowsHide is added alongside
  the existing ``cwd`` option.

Verified on Windows 10 22H2 / Node 22.21 by installing the locally
built tarball and exercising:

* MCP detect_changes via the local backend → no flash.
* gitnexus serve → no flash on git clone/clone-pull.
* gitnexus analyze interactively → output appears in terminal as
  before, no extra window.

* test(windowsHide): extend regression to every spawn-family call in src/

Companion to the src/ patch. The hooks.test.ts regression now
covers 16 files (5 hooks + 11 source files), and asserts the
invariant for every spawn-family function — not just spawnSync.

Changes:

* Generalise countSpawnCalls() to also count spawn, execFile,
  execFileSync, execFileAsync, execSync (the entire spawn-family
  surface of child_process). Skip method calls (e.g. RegExp.exec)
  via a negative-lookbehind on ``.``.
* Add SRC_FILES table with all 11 source-tree files that import
  spawn-family functions from child_process.
* Loop over [...HOOK_FILES, ...SRC_FILES] so a regression in any
  file fails the same test name.
* Tighten the assertion to ``hideCount >= spawnCount`` rather
  than strict equality, because some sites (e.g. setup.ts:534
  using execFileAsync via shell:true on Windows) may legitimately
  add windowsHide to nested option objects in future refactors.
* Sanity gate ``spawnCount > 0`` per file catches a refactor
  that deletes all spawn calls (would otherwise make the
  assertion trivially true).

Manually exercised against the patched repo:
  16 files, 28 total spawn-family calls, 28 windowsHide:true.
  All pass.

The convention to keep this list in sync: every new file in
gitnexus/src/ that imports from 'child_process' must be added to
the SRC_FILES tuple. The cost is one line per file; the benefit
is the next contributor never has to think about windowsHide
again — the test will catch a miss before merge.

* style: prettier --write on storage/git.ts + hooks.test.ts

CI quality / format job flagged two formatting issues in the
merge-resolution commit: a long single-line options object in
storage/git.ts and similar in hooks.test.ts. prettier --write
fixes both with the project's standard wrap-and-trailing-comma
style. No semantic change.

* test(git): include windowsHide in toHaveBeenCalledWith assertion

The merge-resolution commit added windowsHide:true to the
'git rev-parse --is-inside-work-tree' execSync call in
src/storage/git.ts, but the matching strict-shape assertion in
git.test.ts:31-34 still expected the pre-patch two-key options
object {cwd, stdio}. vitest's toHaveBeenCalledWith does a deep
structural match, so the extra third key flipped the assertion
to fail.

Add windowsHide: true to the expected shape. Only this one
assertion is strict; the two siblings ('passes the correct cwd'
and the no-cwd-arg case) use expect.objectContaining and
expect.any(String) and remain green without modification.

* test(setup-codex): include windowsHide in execFile shape assertions

Same root cause as the git.test.ts fix on this branch: the windowsHide
patch added windowsHide:true to the execFile() options in
src/cli/setup.ts, but three strict-shape toHaveBeenCalledWith
assertions in setup-codex.test.ts still expected the pre-patch
{shell:true} / {shell:false} two-key options. vitest does a deep
structural match, so the extra key flipped the assertions to fail
on every CI matrix leg (ubuntu coverage + macos + windows).

Adding windowsHide:true alongside the existing 'shell' key in
all three sites.

* ci: retrigger checks

go-parity failed on a flaky onnxruntime-node postinstall network timeout
(AggregateError [ETIMEDOUT] in node ./script/install), which cascaded into
the CI Gate. No code change — empty commit to re-run the pipeline.

* fix(test): strengthen windowsHide regression assertions (PR #1794 review)

- Replace toBeGreaterThanOrEqual with exact toBe per DoD §2.7
- Remove unused `m` variable in countSpawnCalls (CodeQL finding)
- Add windowsHide: true to runGit test helper for consistency

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: ManniX-ITA <35522085+ManniX-ITA@users.noreply.github.com>
Co-authored-by: Test <test@example.com>
2026-05-24 09:51:21 +01:00
Abhigyan Patwari
ec4624af87
fix(hooks): cap concurrent augment subprocesses (#1486) (#1510)
* fix(hooks): cap concurrent augment subprocesses to prevent runaway process spawn (#1486)

When Claude Code fires PreToolUse hooks for parallel Grep/Glob/Bash tool
calls, each invocation spawned its own `gitnexus augment` subprocess —
a Node + LadybugDB cold start that holds resources for several seconds.
Under heavy parallel search load (issue #1486: 180+ piled-up processes,
load avg > 100), these accumulated faster than they completed because
nothing capped concurrent in-flight augments.

Add a lockfile-based concurrency guard under `<.gitnexus>/.hook-locks/`:
each running hook claims a `<pid>.lock`, the guard counts live PIDs and
prunes stale entries (>30s mtime or pid no longer alive), and bails
silently when MAX_INFLIGHT (3) is reached. Augment is best-effort
enrichment — missing a few fires under burst load is preferable to
melting the system.

Applied to all three hook variants that spawn augment:
- gitnexus/hooks/claude/gitnexus-hook.cjs (npm-installed Claude hook)
- gitnexus-claude-plugin/hooks/gitnexus-hook.js (plugin Claude hook)
- gitnexus-cursor-integration/hooks/gitnexus-hook.cjs (Cursor hook)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hooks): make augment concurrency cap a hard cap via atomic slot files

Address Claude's review of #1510. The original count-then-claim guard had
a TOCTOU window: N hooks could each read `active < MAX_INFLIGHT` between
readdirSync and the per-pid `wx` write and all proceed, briefly exceeding
the cap. The PR title's "cap" language overstated this.

Replace with fixed-name `slot-0.lock` ... `slot-N.lock` under `.hook-locks/`.
`O_CREAT|O_EXCL` on a fixed path is OS-atomic — exactly one process wins
each slot, so the cap is hard regardless of burst arrival timing. Each
slot file contains the owning PID so stale-takeover still works when a
hook crashes without releasing.

PID liveness is checked before age (Claude's Finding 3): a slow-but-alive
hook is never wrongly evicted. The 30s age window only kicks in to defend
against PID reuse on a long-abandoned slot, well above the 7s augment
timeout so a healthy run never hits it.

Also adds the missing concurrency-guard tests to cursor-hook.test.ts
(Claude's Finding 2): source-level wiring + dead-PID reclaim + 3-slots-full
bail. Previously only the CJS and Plugin variants had test coverage for
the guard; the Cursor variant was validated only by code inspection.

Tests: 5726 passing, +9 from baseline (1 hard-cap burst test + 4 source
regressions in hooks.test.ts; 3 source + 2 integration in cursor-hook.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hooks): inspect slot mtime + content via single fd (codeql TOCTOU)

CodeQL flagged the stale-takeover path in acquireHookSlot as a potential
filesystem race (js/file-system-race): statSync(slotPath) followed by
readFileSync(slotPath) gives a TOCTOU window where the file could be
swapped between the metadata check and the content read.

Replace the two separate path-based calls with a single openSync + fstatSync
+ readSync + closeSync sequence. Both mtime and owner PID now come from the
same file descriptor, so the operations are atomic on one inode. No
behavioral change beyond closing the race.

Applied to all three hook variants (CJS, Plugin, Cursor).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hooks): distinguish EPERM from ESRCH in PID liveness check

Cursor Bugbot caught a contradiction with the stated design: the bare
`catch` after `process.kill(owner, 0)` was treating EPERM (process exists
but owned by another user) the same as ESRCH (process gone), which would
evict a live slot whenever the lock dir straddled user boundaries.

Inspect the error code: ESRCH → dead, evict; EPERM → still alive, keep
the slot; anything else → assume alive (be conservative under unexpected
failure rather than over-evict).

Applied to all three hook variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hooks): fail closed when lock dir cannot be created

Previously the mkdirSync catch in acquireHookSlot returned `() => {}`
(a truthy no-op). The caller checks `if (!release) return;` to skip
augment when the guard can't be established — but a truthy no-op
slipped through that check and let augment spawn unguarded. On a
cross-user shared `.gitnexus/` or read-only filesystem, N concurrent
hooks would each take that branch and reintroduce the #1486 fan-out
the guard exists to prevent.

Return `null` instead so the caller's `if (!release) return;` skips
augment cleanly. Augment is best-effort enrichment — skipping it when
the guard fails is strictly safer than running unguarded.

Also clarify the stale-slot comment: PID-liveness wins for slots
younger than HOOK_LOCK_STALE_MS, but age is the final arbiter beyond
30s (PID-reuse defense). The previous wording said "PID-liveness wins
over age" without qualifying it, which contradicted the >30s branch.

Add source-level regression tests in hooks.test.ts and
cursor-hook.test.ts asserting acquireHookSlot returns null (not
() => {}) on lock-dir failure. Note in the Cursor test file that the
10-spawner burst test is not duplicated because the algorithm is
byte-for-byte identical to the CJS hook and already covered there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(hooks): extract lock guard into helper modules

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/04dd20c5-28fd-433a-83cf-ad83fd03fb32

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-13 08:56:27 +01:00
Abhigyan Patwari
2620b704e0
feat(cursor): upgrade hooks to Cursor 2.4 postToolUse for Read/Grep/Shell coverage (#1467)
* feat(cursor): upgrade hooks to Cursor 2.4 postToolUse for Read/Grep/Shell coverage

Cursor 2.4 (released 2026-01-22) shipped generic preToolUse/postToolUse hooks
matching `Shell|Read|Write|Grep|Delete|Task|MCP:<tool>`, replacing the
2.3-era beforeShellExecution hook that only fired on shell commands. The
existing integration only intercepted the shell path, so Cursor users got
graph augmentation roughly 10% as often as Claude Code users — only when
the agent dropped to rg/grep instead of using its native Read/Grep tools.

This swaps the integration over to postToolUse and ports the bash+jq
hook script to cross-platform Node:

- gitnexus-cursor-integration/hooks/hooks.json: registers a single
  postToolUse hook matching Shell|Read|Grep that invokes the new
  gitnexus-hook.cjs.
- gitnexus-cursor-integration/hooks/gitnexus-hook.cjs: new Node hook
  mirroring the safety patterns from the Claude hook (absolute-cwd
  validation, .gitnexus discovery with linked-worktree fallback,
  npx.cmd on Windows, end-of-options `--` marker, debug truncation,
  graceful failure). Extracts the search pattern per tool kind:
  Grep -> toolInput.query; Read -> file basename stripped to identifier
  chars; Shell -> existing rg/grep arg parser. Emits Cursor-shape
  `{ "additional_context": "..." }` on stdout — no shell, no jq.
- gitnexus-cursor-integration/hooks/augment-shell.sh: removed (Windows
  incompatible, narrower coverage).
- gitnexus/test/unit/cursor-hook.test.ts: 33 regression tests covering
  manifest wiring, source-level invariants (no shell:true, npx.cmd,
  isAbsolute, additional_context output shape, end-of-options marker),
  extractPattern coverage per tool, and behavioral early-exit paths
  (empty/invalid stdin, relative cwd, no .gitnexus, unknown tool name,
  short patterns, non-search shell commands, case-insensitive matching).
- README.md / gitnexus/README.md: editor-support table now lists Cursor
  as Full / hooks=Yes (postToolUse), matching reality.
- gitnexus/src/cli/augment.ts and gitnexus/src/core/augmentation/engine.ts:
  doc-strings updated from `Cursor beforeShellExecution` to
  `Cursor postToolUse`.

Closes #1466.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cursor): hook timeout is in seconds, not milliseconds

Cursor's `timeout` field in hooks.json is in seconds (per
https://cursor.com/docs/agent/hooks and the original integration's
`"timeout": 5`). I'd written `10000` after blindly copying the issue
body's example — that resolves to ~2.8 hours, not 10 seconds. If the
script ever hangs before reaching its inner spawnSync timeouts (e.g.
during stdin read), Cursor would have waited that long before killing
it.

Drop to `10` (seconds), matching the Claude plugin's hooks.json and
giving plenty of headroom over the inner 7s augment-CLI timeout.

Add a regression-guard assertion in cursor-hook.test.ts so a future
ms/s mixup fails fast.

Reported by Cursor Bugbot on PR #1467.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cursor): address Claude review findings — payload aliases, debug, install docs

Resolves three findings from Claude reviewer on PR #1467:

1. Cursor payload field-name uncertainty (SIGNIFICANT)
   Claude flagged that the Grep `query` field is an unverified assumption
   per Cursor 2.4 docs (https://cursor.com/docs/agent/hooks). Mitigated:
   - Expanded Grep aliases: query | pattern | regex | q | search | searchQuery
   - Added pickLongestStringValue() last-resort fallback so the hook
     extracts *something* even if Cursor renames every documented field
   - Added GITNEXUS_DEBUG=1 stderr logging of the raw stdin payload so
     users can capture Cursor's actual contract when diagnosing silent
     no-ops, and report it back if aliases drift
   - Added Read alias `filePath` (camelCase variant alongside `file_path`)
   - Inline comment block citing the docs URL and the uncertainty

2. Hook command path resolution + install docs (SIGNIFICANT)
   Claude flagged `node ./hooks/gitnexus-hook.cjs` as relative without
   documented install path. Added gitnexus-cursor-integration/README.md
   with explicit install steps:
   - .cursor/hooks.json + hooks/gitnexus-hook.cjs at project root
   - Confirms Cursor's project-root CWD convention with doc link
   - Verify steps including GITNEXUS_DEBUG capture
   - Pattern-extraction contract table per tool
   - Troubleshooting: not-firing, npx fallback, wrong-pattern diagnosis

3. README "Full" overclaim for Cursor (MODERATE)
   Both README rows now read `Yes (postToolUse, manual install)` linking
   to the new install README, accurately signaling that hooks aren't
   automated by `gitnexus setup` like they are for Claude Code.

4. Shell quoted-pattern parser limitation (MINOR, documented)
   Added inline comment in gitnexus-hook.cjs documenting the known
   `rg "User Service"` -> `User` truncation, plus regression tests in
   cursor-hook.test.ts pinning the behavior so a future change is
   visible.

Test additions (33 -> 41):
- Wide-alias source coverage for Grep (query / pattern / regex / q /
  search / searchQuery) plus pickLongestStringValue fallback
- Read alias coverage including camelCase filePath
- GITNEXUS_DEBUG behavioral test: stderr quiet by default, payload
  echoed when env var set, stdout output contract preserved either way
- Shell quoted-pattern documented behavior tests
- Install README presence + content (.cursor/hooks.json, hooks/, debug
  diagnostics)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-10 13:29:06 +01:00
abhigyanpatwari
eca55aacd7 fixed resource count multiplying issue ( using resource templates now ) 2026-02-13 21:28:36 +05:30