diff --git a/.claude/skills/arch-init/SKILL.md b/.claude/skills/arch-init/SKILL.md index 26cacde9d..d9df3cc68 100644 --- a/.claude/skills/arch-init/SKILL.md +++ b/.claude/skills/arch-init/SKILL.md @@ -42,6 +42,9 @@ name in a multi-architect workspace). state. - The state file is authoritative free text. It typically opens with a role banner and may carry resume instructions; follow whatever it says. + - Architect state files are per-person and gitignored + (`codev/state/*.md`); never commit them. Builder `*_thread.md` files + are the opposite: versioned, shipping with each builder PR. 3. **Confirm identity + orient, then follow the state file.** In one tight block, report: who you now are (name + one-line role from the banner, if diff --git a/.gitignore b/.gitignore index 71753918d..d3c06dea5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ worktrees/ .architect-role.md .codev/config.json +# Architect state files are per-person; builder *_thread.md files ARE versioned (#1192) +codev/state/*.md +!codev/state/*_thread.md + # OS files .DS_Store diff --git a/codev-skeleton/.claude/skills/arch-init/SKILL.md b/codev-skeleton/.claude/skills/arch-init/SKILL.md index 26cacde9d..d9df3cc68 100644 --- a/codev-skeleton/.claude/skills/arch-init/SKILL.md +++ b/codev-skeleton/.claude/skills/arch-init/SKILL.md @@ -42,6 +42,9 @@ name in a multi-architect workspace). state. - The state file is authoritative free text. It typically opens with a role banner and may carry resume instructions; follow whatever it says. + - Architect state files are per-person and gitignored + (`codev/state/*.md`); never commit them. Builder `*_thread.md` files + are the opposite: versioned, shipping with each builder PR. 3. **Confirm identity + orient, then follow the state file.** In one tight block, report: who you now are (name + one-line role from the banner, if diff --git a/codev/plans/1192-gitignore-architect-state-file.md b/codev/plans/1192-gitignore-architect-state-file.md new file mode 100644 index 000000000..6a43ad450 --- /dev/null +++ b/codev/plans/1192-gitignore-architect-state-file.md @@ -0,0 +1,183 @@ +# PIR Plan: Gitignore Architect State Files Across init/adopt/update/doctor + +## Understanding + +PR #1136 shipped `/arch-init`, which reads and offers to create architect state +files at `codev/state/.md`. These files are per-person: every team member +has their own `main` architect, so a committed `main.md` collides across +members. Codev currently takes no stance on their versioning: they are neither +tracked nor gitignored (`git check-ignore codev/state/main.md` exits 1 in this +repo today). + +Builder thread files (`codev/state/_thread.md`) have the opposite lifecycle +by design: written in the builder worktree, committed with the PR, landing on +main at merge. Any ignore rule must preserve that split. The rule pair is: + +``` +codev/state/*.md +!codev/state/*_thread.md +``` + +Order matters: in gitignore, the last matching rule wins, so the negation must +come after the ignore pattern. + +The good news structurally: all three scaffold commands already flow through a +single constant, so the rule pair lands in one place. + +- **init** — `packages/codev/src/commands/init.ts:137` calls + `createGitignore`, which writes `FULL_GITIGNORE_CONTENT` (built from + `CODEV_GITIGNORE_ENTRIES`). +- **adopt** — `packages/codev/src/commands/adopt.ts:197` calls + `updateGitignore`, which either creates the file from + `CODEV_GITIGNORE_ENTRIES` or delegates to the line-level backfill. +- **update** — `packages/codev/src/commands/update.ts:295` calls + `backfillGitignore(targetDir, CODEV_GITIGNORE_ENTRIES, ...)`, appending only + the missing lines under a dated `# Codev (added by codev update ...)` header. +- **doctor** — `packages/codev/src/commands/doctor.ts` has no gitignore + awareness today; it needs a new check. The established pattern is an audit + helper in `lib/` (`auditPrGates`, `auditFrameworkRefs`) surfaced as warnings + in `doctor()`. + +`backfillGitignore` (`packages/codev/src/lib/gitignore.ts:112-151`) is +append-only, line-level, and preserves the order of entries within the source +block, so the pair appends in the correct order for existing installs. + +## Proposed Change + +### 1. Core rule pair: `packages/codev/src/lib/gitignore.ts` + +Append two lines to `CODEV_GITIGNORE_ENTRIES` (lines 18-24), keeping the +ignore rule before the negation: + +``` +codev/state/*.md +!codev/state/*_thread.md +``` + +This single edit covers init (fresh `.gitignore`), adopt (create-or-backfill), +and update (dated backfill for existing installs). No changes needed in +`init.ts`, `adopt.ts`, or `update.ts` themselves. + +### 2. Doctor audit: new helper + wiring + +Add `auditStateFileIgnore(workspaceRoot)` to +`packages/codev/src/lib/gitignore.ts`, returning a list of warning strings. +Checks, in order: + +1. **Not a git repo** (`git rev-parse --is-inside-work-tree` fails) → return + no findings (nothing to audit). +2. **Ignore rule missing or ineffective** — probe with + `git check-ignore -q codev/state/__doctor-probe__.md` (check-ignore works on + paths that don't exist). Non-ignored → warn: architect state files are not + gitignored; run `codev update` (backfills the rule) or add + `codev/state/*.md` + `!codev/state/*_thread.md` to `.gitignore`. Probing + git behavior rather than string-matching `.gitignore` means a user's + equivalent hand-written rules pass the check. +3. **Thread files accidentally swallowed** — probe + `git check-ignore -q codev/state/__doctor-probe___thread.md`. If ignored → + warn: builder thread files must stay versioned (their ship-with-the-PR + lifecycle breaks otherwise); the `!codev/state/*_thread.md` negation is + missing or shadowed. +4. **Architect state file already tracked** — `git ls-files codev/state/*.md` + filtered to exclude `*_thread.md`. Any hit → warn per file, recommending + `git rm --cached ` (pre-existing installs may have committed one + before the rule existed; gitignore has no effect on already-tracked files). + +Wire it into `doctor.ts` inside `checkCodevStructure` +(`packages/codev/src/commands/doctor.ts:475-492`), which already aggregates +project-structure warnings and runs git commands (`checkGitRemote`). Each +finding prints as a `⚠` line and joins the end-of-run warning summary, matching +existing behavior. + +### 3. This repo's own `.gitignore` + +Add the rule pair with a short comment. Verified: only `*_thread.md` files are +tracked under `codev/state/` in this repo, so no `git rm --cached` migration is +needed; the untracked `codev/state/main.md` in the main checkout becomes +ignored the moment this merges. + +### 4. Documentation: `/arch-init` skill in both trees + +The skill is the surface that promotes state files into a shipped concept, so +it should state the versioning stance. Add one short note to its state-file +section (step 2, "Read your state file"): architect state files are per-person +and gitignored (`codev/state/*.md`); do not commit them — unlike builder +`*_thread.md` files, which are versioned and ship with PRs. + +Files (kept byte-identical to each other where they already are): +- `.claude/skills/arch-init/SKILL.md` +- `codev-skeleton/.claude/skills/arch-init/SKILL.md` + +## Files to Change + +- `packages/codev/src/lib/gitignore.ts:18-24` — add the two lines to + `CODEV_GITIGNORE_ENTRIES`; add `auditStateFileIgnore()` helper. +- `packages/codev/src/commands/doctor.ts:475-492` — call + `auditStateFileIgnore` from `checkCodevStructure`, append findings to its + warnings. +- `.gitignore` — add the rule pair under a short comment. +- `.claude/skills/arch-init/SKILL.md` — one-note versioning stance. +- `codev-skeleton/.claude/skills/arch-init/SKILL.md` — same note. +- `packages/codev/src/__tests__/gitignore.test.ts` — extend existing suites; + add `auditStateFileIgnore` suite (temp dir + `git init`). + +## Risks & Alternatives Considered + +- **Risk: backfill appends the ignore rule after a pre-existing negation.** + `backfillGitignore` is line-level; if a user's `.gitignore` already contained + `!codev/state/*_thread.md` but not `codev/state/*.md` (a state no Codev + tooling ever produces — the negation alone is a no-op), the appended ignore + rule would land after the negation and re-ignore thread files. Mitigation: + the doctor thread-file probe (check 3) catches exactly this ordering bug and + warns. Not worth complicating the append-only backfill for a self-inflicted + edge case. +- **Risk: `codev/state/*.md` is broader than architect files.** Any future + non-thread `.md` dropped in `codev/state/` gets ignored by default. That is + the intended stance per the issue (per-person by default; versioned files + must opt in via a negation, as `*_thread.md` does). +- **Alternative: string-match `.gitignore` in doctor instead of + `git check-ignore`.** Rejected — probing git's actual behavior validates + rule ordering and honors user-written equivalent rules; string matching + false-positives on both. +- **Alternative: scope the doc note into CLAUDE.md/AGENTS.md templates.** + Rejected for this PR — the discovery text there + (`codev-skeleton/templates/CLAUDE.md:142`) describes thread files only and + stays correct as-is. The `/arch-init` skill is where the architect state + file concept lives. + +## Test Plan + +Extend `packages/codev/src/__tests__/gitignore.test.ts`: + +- **Constant**: `CODEV_GITIGNORE_ENTRIES` contains both lines, with + `codev/state/*.md` appearing before `!codev/state/*_thread.md`. +- **createGitignore (init)**: generated file contains both lines. +- **updateGitignore (adopt)**: partial-block backfill adds both lines. +- **backfillGitignore (update)**: a pre-#1192 `.gitignore` gains exactly the + two lines under the dated header; idempotent on second run. +- **End-to-end pattern validity** (new): in a temp dir with `git init` and the + generated `.gitignore`, assert `git check-ignore codev/state/main.md` exits + 0 and `git check-ignore codev/state/x_thread.md` exits non-zero — proving + the pair actually produces the intended split, not just that the text is + present. +- **auditStateFileIgnore** (new suite, temp dir + `git init`): + - no rule → "not gitignored" warning + - rule pair present → no warnings + - negation shadowed (ignore rule after negation) → thread-file warning + - tracked `codev/state/main.md` (committed before the rule) → tracked-file + warning naming the file + - non-git directory → no findings, no crash + +Manual verification at the dev-approval gate: + +- `pnpm --filter @cluesmith/codev build && pnpm --filter @cluesmith/codev test` + from the worktree. +- In a scratch dir: `codev init` (via the built CLI) → inspect `.gitignore`; + `git check-ignore codev/state/main.md` → ignored; + `git check-ignore codev/state/x_thread.md` → not ignored. +- In a scratch repo with a pre-existing partial `.gitignore`: run the built + `codev update` → both lines appended under the dated header. +- Remove the rule → `codev doctor` shows the warning; `git add -f` a fake + `codev/state/main.md` → doctor warns it is tracked. +- In this worktree: `git check-ignore codev/state/main.md` succeeds and + `git status` still shows thread files as trackable. diff --git a/codev/projects/1192-gitignore-architect-state-file/status.yaml b/codev/projects/1192-gitignore-architect-state-file/status.yaml new file mode 100644 index 000000000..dff513d67 --- /dev/null +++ b/codev/projects/1192-gitignore-architect-state-file/status.yaml @@ -0,0 +1,30 @@ +id: '1192' +title: gitignore-architect-state-file +protocol: pir +phase: verified +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: approved + requested_at: '2026-07-19T04:10:22.911Z' + approved_at: '2026-07-19T04:19:10.428Z' + dev-approval: + status: approved + requested_at: '2026-07-19T04:26:28.933Z' + approved_at: '2026-07-20T09:18:31.722Z' + pr: + status: approved + requested_at: '2026-07-20T09:23:28.096Z' + approved_at: '2026-07-21T07:08:05.011Z' +iteration: 1 +build_complete: false +history: [] +started_at: '2026-07-19T04:07:14.007Z' +updated_at: '2026-07-21T07:08:21.995Z' +pr_history: + - phase: review + pr_number: 1213 + branch: builder/pir-1192 + created_at: '2026-07-20T09:21:07.279Z' +pr_ready_for_human: false diff --git a/codev/resources/arch.md b/codev/resources/arch.md index c85d13cda..d9a98f8f6 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -1733,7 +1733,7 @@ Messages sent via `afx send` are not injected immediately — they pass through **Location**: `commands/whoami.ts` (composes `detectCurrentBuilderId`/`detectWorkspaceRoot` from `commands/send.ts` and `lookupBuilderSpawningArchitect` from `state.ts`) -`afx whoami` reports the current terminal's agent identity (workspace, type, name) from Tower/global.db's perspective. Identity precedence is fixed: **builder-worktree cwd match** (canonical id verified against global.db — same resolution `afx send` uses, including the #1094 rule that an unverifiable worktree identity throws rather than falling through) → **`CODEV_ARCHITECT_NAME`** (the Tower-injected architect env var, read directly — NOT via `currentArchitectName()`, whose `main` default is deliberately not used here) → **unknown** (exit 1, no implicit `main`). Strictly read-only against global.db: `lookupBuilderSpawningArchitect(builderId, workspacePath?, db?)` accepts an optional connection so whoami passes its own readonly handle instead of the read-write `getDb()` singleton. Workspace display name comes from `known_workspaces` with directory-basename fallback (informational field only — fail-loud applies to type/name). `--json` emits `{workspace, type, name, architect?}`; failures emit `{"error": ...}` on stdout plus a human explanation on stderr. Works without Tower running. The shipped `/arch-init` skill (`.claude/skills/arch-init/`, mirrored in `codev-skeleton/`) builds on whoami for architect identity adoption + state recovery from `codev/state/.md`. +`afx whoami` reports the current terminal's agent identity (workspace, type, name) from Tower/global.db's perspective. Identity precedence is fixed: **builder-worktree cwd match** (canonical id verified against global.db — same resolution `afx send` uses, including the #1094 rule that an unverifiable worktree identity throws rather than falling through) → **`CODEV_ARCHITECT_NAME`** (the Tower-injected architect env var, read directly — NOT via `currentArchitectName()`, whose `main` default is deliberately not used here) → **unknown** (exit 1, no implicit `main`). Strictly read-only against global.db: `lookupBuilderSpawningArchitect(builderId, workspacePath?, db?)` accepts an optional connection so whoami passes its own readonly handle instead of the read-write `getDb()` singleton. Workspace display name comes from `known_workspaces` with directory-basename fallback (informational field only — fail-loud applies to type/name). `--json` emits `{workspace, type, name, architect?}`; failures emit `{"error": ...}` on stdout plus a human explanation on stderr. Works without Tower running. The shipped `/arch-init` skill (`.claude/skills/arch-init/`, mirrored in `codev-skeleton/`) builds on whoami for architect identity adoption + state recovery from `codev/state/.md`. Architect state files are per-person (every team member has their own `main`, so a committed one collides across the team) and gitignored via `codev/state/*.md`; builder thread files (`codev/state/_thread.md`) have the opposite lifecycle and are versioned via a `!codev/state/*_thread.md` negation, since they ship with each builder's PR (Issue #1192). `codev init`/`adopt`/`update` all source this rule pair from the single `CODEV_GITIGNORE_ENTRIES` constant (`packages/codev/src/lib/gitignore.ts`); `codev doctor` audits the split via `auditStateFileIgnore()`. ## Installation Architecture diff --git a/codev/resources/lessons-learned.md b/codev/resources/lessons-learned.md index f7dea5ef1..9927de952 100644 --- a/codev/resources/lessons-learned.md +++ b/codev/resources/lessons-learned.md @@ -299,6 +299,7 @@ Generalizable wisdom extracted from review documents, ordered by impact. Updated - [From 0755] Export error messages as functions, not inline strings, when tests must assert verbatim spec text. Producer and asserter import the same function — drift between spec and code becomes a failing test rather than a silent regression. Caught a real verbatim-text drift in iter-1 of Phase 3 and made the iter-2 fix mechanical. - [From 0755] Test the public address grammar, not the internal resolver shape. Phase 3 iter-1 tests called `resolveTarget('sibling', ...)` directly, bypassing the `parseAddress` step. That hid a real bug (`architect:` was being misparsed as `project:agent`). Tests of routing logic should send the same string the CLI sends, not the internal data shape. - [From 0755] A CI guardrail test for "this access pattern should never reappear" is cheap insurance for sweep-style refactors. The `entry.architect` (singular) grep test took 30 minutes to write and would catch any future re-introduction. Worth doing whenever a sweep removes a long-lived pattern. +- [From #1192] When auditing config-driven behavior (gitignore rules, etc.), probe the tool's actual resolved decision (`git check-ignore` on a phantom path) rather than string-matching the config file. String matching misses rule-ordering bugs (a negation shadowed by a later conflicting rule) and false-positives on a user's equivalent-but-differently-worded rule. Applies to `codev doctor`'s `auditStateFileIgnore` and generalizes to any "is this configuration actually in effect" check. ## UI/UX diff --git a/codev/reviews/1192-gitignore-architect-state-file.md b/codev/reviews/1192-gitignore-architect-state-file.md new file mode 100644 index 000000000..f9108a26c --- /dev/null +++ b/codev/reviews/1192-gitignore-architect-state-file.md @@ -0,0 +1,68 @@ +# PIR Review: Gitignore Architect State Files Across init/adopt/update/doctor + +Fixes #1192 + +## Summary + +Architect state files (`codev/state/.md`, read/written by `/arch-init` from PR #1136) are per-person and were neither tracked nor gitignored, risking cross-team collisions if ever committed. This PR adds the ignore rule pair `codev/state/*.md` / `!codev/state/*_thread.md` to the single constant all three scaffold commands (`init`, `adopt`, `update`) already share, so the fix lands in all three at once, and adds a new `codev doctor` audit (`auditStateFileIgnore`) that verifies the split is actually in effect by probing real `git check-ignore` behavior rather than string-matching `.gitignore` text — which also catches an already-tracked architect state file and a shadowed-negation ordering bug that string matching would miss. + +## Files Changed + +- `packages/codev/src/lib/gitignore.ts` (+88) — rule pair added to `CODEV_GITIGNORE_ENTRIES` via two named constants (`STATE_IGNORE_RULE`, `THREAD_KEEP_RULE`), new `auditStateFileIgnore()` function +- `packages/codev/src/commands/doctor.ts` (+4) — wires the audit into `checkCodevStructure`'s existing warnings list +- `packages/codev/src/__tests__/gitignore.test.ts` (+128) — new suites for the rule pair, an end-to-end `git check-ignore` validity test, and 5 `auditStateFileIgnore` cases +- `packages/codev/src/__tests__/doctor.test.ts` (+24) — extended the "properly migrated" mock to answer the new `git check-ignore`/`ls-files` calls realistically +- `packages/codev/src/__tests__/update.test.ts` (+4) — updated two pre-existing #880 regression tests whose `gitignoreAdded` expectations were exact-array-equality and needed the two new entries appended +- `.gitignore` (+4) — this repo's own gitignore gained the rule pair +- `.claude/skills/arch-init/SKILL.md` / `codev-skeleton/.claude/skills/arch-init/SKILL.md` (+3 each, byte-identical) — one-line versioning-stance note +- `codev/plans/1192-gitignore-architect-state-file.md`, `codev/state/pir-1192_thread.md` — plan + builder thread log + +## Commits + +- `573b2e79` [PIR #1192] Plan draft +- `5fde36cf` [PIR #1192] Gitignore architect state files across init/adopt/update, audit in doctor +- `c867ce14` [PIR #1192] Repo gitignore rule pair and arch-init versioning note (both trees) +- `7638b065` [PIR #1192] Single-source the state-file gitignore rule pair + +## Test Results + +- `pnpm --filter @cluesmith/codev build`: ✓ pass +- `pnpm --filter @cluesmith/codev test`: ✓ pass (3528 passed, 48 skipped, 0 failed; 8 new tests) +- `npx tsc --noEmit`: ✓ pass +- Manual verification (dev-approval gate): + - Built CLI `codev init` in a scratch dir → `.gitignore` contains the rule pair; `git check-ignore codev/state/main.md` ignored, `git check-ignore codev/state/x_thread.md` not ignored. + - Built CLI `codev update` against a pre-#1192 `.gitignore` → appends exactly the two lines under a dated header. + - Compiled `auditStateFileIgnore()` exercised directly against scratch git repos: emits the "not gitignored" warning when the rule is absent, and the "tracked by git" warning (naming the file, recommending `git rm --cached`) when an architect state file was force-added. + - In this worktree: `git check-ignore codev/state/main.md` succeeds; `git check-ignore codev/state/pir-1192_thread.md` does not — confirming the split is live here too. + +## Architecture Updates + +Added to `codev/resources/arch.md`, appended to the existing `/arch-init` paragraph in **Agent Farm Internals** (the section already described `codev/state/.md` state recovery, so the versioning fact belongs right next to it): the per-person/gitignored vs. builder-thread/versioned split, which constant sources the rule pair, and which function audits it. + +No `arch-critical.md` (HOT tier) change — the file is at its 10-fact cap, and this fact is narrow enough (specific to the `codev/state` directory and `/arch-init`) that it doesn't warrant displacing an existing cross-cutting fact. The hot-tier map already points readers to "Agent Farm Internals" for state/terminal/messaging topics, which now covers this detail too. + +## Lessons Learned Updates + +Added to `codev/resources/lessons-learned.md` under **Testing**: probe the tool's actual resolved behavior (`git check-ignore` on a phantom path) rather than string-matching config text when auditing config-driven behavior — string matching misses rule-ordering bugs (a negation shadowed by a later conflicting rule) and false-positives on a user's equivalent-but-differently-worded rule. This generalizes beyond gitignore to any "is this configuration actually in effect" check, so it's cold-tier reference rather than a hot-tier behavior-changer. + +No `lessons-critical.md` (HOT tier) change, for the same cap/narrowness reasoning as above. + +## Things to Look At During PR Review + +- **`STATE_IGNORE_RULE`/`THREAD_KEEP_RULE` single-sourcing** (`gitignore.ts:16-37`): these were originally duplicated as separate literals inside `auditStateFileIgnore`'s warning strings and in `CODEV_GITIGNORE_ENTRIES`. Refactored during dev-approval review to define them once and have `CODEV_GITIGNORE_ENTRIES` interpolate them, removing the duplicate declaration. Worth a second look that the constants are exported cleanly and nothing else in the file still hardcodes the literal strings. +- **Why `git check-ignore` over string-matching** (`gitignore.ts:181-232`): discussed at length at the dev-approval gate — string-matching each line of `CODEV_GITIGNORE_ENTRIES` for presence in `.gitignore` would miss the shadowed-negation ordering bug (the `warns when a later rule shadows the thread-file negation` test case) and false-positive on a user's differently-worded-but-equivalent rule. Kept as probing real git behavior; the reasoning is captured as a lessons-learned entry above. +- **Scope boundary**: the audit only checks the two rules this issue introduces, not the other five entries in `CODEV_GITIGNORE_ENTRIES` (`.agent-farm/`, `.consult/`, etc.) — those already self-heal silently via `codev update`/`adopt` and have a materially lower failure cost (clutter, not a per-person collision hazard). A general "verify all Codev gitignore entries" audit was intentionally left as a possible separate follow-up, not folded into this PR. + +## How to Test Locally + +- **View diff**: VSCode sidebar → right-click builder `pir-1192` → **Review Diff** +- **Run dev**: `afx dev pir-1192` (not applicable here — this is a CLI-only change with no dev server) +- **What to verify**: + - `cd packages/codev && pnpm build && pnpm test` — build and full suite green + - In a scratch dir, run the built `codev init`, then `git init` and confirm `git check-ignore codev/state/main.md` succeeds while `git check-ignore codev/state/x_thread.md` fails + - Run the built `codev update` against a `.gitignore` missing the rule pair — confirm it backfills both lines under a dated header + - Run the built `codev doctor` in a repo missing the rule — confirm the new warning appears; force-add a fake `codev/state/main.md` and confirm doctor flags it as tracked + +## Flaky Tests + +None encountered. diff --git a/codev/state/pir-1192_thread.md b/codev/state/pir-1192_thread.md new file mode 100644 index 000000000..b5b330f46 --- /dev/null +++ b/codev/state/pir-1192_thread.md @@ -0,0 +1,46 @@ +# pir-1192 thread — Gitignore architect state files + +## 2026-07-19 — Plan phase + +Investigated the codebase for issue #1192 (gitignore `codev/state/.md` +architect state files across init/adopt/update/doctor). + +Key findings: +- All three scaffold commands share one constant: `CODEV_GITIGNORE_ENTRIES` in + `packages/codev/src/lib/gitignore.ts`. init uses `createGitignore` (full + content), adopt uses `updateGitignore` (line-level backfill), update uses + `backfillGitignore` with the same constant. Adding the two lines there covers + all three commands at once. +- doctor has no gitignore awareness today; it has a `checkCodevStructure` + warnings pattern plus audit helpers in `lib/` (`auditPrGates`, + `auditFrameworkRefs`) to model a new state-file audit after. +- This repo's own `.gitignore` has no `codev/state` rules; `git check-ignore + codev/state/main.md` exits 1 (not ignored). Only `*_thread.md` files are + tracked in `codev/state/` — no architect state file has been committed, so + no `git rm --cached` migration is needed here. +- The `/arch-init` skill exists in both trees (`.claude/skills/arch-init/` and + `codev-skeleton/.claude/skills/arch-init/`) and is the doc surface that + promotes the state-file convention; it should mention the versioning stance. + +Plan written to `codev/plans/1192-gitignore-architect-state-file.md`. Sitting +at the plan-approval gate. + +## 2026-07-19 — Implement phase + +Plan approved without changes. Implemented as planned: + +- Rule pair added to `CODEV_GITIGNORE_ENTRIES` (covers init/adopt/update in + one edit); new `auditStateFileIgnore()` in `lib/gitignore.ts` probing real + git behavior via `git check-ignore` on phantom paths; wired into doctor's + `checkCodevStructure`. +- Repo `.gitignore` gained the pair; verified in-worktree: + `git check-ignore codev/state/main.md` → ignored, + `codev/state/pir-1192_thread.md` → not ignored. +- `/arch-init` SKILL.md versioning note added, mirrored byte-identical into + the skeleton copy. +- Tests: new suites for the constant order, backfill pair, end-to-end + check-ignore split, and the audit (5 cases incl. shadowed negation and + tracked-file migration warning). Two pre-existing #880 backfill tests had + stale `result.added` expectations; updated to include the new entries. +- Worktree gotcha: `@cluesmith/codev-core` dist wasn't built here; had to + build types + core before the codev package would compile. diff --git a/packages/codev/src/__tests__/doctor.test.ts b/packages/codev/src/__tests__/doctor.test.ts index 4fabd78bf..9292e9084 100644 --- a/packages/codev/src/__tests__/doctor.test.ts +++ b/packages/codev/src/__tests__/doctor.test.ts @@ -582,7 +582,29 @@ describe('doctor command', () => { return Buffer.from(''); }); - vi.mocked(spawnSync).mockImplementation((cmd: string) => { + vi.mocked(spawnSync).mockImplementation((cmd: string, args?: readonly string[]) => { + // Healthy state-file split (#1192): thread files not ignored, + // nothing tracked under codev/state/ + if (cmd === 'git' && args && args[0] === 'check-ignore' && String(args[args.length - 1]).endsWith('_thread.md')) { + return { + status: 1, + stdout: '', + stderr: '', + signal: null, + output: [null, '', ''], + pid: 0, + }; + } + if (cmd === 'git' && args && args[0] === 'ls-files') { + return { + status: 0, + stdout: '', + stderr: '', + signal: null, + output: [null, '', ''], + pid: 0, + }; + } const responses: Record = { 'node': 'v20.0.0', 'tmux': 'tmux 3.4', diff --git a/packages/codev/src/__tests__/gitignore.test.ts b/packages/codev/src/__tests__/gitignore.test.ts index 971d1d0ef..e3d2186e6 100644 --- a/packages/codev/src/__tests__/gitignore.test.ts +++ b/packages/codev/src/__tests__/gitignore.test.ts @@ -8,10 +8,12 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { execFileSync } from 'node:child_process'; import { createGitignore, updateGitignore, backfillGitignore, + auditStateFileIgnore, CODEV_GITIGNORE_ENTRIES, } from '../lib/gitignore.js'; @@ -123,6 +125,17 @@ describe('Gitignore Utilities', () => { it('should contain .architect-role.md (issue #880)', () => { expect(CODEV_GITIGNORE_ENTRIES).toContain('.architect-role.md'); }); + + // Regression for issue #1192: architect state files are per-person and + // ignored; builder thread files stay versioned. The negation must come + // AFTER the ignore rule (gitignore last-match-wins). + it('should ignore architect state files but keep thread files, in that order (issue #1192)', () => { + expect(CODEV_GITIGNORE_ENTRIES).toContain('codev/state/*.md'); + expect(CODEV_GITIGNORE_ENTRIES).toContain('!codev/state/*_thread.md'); + expect(CODEV_GITIGNORE_ENTRIES.indexOf('codev/state/*.md')).toBeLessThan( + CODEV_GITIGNORE_ENTRIES.indexOf('!codev/state/*_thread.md') + ); + }); }); describe('backfillGitignore (issue #880)', () => { @@ -137,7 +150,7 @@ describe('Gitignore Utilities', () => { const result = backfillGitignore(targetDir, CODEV_GITIGNORE_ENTRIES, { today: new Date('2026-05-27') }); expect(result.skipped).toBe(false); - expect(result.added).toEqual(['.architect-role.md']); + expect(result.added).toEqual(['.architect-role.md', 'codev/state/*.md', '!codev/state/*_thread.md']); expect(result.alreadyPresent).toEqual( expect.arrayContaining(['.agent-farm/', '.consult/', 'codev/.update-hashes.json', '.builders/']) ); @@ -208,7 +221,7 @@ describe('Gitignore Utilities', () => { const result = backfillGitignore(targetDir, CODEV_GITIGNORE_ENTRIES, { dryRun: true }); - expect(result.added).toEqual(['.architect-role.md']); + expect(result.added).toEqual(['.architect-role.md', 'codev/state/*.md', '!codev/state/*_thread.md']); expect(fs.readFileSync(path.join(targetDir, '.gitignore'), 'utf-8')).toBe(original); }); @@ -229,5 +242,116 @@ describe('Gitignore Utilities', () => { // Only one occurrence of .architect-role.md expect(afterSecond.match(/\.architect-role\.md/g) || []).toHaveLength(1); }); + + // Regression for issue #1192: a pre-#1192 install gains the state-file + // rule pair (and only that) from `codev update`, in ignore-then-negate order. + it('backfills the state-file rule pair for pre-#1192 installs (issue #1192)', () => { + const targetDir = path.join(tempDir, 'project'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync( + path.join(targetDir, '.gitignore'), + '# Codev\n.agent-farm/\n.consult/\ncodev/.update-hashes.json\n.builders/\n.architect-role.md\n' + ); + + const result = backfillGitignore(targetDir, CODEV_GITIGNORE_ENTRIES, { today: new Date('2026-07-19') }); + + expect(result.added).toEqual(['codev/state/*.md', '!codev/state/*_thread.md']); + + const content = fs.readFileSync(path.join(targetDir, '.gitignore'), 'utf-8'); + expect(content.indexOf('codev/state/*.md')).toBeLessThan( + content.indexOf('!codev/state/*_thread.md') + ); + }); + }); + + function gitInit(dir: string): void { + execFileSync('git', ['init', '-q'], { cwd: dir }); + } + + function checkIgnoreStatus(dir: string, file: string): number { + try { + execFileSync('git', ['check-ignore', '-q', file], { cwd: dir }); + return 0; + } catch (err) { + return (err as { status: number }).status; + } + } + + // End-to-end validity of the rule pair (issue #1192): prove git actually + // produces the intended split, not just that the text is present. + describe('state-file split against real git (issue #1192)', () => { + it('ignores architect state files but not builder thread files', () => { + const targetDir = path.join(tempDir, 'project'); + fs.mkdirSync(targetDir, { recursive: true }); + gitInit(targetDir); + createGitignore(targetDir); + + expect(checkIgnoreStatus(targetDir, 'codev/state/main.md')).toBe(0); + expect(checkIgnoreStatus(targetDir, 'codev/state/pir-42_thread.md')).not.toBe(0); + }); + }); + + describe('auditStateFileIgnore (issue #1192)', () => { + it('warns when architect state files are not ignored', () => { + const targetDir = path.join(tempDir, 'project'); + fs.mkdirSync(targetDir, { recursive: true }); + gitInit(targetDir); + + const warnings = auditStateFileIgnore(targetDir); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('not gitignored'); + }); + + it('reports nothing when the rule pair is in place', () => { + const targetDir = path.join(tempDir, 'project'); + fs.mkdirSync(targetDir, { recursive: true }); + gitInit(targetDir); + createGitignore(targetDir); + + expect(auditStateFileIgnore(targetDir)).toEqual([]); + }); + + it('warns when a later rule shadows the thread-file negation', () => { + const targetDir = path.join(tempDir, 'project'); + fs.mkdirSync(targetDir, { recursive: true }); + gitInit(targetDir); + // Negation before the ignore rule: last-match-wins re-ignores thread files + fs.writeFileSync( + path.join(targetDir, '.gitignore'), + '!codev/state/*_thread.md\ncodev/state/*.md\n' + ); + + const warnings = auditStateFileIgnore(targetDir); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('must stay versioned'); + }); + + it('warns per architect state file already tracked by git', () => { + const targetDir = path.join(tempDir, 'project'); + fs.mkdirSync(targetDir, { recursive: true }); + gitInit(targetDir); + createGitignore(targetDir); + fs.mkdirSync(path.join(targetDir, 'codev', 'state'), { recursive: true }); + fs.writeFileSync(path.join(targetDir, 'codev', 'state', 'main.md'), '# main architect\n'); + fs.writeFileSync(path.join(targetDir, 'codev', 'state', 'pir-42_thread.md'), '# thread\n'); + execFileSync('git', ['add', '-f', 'codev/state/main.md', 'codev/state/pir-42_thread.md'], { + cwd: targetDir, + }); + + const warnings = auditStateFileIgnore(targetDir); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('codev/state/main.md'); + expect(warnings[0]).toContain('git rm --cached'); + }); + + it('returns no findings outside a git repository', () => { + const targetDir = path.join(tempDir, 'plain-dir'); + fs.mkdirSync(targetDir, { recursive: true }); + + expect(auditStateFileIgnore(targetDir)).toEqual([]); + }); }); }); diff --git a/packages/codev/src/__tests__/update.test.ts b/packages/codev/src/__tests__/update.test.ts index efe9fc8cc..7dc768d6f 100644 --- a/packages/codev/src/__tests__/update.test.ts +++ b/packages/codev/src/__tests__/update.test.ts @@ -527,7 +527,7 @@ describe('update command', () => { const result = await update({ agent: true }); expect(result.error).toBeUndefined(); - expect(result.gitignoreAdded).toEqual(['.architect-role.md']); + expect(result.gitignoreAdded).toEqual(['.architect-role.md', 'codev/state/*.md', '!codev/state/*_thread.md']); const content = fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf-8'); expect(content).toContain('.architect-role.md'); @@ -547,7 +547,7 @@ describe('update command', () => { const { update } = await import('../commands/update.js'); const result = await update({ agent: true, dryRun: true }); - expect(result.gitignoreAdded).toEqual(['.architect-role.md']); + expect(result.gitignoreAdded).toEqual(['.architect-role.md', 'codev/state/*.md', '!codev/state/*_thread.md']); expect(fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf-8')).toBe(stale); }); diff --git a/packages/codev/src/commands/doctor.ts b/packages/codev/src/commands/doctor.ts index 952cb63f6..317c1b41a 100644 --- a/packages/codev/src/commands/doctor.ts +++ b/packages/codev/src/commands/doctor.ts @@ -13,6 +13,7 @@ import { query as claudeQuery } from '@anthropic-ai/claude-agent-sdk'; import { executeForgeCommandSync, loadForgeConfig, validateForgeConfig, resolveAllConcepts, type ConceptResolution } from '../lib/forge.js'; import { detectHarnessFromCommand } from '../agent-farm/utils/harness.js'; import { auditPrGates, formatPrGateWarning } from '../lib/pr-gate-audit.js'; +import { auditStateFileIgnore } from '../lib/gitignore.js'; import { auditFrameworkRefs, formatFrameworkRefFinding, hasFrameworkOverrides } from '../lib/framework-ref-audit.js'; import { resolveAgyBin, AGY_OAUTH_MARKERS } from './consult/index.js'; @@ -488,6 +489,9 @@ function checkCodevStructure(workspaceRoot: string): { warnings: string[] } { warnings.push('No git remote configured - builders cannot push branches or create PRs. Run: git remote add origin '); } + // Architect state files must be gitignored; builder thread files versioned (#1192) + warnings.push(...auditStateFileIgnore(workspaceRoot)); + return { warnings }; } diff --git a/packages/codev/src/lib/gitignore.ts b/packages/codev/src/lib/gitignore.ts index bf9676e1a..2d9a0961b 100644 --- a/packages/codev/src/lib/gitignore.ts +++ b/packages/codev/src/lib/gitignore.ts @@ -11,6 +11,17 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +/** + * Architect state files (`codev/state/.md`) are per-person and must be + * gitignored; builder thread files (`codev/state/*_thread.md`) share the + * directory but must stay versioned (they ship with each builder PR). Defined + * once here and referenced both in the entries block below and in the doctor + * audit's warning text (issue #1192), so the two can't drift out of sync. + */ +export const STATE_IGNORE_RULE = 'codev/state/*.md'; +export const THREAD_KEEP_RULE = '!codev/state/*_thread.md'; /** * Standard gitignore entries for codev projects @@ -21,6 +32,8 @@ export const CODEV_GITIGNORE_ENTRIES = `# Codev codev/.update-hashes.json .builders/ .architect-role.md +${STATE_IGNORE_RULE} +${THREAD_KEEP_RULE} `; /** @@ -149,3 +162,78 @@ export function backfillGitignore( return { added, alreadyPresent, skipped: false }; } + +function gitExitStatus(args: string[], cwd: string): number | null { + try { + const result = spawnSync('git', args, { cwd, stdio: 'pipe', timeout: 5000 }); + return result.status; + } catch { + return null; + } +} + +/** + * Audit the architect-state-file versioning split for `codev doctor` (issue #1192). + * + * Architect state files (`codev/state/.md`) are per-person and must be + * gitignored; builder thread files (`codev/state/*_thread.md`) share the + * directory but must stay versioned (they ship with each builder PR). + * + * Probes git's actual ignore behavior via `git check-ignore` on phantom paths + * (check-ignore does not require the path to exist) rather than string-matching + * `.gitignore`, so hand-written equivalent rules pass and rule-ordering bugs + * (a shadowed negation) are caught. Returns warning strings; empty outside a + * git repository. + */ +export function auditStateFileIgnore(workspaceRoot: string): string[] { + const warnings: string[] = []; + + if (gitExitStatus(['rev-parse', '--is-inside-work-tree'], workspaceRoot) !== 0) { + return warnings; + } + + const architectProbe = gitExitStatus( + ['check-ignore', '-q', 'codev/state/__doctor-probe__.md'], + workspaceRoot + ); + if (architectProbe !== 0) { + warnings.push( + `Architect state files (codev/state/.md) are not gitignored. They are per-person and must not be committed. Run "codev update" to backfill the rule, or add "${STATE_IGNORE_RULE}" + "${THREAD_KEEP_RULE}" to .gitignore` + ); + } + + const threadProbe = gitExitStatus( + ['check-ignore', '-q', 'codev/state/__doctor-probe___thread.md'], + workspaceRoot + ); + if (threadProbe === 0) { + warnings.push( + `Builder thread files (codev/state/*_thread.md) are gitignored. They must stay versioned (they ship with each builder PR). The "${THREAD_KEEP_RULE}" negation is missing or shadowed by a later rule in .gitignore` + ); + } + + // gitignore has no effect on already-tracked files; pre-existing installs may + // have committed an architect state file before the rule existed. + try { + const result = spawnSync('git', ['ls-files', 'codev/state/*.md'], { + cwd: workspaceRoot, + encoding: 'utf-8', + timeout: 5000, + }); + if (result.status === 0) { + const tracked = result.stdout + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0 && !line.endsWith('_thread.md')); + for (const file of tracked) { + warnings.push( + `Architect state file is tracked by git: ${file}. It is per-person and should not be versioned. Run: git rm --cached ${file}` + ); + } + } + } catch { + // git unavailable or timed out; nothing to report + } + + return warnings; +}