fix(observe,skills): gitignore the local state the CLI writes; make the Codex hook portable
Problem
insta project create|link (and insta observe install) leaves three things for the user to discover in git status:
.insta/observe/— a copy of this CLI version's hook + scanner, regenerated on every link.codex/hooks.json— with the absolute project path baked into the hook command, so committing it ships/Users/<author>/…to every clone, where it fails after each tool callskills-lock.json— written bynpx skills add; its payload (the skill dirs) was already ignored, the lock itself was not
Only the three skill dirs were ever added to .gitignore. Nothing ignored .insta/audit.jsonl either, and that file holds partial secret fingerprints plus redacted context (hostnames, usernames around the masked span).
Change
- One helper,
src/gitignore.ts→ensureGitignore(cwd, entries, comment), used by both installers (moved out ofensure-skills.ts, re-exported there). Rule: the step that writes a regenerable or machine-local file adds its ignore entry, exactly asinsta secretsalready does for.env. - observe install ignores
.insta/observe/and.insta/audit.jsonl. Deliberately not.insta/wholesale:project.jsonis the team binding the skill tells users to commit. - skills install also ignores
skills-lock.json, and only touches.gitignorewhen at least oneskills addsucceeded. The lock pins only a content hash, not the prod/staging source this CLI resolves per environment, andinsta project linkis the restore path, so a committed lock is a lockfile for nothing. - Already-tracked paths get a hint. An ignore entry does nothing for a file git already tracks, and the repos that most need these entries are the ones that committed
audit.jsonlorskills-lock.jsonbefore the CLI ignored them. Both installers now print the onegit rm -r --cached …line that fixes it (alreadyTracked/untrackHintingitignore.ts). - Codex hook becomes one shell-neutral
node -escript that climbs from the session cwd to the nearest.insta/observe/hook.js(the same walkfindProjectRootdoes) and runs it with stdin passed through; nothing found → silent no-op. No absolute path, no POSIX syntax, no git forks. The script is kept free of every character sh or cmd.exe rewrites inside double quotes ($`%!") and a test asserts that, so nocommandWindowsoverride is needed..codex/hooks.jsonis now shareable (Codex still has each user trust it before it runs). - Both commands print
.gitignore += …for what they added; re-runs add nothing.
What is committed vs ignored after this
| File | Verdict |
|---|---|
.insta/project.json | commit (team binding) |
.claude/settings.json hook entry | commit (portable, guarded) |
.codex/hooks.json hook entry | commit (now portable, shell-neutral) |
.insta/observe/, .insta/audit.jsonl | ignored |
.claude/skills/, .agents/skills/, .github/skills/, skills-lock.json | ignored |
Existing projects pick the entries up on their next insta project link / insta observe install (idempotent).
Review round 1 → round 2
Both Criticals were on the first Codex entry (git rev-parse --show-toplevel ≠ insta project root in a monorepo → hook silently dead; POSIX guard handed to cmd.exe on Windows → failure after every tool call). Fixed by the shell-neutral climb above; the new tests run the command through the platform shell (shell: true), so the Windows CI job exercises the real form: nested monorepo project from the project dir and a subdirectory, git root above the project, fresh clone, stdin passthrough, character-set assertion.
Suggestions taken: #3 already-tracked hint, #4 no git forks (moot with the new form), #5 nested-project test, #6 gate the skills ignore on a successful add.
Round 2 → round 3
Critical: finding the right hook.js from a nested session cwd was only half of it — the hook then recorded into CLAUDE_PROJECT_DIR || event.cwd, and Codex's event.cwd is the session cwd, so apps/api/src/routes/.insta/audit.jsonl appeared, unignored. Fixed in the hook itself: the materialized entry lives at <root>/.insta/observe/hook.js, so projectRootFor derives the root from its own entry path and only falls back to the harness env / event cwd when not running from a materialized location. observe report|sync read the audit from findProjectRoot() so they work from any subdirectory too. Test: the real hook source (loaded via tsx through NODE_OPTIONS, no build step) driven by the generated Codex command from apps/api/src/routes with a Codex-shaped event carrying a DB password appends a redacted finding to apps/api/.insta/audit.jsonl and writes nothing at the session cwd.
Recorded decisions on the informational items:
- #7 line-equality matching — kept. A redundant entry next to a user's
*.jsonlor.insta/observe(no slash) is harmless, and ignore-semantics matching would need a real gitignore engine for no user-visible gain. - #8 uninstall leaves the entries — deliberate, now stated in a comment: a stale
audit.jsonlshould stay ignored afterobserve uninstall. - #9
.gitignorewritten outside a git repo — kept as the pre-existing skills-path behaviour; settling it for both installers andinsta secretstogether is the follow-up below, not this PR.
Round 3 (clean) → final head
Both bots approved at 38743c3 with no Criticals. Taken from r2d2's suggestions: #1 a test for the report-root change, and #2 auditRoot() — report/sync anchor on the link file, else on the materialized hook (a standalone insta observe install in an unlinked directory has no project.json), else cwd, so both halves agree from any subdirectory.
Recorded declines (non-blocking, kept as is):
- #3
installed === 0skips ignore maintenance on an offline re-run — accepted trade-off. The alternative prints.gitignore += …for files that may not exist; the entries land on the next successful link, which is also when the files are (re)written. - #4 bound the ancestor climb — the climb stops at the first
.insta/observe/hook.js, and the hook only runs after the user has trusted the project's.codex/hooks.json, which lists no path at all. A world-writable/tmp/.insta/observe/hook.jsrequires filesystem write access the attacker already has; a depth cap would trade the monorepo fix for hardening against that. Noted as a follow-up if the threat model changes. - #5 second node process (~+44 ms per tool call) — inside the 15 s timeout; an in-process
import()couples the wrapper to the hook's module format and error handling. Follow-up if it ever shows up in practice. - #6
shell: trueuses/swhile Codex usescmd /C— the pre-PR Windows command (node "C:\…\hook.js") had the same one-double-quoted-argument shape and worked, so the quote regime is unchanged by this PR. Manual Windows check noted for the release. - #7 legacy absolute-path Codex entry migration relies on
_instamarker — the marker is written by every version that ever produced that entry, so it is the intended key; theobserve/hook.substring is only a belt-and-braces fallback.
Verification
npm run typecheckclean;vitest710/710 (8 new tests across the two files).- Built CLI run in a throwaway git repo:
git statusafterwards shows only.codex/and.gitignore; second run prints no.gitignore +=line.
Not in this PR (follow-ups): insta secrets only ignores .env when .git is in cwd (misses pre-git init and subdirectory runs) and the installers do the opposite — pick one rule for all three; ~/.insta/config.json is written 0644 with tokens inside; source deploy has no .env-in-build-context preflight; audit.jsonl stores the last 4 chars of each secret (the hash prefix alone would do for dedup).
🤖 Generated with Claude Code