From e8768b1c046515084af2bb7bd2629aaa619d8617 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:14:03 +1000 Subject: [PATCH 01/19] chore(porch): 805 init pir --- .../status.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 codev/projects/805-allow-directory-entries-in-wor/status.yaml diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml new file mode 100644 index 000000000..e5912fc68 --- /dev/null +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -0,0 +1,18 @@ +id: '805' +title: allow-directory-entries-in-wor +protocol: pir +phase: plan +plan_phases: [] +current_plan_phase: null +gates: + plan-approval: + status: pending + dev-approval: + status: pending + pr: + status: pending +iteration: 1 +build_complete: false +history: [] +started_at: '2026-05-31T03:14:03.261Z' +updated_at: '2026-05-31T03:14:03.266Z' From d91edb116effc11f89729f818dd19bf7da071fc8 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:17:38 +1000 Subject: [PATCH 02/19] [PIR #805] Plan draft --- .../805-allow-directory-entries-in-wor.md | 166 ++++++++++++++++++ codev/state/pir-805_thread.md | 29 +++ 2 files changed, 195 insertions(+) create mode 100644 codev/plans/805-allow-directory-entries-in-wor.md create mode 100644 codev/state/pir-805_thread.md diff --git a/codev/plans/805-allow-directory-entries-in-wor.md b/codev/plans/805-allow-directory-entries-in-wor.md new file mode 100644 index 000000000..8303cca53 --- /dev/null +++ b/codev/plans/805-allow-directory-entries-in-wor.md @@ -0,0 +1,166 @@ +# PIR Plan: Allow directory entries in `worktree.symlinks` via trailing-slash opt-in + +Issue: #805 · Area: `area/config` + +## Understanding + +`worktree.symlinks` in `.codev/config.json` currently accepts **file entries only**. +A directory entry (e.g. `".local-user-data"`) is silently dropped — never symlinked, +never logged. + +**Root cause** — `packages/codev/src/agent-farm/commands/spawn-worktree.ts:83-91`: + +```ts +for (const pattern of getWorktreeConfig(config.workspaceRoot).symlinks) { + for (const rel of globSync(pattern, { cwd: config.workspaceRoot, dot: true, nodir: true })) { + ... + } +} +``` + +The `nodir: true` glob flag filters directory matches out before they ever reach +`symlinkSync`. That flag is **intentional**: without it, a pattern like `"apps/auth"` +would symlink the worktree's own source directory back at the parent checkout — the +builder's branch would silently edit the parent's working copy, defeating the point +of `git worktree add`. So the fix must be an **opt-in for directories**, not a blanket +relaxation of the guard. + +Goal: let a trailing slash on an entry (`".local-user-data/"`) mean "this is a +directory — symlink it", while every entry without a trailing slash keeps today's +exact behaviour (and the footgun guard). + +## Proposed Change + +In the `worktree.symlinks` loop of `symlinkConfigFiles`, branch on whether the entry +ends with `/`: + +- **No trailing slash** → unchanged. Same `globSync(pattern, { dot: true, nodir: true })` + loop, same code path. Directories that happen to match are still filtered out — the + `"apps/auth"` footgun stays guarded. +- **Trailing slash** → strip the slash and treat the remainder as a **literal relative + path** (no glob expansion). Compute `target = resolve(worktreePath, rel)` and + `srcAbs = resolve(workspaceRoot, rel)`, keep the `existsSync(target) → continue` + idempotency check, `mkdirSync(dirname(target), { recursive: true })`, then + `symlinkSync(srcAbs, target)`. + +### Why literal-path, not `globSync(nodir: false)` + +Acceptance requires: *"Source not existing at spawn time is non-fatal (dangling link +is acceptable — runtime tooling creates the dir)."* A glob can only ever return paths +that **already exist**, so a globbed directory entry could never produce the dangling +link the use case wants (shannon's `.local-user-data/` is created by runtime tooling, +possibly after spawn). Treating the entry as a literal path lets us create the symlink +unconditionally; if the parent dir doesn't exist yet, the link dangles until runtime +tooling materialises it — exactly the desired behaviour. It is also simpler and more +self-documenting (`foo/` = "symlink this exact directory"). + +Trade-off: glob metacharacters inside a trailing-slash entry (e.g. `packages/*/data/`) +are **not** expanded — the `*` would be taken literally. The concrete use case is a +single literal directory, and directory-glob expansion is explicitly out of scope. +The implement phase will log a warning if a trailing-slash entry contains glob +metacharacters, so a misconfiguration is visible rather than silently producing a +`*`-named link. + +### Cross-platform (Windows) + +On POSIX the third `symlinkSync` type argument is ignored, but on Windows a directory +symlink needs `symlinkSync(src, dest, 'dir')`. We pass `'dir'` when the source exists +and is a directory: + +```ts +const isDir = existsSync(srcAbs) && statSync(srcAbs).isDirectory(); +symlinkSync(srcAbs, target, isDir ? 'dir' : undefined); +``` + +This is correct on POSIX (type ignored) and correct on Windows for the common case +(source exists at spawn). A dangling Windows dir-symlink (source absent at spawn) is +best-effort — documented, not engineered, matching the existing POSIX-leaning code. + +### Sketch + +```ts +for (const rawPattern of getWorktreeConfig(config.workspaceRoot).symlinks) { + if (rawPattern.endsWith('/')) { + const rel = rawPattern.slice(0, -1); + if (!rel) continue; // guard bare "/" + const target = resolve(worktreePath, rel); + if (existsSync(target)) continue; // idempotent + const srcAbs = resolve(config.workspaceRoot, rel); + mkdirSync(dirname(target), { recursive: true }); + const isDir = existsSync(srcAbs) && statSync(srcAbs).isDirectory(); + symlinkSync(srcAbs, target, isDir ? 'dir' : undefined); + logger.info(`Linked directory ${rel}/ from workspace root`); + } else { + for (const rel of globSync(rawPattern, { cwd: config.workspaceRoot, dot: true, nodir: true })) { + const target = resolve(worktreePath, rel); + if (existsSync(target)) continue; + mkdirSync(dirname(target), { recursive: true }); + symlinkSync(resolve(config.workspaceRoot, rel), target); + logger.info(`Linked ${rel} from workspace root`); + } + } +} +``` + +## Files to Change + +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts:83-91` — branch the + symlinks loop on trailing slash (per sketch above). Add `statSync` to the existing + `node:fs` import on line 12. +- `packages/codev/src/agent-farm/types.ts:202-208` — extend the `symlinks` field + JSDoc to document the trailing-slash directory opt-in and the footgun rationale. +- `packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts` — add `statSync` + to the `node:fs` mock (line ~22-34, currently inherits the real one via `...actual`); + add new `symlinkConfigFiles` test cases (see Test Plan). +- `CLAUDE.md` and `AGENTS.md` — in the "Config: the `worktree` block" section, note + that a trailing slash on a `symlinks` entry opts a directory in (one sentence, kept + in sync between the two files per their header contract). + +Out of scope (per issue): `git check-ignore` auto-detection; shannon's own +`.codev/config.local.json` edit (tracked separately, one line, cross-repo follow-up). + +## Risks & Alternatives Considered + +- **Risk — footgun re-opened.** Mitigation: the guard only relaxes for entries that + *explicitly* end in `/`. `"apps/auth"` (no slash) still routes through the + `nodir: true` glob and is filtered out exactly as today. A test asserts a non-slash + directory entry produces no symlink. +- **Risk — `statSync` on a missing source throws.** Mitigation: short-circuit with + `existsSync(srcAbs) &&` before `statSync`, so a dangling-link case never calls + `statSync`. The `node:fs` test mock gains `statSync` so unit tests don't hit the + real fs for fake paths. +- **Risk — glob metacharacters in a trailing-slash entry.** A `*` would be taken + literally and could create a `*`-named link. Mitigation: log a warning when a + trailing-slash entry contains glob metacharacters; documented as a known limitation. +- **Alternative — `globSync(pattern, { nodir: false })` for slash entries.** Rejected: + cannot satisfy the dangling-link acceptance criterion (glob only returns existing + paths) and adds no value for the literal-directory use case. +- **Alternative — `git check-ignore` to auto-detect untracked dirs.** Rejected by the + issue as out of scope; trailing-slash opt-in is simpler and self-documenting. + +## Test Plan + +**Unit** (`spawn-worktree.test.ts`, `symlinkConfigFiles` describe block; mirrors the +existing mocked-fs style — no real filesystem): + +1. **Directory entry symlinks the dir** — `symlinks: ['.local-user-data/']`, source + exists & is a directory → `symlinkSync('/projects/test/.local-user-data', + '/tmp/wt/.local-user-data', 'dir')`, `globSync` **not** called for that entry. +2. **Dangling link when source absent** — `symlinks: ['.local-user-data/']`, + `existsSync(srcAbs) → false` → `symlinkSync(srcAbs, target, undefined)` still called + (link created), no throw. +3. **Idempotency** — target already exists in worktree → `symlinkSync` not called. +4. **Non-slash directory entry stays filtered** — `symlinks: ['apps/auth']`, `globSync` + (nodir:true) returns `[]` → `symlinkSync` not called (footgun guard intact). +5. **File entries unaffected** — existing file-glob tests continue to pass unchanged. + +**Build / typecheck**: `pnpm --filter @cluesmith/codev build` and the unit suite green. + +**Manual (reviewer, at `dev-approval`)**: +- Add `".local-user-data/"` to a test `.codev/config.json` `worktree.symlinks`, spawn a + builder, confirm `/.local-user-data → /.local-user-data` and + that a write through the link lands at the parent path. +- Confirm a file-only config still spawns identically (no behaviour change). + +**Cross-platform**: POSIX is the primary target (matches existing code). Windows +dir-symlink type is set when the source exists; not separately gated. diff --git a/codev/state/pir-805_thread.md b/codev/state/pir-805_thread.md new file mode 100644 index 000000000..bd720159b --- /dev/null +++ b/codev/state/pir-805_thread.md @@ -0,0 +1,29 @@ +# PIR #805 — Allow directory entries in worktree.symlinks via trailing-slash opt-in + +Builder: pir-805 · Protocol: PIR (strict) · Branch: builder/pir-805 + +## Plan phase (in progress) + +**Issue**: `worktree.symlinks` drops directory entries because `symlinkConfigFiles` +globs with `nodir: true`. Add a trailing-slash opt-in so `".local-user-data/"` +produces a directory symlink, while file entries keep current behaviour and the +"can't mask source" footgun guard stays intact for non-slash entries. + +**Root cause**: `packages/codev/src/agent-farm/commands/spawn-worktree.ts:83-91` +— the single glob loop uses `nodir: true`, silently filtering directories. + +**Design decisions**: +- Trailing-slash entries → treated as a **literal relative path** (not globbed). + Chosen over `globSync(nodir:false)` because the acceptance criterion requires a + dangling link to be created when the source doesn't exist yet (glob can't match + a missing source). Self-documenting: `foo/` means "this exact dir". +- Non-slash entries → unchanged glob path with `nodir: true` (footgun guard kept). +- Cross-platform: pass `'dir'` to `symlinkSync` when the source exists and is a + directory (`existsSync && statSync().isDirectory()`); POSIX ignores the type arg. +- Keep `existsSync(target) → continue` idempotency in both branches. + +**Test impact**: `node:fs` mock in `spawn-worktree.test.ts` needs `statSync` added +(currently inherits real one via `...actual`). + +Plan written to `codev/plans/805-allow-directory-entries-in-wor.md`. Awaiting +`plan-approval` gate. From 7f723619fdecd5aabe2e4807406648df77daf6d5 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:17:44 +1000 Subject: [PATCH 03/19] chore(porch): 805 plan-approval gate-requested --- codev/projects/805-allow-directory-entries-in-wor/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index e5912fc68..6abdeef4f 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -7,6 +7,7 @@ current_plan_phase: null gates: plan-approval: status: pending + requested_at: '2026-05-31T03:17:44.673Z' dev-approval: status: pending pr: @@ -15,4 +16,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T03:14:03.266Z' +updated_at: '2026-05-31T03:17:44.674Z' From 6489bac1843cfc0501217e15a8216484ed78c85e Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:24:12 +1000 Subject: [PATCH 04/19] [PIR #805] Plan revised: dangling-symlink-safe idempotency check --- .../805-allow-directory-entries-in-wor.md | 55 +++++++++++++++---- codev/state/pir-805_thread.md | 10 ++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/codev/plans/805-allow-directory-entries-in-wor.md b/codev/plans/805-allow-directory-entries-in-wor.md index 8303cca53..dfb54b96a 100644 --- a/codev/plans/805-allow-directory-entries-in-wor.md +++ b/codev/plans/805-allow-directory-entries-in-wor.md @@ -76,6 +76,33 @@ This is correct on POSIX (type ignored) and correct on Windows for the common ca (source exists at spawn). A dangling Windows dir-symlink (source absent at spawn) is best-effort — documented, not engineered, matching the existing POSIX-leaning code. +### Destination already occupied / idempotency + +If anything already occupies the destination we **skip** it — never overwrite or merge: + +- A tracked path materialised by `git worktree add`, or a dir created by a `postSpawn` + step → left untouched, builder uses it as-is. +- A previously-created, resolvable symlink → skipped (idempotent re-spawn / `afx setup`). + +`existsSync` alone is insufficient because it **follows** symlinks: a *dangling* +directory symlink (a first-class supported case here — source created by runtime +tooling after spawn) reads as "absent", so a setup re-run would call `symlinkSync` +again and throw `EEXIST`. `afx setup` / "Run Worktree Setup" is explicitly idempotent, +so this path is real. Guard with an `lstatSync`-based check that detects the link +itself: + +```ts +function pathOccupied(p: string): boolean { + if (existsSync(p)) return true; // real file/dir, or a resolvable symlink + try { lstatSync(p); return true; } // a dangling symlink still occupies the path + catch { return false; } +} +``` + +The existing file-symlink branch is unaffected (globbed file sources always exist, so +those links are never dangling), but for symmetry and safety the directory branch uses +`pathOccupied`. + ### Sketch ```ts @@ -84,7 +111,7 @@ for (const rawPattern of getWorktreeConfig(config.workspaceRoot).symlinks) { const rel = rawPattern.slice(0, -1); if (!rel) continue; // guard bare "/" const target = resolve(worktreePath, rel); - if (existsSync(target)) continue; // idempotent + if (pathOccupied(target)) continue; // idempotent + dangling-link safe const srcAbs = resolve(config.workspaceRoot, rel); mkdirSync(dirname(target), { recursive: true }); const isDir = existsSync(srcAbs) && statSync(srcAbs).isDirectory(); @@ -105,13 +132,13 @@ for (const rawPattern of getWorktreeConfig(config.workspaceRoot).symlinks) { ## Files to Change - `packages/codev/src/agent-farm/commands/spawn-worktree.ts:83-91` — branch the - symlinks loop on trailing slash (per sketch above). Add `statSync` to the existing - `node:fs` import on line 12. + symlinks loop on trailing slash (per sketch above); add a small `pathOccupied` + helper. Add `statSync` and `lstatSync` to the existing `node:fs` import on line 12. - `packages/codev/src/agent-farm/types.ts:202-208` — extend the `symlinks` field JSDoc to document the trailing-slash directory opt-in and the footgun rationale. - `packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts` — add `statSync` - to the `node:fs` mock (line ~22-34, currently inherits the real one via `...actual`); - add new `symlinkConfigFiles` test cases (see Test Plan). + and `lstatSync` to the `node:fs` mock (line ~22-34, currently inherits the real ones + via `...actual`); add new `symlinkConfigFiles` test cases (see Test Plan). - `CLAUDE.md` and `AGENTS.md` — in the "Config: the `worktree` block" section, note that a trailing slash on a `symlinks` entry opts a directory in (one sentence, kept in sync between the two files per their header contract). @@ -127,8 +154,12 @@ Out of scope (per issue): `git check-ignore` auto-detection; shannon's own directory entry produces no symlink. - **Risk — `statSync` on a missing source throws.** Mitigation: short-circuit with `existsSync(srcAbs) &&` before `statSync`, so a dangling-link case never calls - `statSync`. The `node:fs` test mock gains `statSync` so unit tests don't hit the - real fs for fake paths. + `statSync`. The `node:fs` test mock gains `statSync`/`lstatSync` so unit tests don't + hit the real fs for fake paths. +- **Risk — `EEXIST` on setup re-run over a dangling dir-symlink.** `existsSync` follows + links, so a dangling link reads as absent and `symlinkSync` would throw on re-run. + Mitigation: the `pathOccupied` helper uses `lstatSync` to detect the link itself, so + a re-run skips cleanly. (Never overwrites/merges an occupied destination either way.) - **Risk — glob metacharacters in a trailing-slash entry.** A `*` would be taken literally and could create a `*`-named link. Mitigation: log a warning when a trailing-slash entry contains glob metacharacters; documented as a known limitation. @@ -149,10 +180,14 @@ existing mocked-fs style — no real filesystem): 2. **Dangling link when source absent** — `symlinks: ['.local-user-data/']`, `existsSync(srcAbs) → false` → `symlinkSync(srcAbs, target, undefined)` still called (link created), no throw. -3. **Idempotency** — target already exists in worktree → `symlinkSync` not called. -4. **Non-slash directory entry stays filtered** — `symlinks: ['apps/auth']`, `globSync` +3. **Idempotency, real dir** — target dir already exists in worktree (`existsSync → + true`) → `symlinkSync` not called. +4. **Idempotency, dangling link** — `existsSync(target) → false` but `lstatSync(target)` + succeeds (link already present, source still absent) → `symlinkSync` not called (no + `EEXIST`). +5. **Non-slash directory entry stays filtered** — `symlinks: ['apps/auth']`, `globSync` (nodir:true) returns `[]` → `symlinkSync` not called (footgun guard intact). -5. **File entries unaffected** — existing file-glob tests continue to pass unchanged. +6. **File entries unaffected** — existing file-glob tests continue to pass unchanged. **Build / typecheck**: `pnpm --filter @cluesmith/codev build` and the unit suite green. diff --git a/codev/state/pir-805_thread.md b/codev/state/pir-805_thread.md index bd720159b..4140357e2 100644 --- a/codev/state/pir-805_thread.md +++ b/codev/state/pir-805_thread.md @@ -27,3 +27,13 @@ produces a directory symlink, while file entries keep current behaviour and the Plan written to `codev/plans/805-allow-directory-entries-in-wor.md`. Awaiting `plan-approval` gate. + +### Plan revision 1 (reviewer Q: "what if a folder already exists at the destination?") + +Surfaced a real gap: `existsSync(target)` follows symlinks, so a **dangling** +dir-symlink reads as absent → a `afx setup` re-run would call `symlinkSync` again and +throw `EEXIST`. Since dangling links are a supported case AND `afx setup` is +idempotent-by-design, this path is real. Fix: added a `pathOccupied(target)` helper +(`existsSync` OR `lstatSync` succeeds) so any occupied destination — real dir, +resolvable link, or dangling link — is skipped. Never overwrites/merges. Added +`lstatSync` to imports + test mock, plus a dedicated idempotency-on-dangling-link test. From 7f17d97d510fb4ab899cd907acaec30f4e442db5 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:26:18 +1000 Subject: [PATCH 05/19] chore(porch): 805 plan-approval gate-approved --- .../projects/805-allow-directory-entries-in-wor/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index 6abdeef4f..1c38523f3 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -6,8 +6,9 @@ plan_phases: [] current_plan_phase: null gates: plan-approval: - status: pending + status: approved requested_at: '2026-05-31T03:17:44.673Z' + approved_at: '2026-05-31T03:26:18.777Z' dev-approval: status: pending pr: @@ -16,4 +17,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T03:17:44.674Z' +updated_at: '2026-05-31T03:26:18.777Z' From 2eede4d7fcbbd8cca852ef989c8815eb21dd5027 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:26:25 +1000 Subject: [PATCH 06/19] chore(porch): 805 implement phase-transition --- codev/projects/805-allow-directory-entries-in-wor/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index 1c38523f3..2c534baff 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -1,7 +1,7 @@ id: '805' title: allow-directory-entries-in-wor protocol: pir -phase: plan +phase: implement plan_phases: [] current_plan_phase: null gates: @@ -17,4 +17,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T03:26:18.777Z' +updated_at: '2026-05-31T03:26:25.200Z' From 43db7bee72498924c70f4a16a76b66c1cd432978 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:31:36 +1000 Subject: [PATCH 07/19] [PIR #805] Symlink directory entries via trailing-slash opt-in --- .../src/agent-farm/commands/spawn-worktree.ts | 58 ++++++++++++++++--- packages/codev/src/agent-farm/types.ts | 16 ++++- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/codev/src/agent-farm/commands/spawn-worktree.ts b/packages/codev/src/agent-farm/commands/spawn-worktree.ts index 1203b44a5..a0eb5e91f 100644 --- a/packages/codev/src/agent-farm/commands/spawn-worktree.ts +++ b/packages/codev/src/agent-farm/commands/spawn-worktree.ts @@ -9,7 +9,7 @@ import { resolve, dirname } from 'node:path'; import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync, chmodSync, symlinkSync, readdirSync, mkdirSync } from 'node:fs'; +import { existsSync, lstatSync, statSync, readFileSync, writeFileSync, chmodSync, symlinkSync, readdirSync, mkdirSync } from 'node:fs'; import { globSync } from 'glob'; import type { Config, ProtocolDefinition } from '../types.js'; import { logger, fatal } from '../utils/logger.js'; @@ -38,14 +38,37 @@ export async function checkDependencies(): Promise { // Git Worktree Management // ============================================================================= +/** + * True when `p` already exists on disk, including a *dangling* symlink (a link + * whose target is absent). `existsSync` follows symlinks, so it reports `false` + * for a dangling link even though the link file occupies the path — which would + * make a re-run of `symlinkConfigFiles` (e.g. `afx setup`) throw EEXIST. The + * `lstatSync` fallback inspects the link itself without following it. + */ +function pathOccupied(p: string): boolean { + if (existsSync(p)) return true; + try { + lstatSync(p); + return true; + } catch { + return false; + } +} + /** * Symlink config files from workspace root into a worktree (if they exist). * Shared by createWorktree() and createWorktreeFromBranch(). * * Always symlinks root `.env` and `.codev/config.json` (existing behavior). * Additionally, when `worktree.symlinks` is configured in `.codev/config.json`, - * expands each glob pattern from the workspace root and symlinks each match - * into the worktree at the same relative path. + * each entry is linked into the worktree at the same relative path: + * - File entries (no trailing slash) are glob-expanded with `nodir: true`, so + * a pattern that resolves to a directory is silently skipped — this guards + * against masking the worktree's own source with the parent checkout. + * - Directory entries (trailing slash) opt explicitly out of that guard: the + * slash is stripped, the remainder is treated as a literal path, and the + * directory is symlinked whole. The source need not exist at spawn time — a + * dangling link is acceptable (runtime tooling may create the dir later). */ export function symlinkConfigFiles(config: Config, worktreePath: string): void { // Symlink .env at root level @@ -78,15 +101,32 @@ export function symlinkConfigFiles(config: Config, worktreePath: string): void { } } - // Opt-in: expand worktree.symlinks globs and link each match at the same - // relative path inside the worktree. Unconfigured repos see no effect. + // Opt-in: link each worktree.symlinks entry at the same relative path inside + // the worktree. Unconfigured repos see no effect. for (const pattern of getWorktreeConfig(config.workspaceRoot).symlinks) { - for (const rel of globSync(pattern, { cwd: config.workspaceRoot, dot: true, nodir: true })) { + if (pattern.endsWith('/')) { + // Directory opt-in: literal path, symlinked whole (see fn-level comment). + const rel = pattern.slice(0, -1); + if (!rel) continue; // guard against a bare "/" entry + if (/[*?[\]{}!()]/.test(rel)) { + logger.warn(`Skipping worktree.symlinks entry "${pattern}": directory entries are literal paths, not globs.`); + continue; + } const target = resolve(worktreePath, rel); - if (existsSync(target)) continue; + if (pathOccupied(target)) continue; + const srcAbs = resolve(config.workspaceRoot, rel); mkdirSync(dirname(target), { recursive: true }); - symlinkSync(resolve(config.workspaceRoot, rel), target); - logger.info(`Linked ${rel} from workspace root`); + const isDir = existsSync(srcAbs) && statSync(srcAbs).isDirectory(); + symlinkSync(srcAbs, target, isDir ? 'dir' : undefined); + logger.info(`Linked directory ${rel}/ from workspace root`); + } else { + for (const rel of globSync(pattern, { cwd: config.workspaceRoot, dot: true, nodir: true })) { + const target = resolve(worktreePath, rel); + if (existsSync(target)) continue; + mkdirSync(dirname(target), { recursive: true }); + symlinkSync(resolve(config.workspaceRoot, rel), target); + logger.info(`Linked ${rel} from workspace root`); + } } } } diff --git a/packages/codev/src/agent-farm/types.ts b/packages/codev/src/agent-farm/types.ts index 897281c6a..f12c68d02 100644 --- a/packages/codev/src/agent-farm/types.ts +++ b/packages/codev/src/agent-farm/types.ts @@ -200,9 +200,19 @@ export interface UserConfig { */ worktree?: { /** - * Glob patterns of files to symlink from the workspace root into each new - * worktree. Resolved at spawn time relative to the workspace root. - * Example values: '.env.local', 'packages//.env', 'turbo.json'. + * Patterns to symlink from the workspace root into each new worktree, + * resolved at spawn time relative to the workspace root. + * + * - File entries are glob patterns expanded with `nodir: true`, so a match + * that is a directory is silently skipped. This guards against a pattern + * like 'apps/auth' masking the worktree's own source with the parent + * checkout. Example values: '.env.local', 'packages//.env', 'turbo.json'. + * - A trailing slash opts a directory in explicitly: '.local-user-data/' is + * treated as a literal path and symlinked whole. The source need not exist + * at spawn time (a dangling link is fine — runtime tooling may create it). + * Directory entries are literal, not globbed (no wildcard expansion), and + * are intentionally NOT branch-isolated (the link is shared with the parent). + * * Note: root `.env` and `.codev/config.json` are always symlinked regardless. */ symlinks?: string[]; From be62b59bb88e3dc17956e6f39100d633aa620872 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:31:36 +1000 Subject: [PATCH 08/19] [PIR #805] Test directory-symlink opt-in (dir type, dangling, idempotency, footgun) --- .../__tests__/spawn-worktree.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index 228ad3c71..d042ddc59 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -24,6 +24,8 @@ vi.mock('node:fs', async (importOriginal) => { return { ...actual, existsSync: vi.fn(() => false), + lstatSync: vi.fn(() => { throw new Error('ENOENT'); }), + statSync: vi.fn(() => ({ isDirectory: () => true })), readFileSync: vi.fn(() => '{}'), writeFileSync: vi.fn(), chmodSync: vi.fn(), @@ -930,6 +932,124 @@ describe('spawn-worktree', () => { expect(symlinkSync).not.toHaveBeenCalled(); }); + // -- Directory entries (trailing-slash opt-in, Issue #805) ---------------- + + it('symlinks a trailing-slash directory entry whole, with the dir type', async () => { + const { existsSync, symlinkSync, statSync } = await import('node:fs'); + getWorktreeConfigMock.mockReturnValueOnce({ + symlinks: ['.local-user-data/'], + postSpawn: [], + devCommand: null, + devUrls: [], + }); + vi.mocked(existsSync) + .mockReturnValueOnce(false) // root .env + .mockReturnValueOnce(false) // root .codev/config.json + .mockReturnValueOnce(false) // pathOccupied(target): existsSync + .mockReturnValueOnce(true); // existsSync(srcAbs): source dir present + vi.mocked(statSync).mockReturnValueOnce({ isDirectory: () => true } as any); + + symlinkConfigFiles(config, '/tmp/wt'); + + // Directory entries are literal — never globbed. + expect(globSyncMock).not.toHaveBeenCalled(); + expect(symlinkSync).toHaveBeenCalledTimes(1); + expect(symlinkSync).toHaveBeenCalledWith( + '/projects/test/.local-user-data', + '/tmp/wt/.local-user-data', + 'dir', + ); + }); + + it('creates a dangling link when the source directory is absent', async () => { + const { symlinkSync } = await import('node:fs'); + getWorktreeConfigMock.mockReturnValueOnce({ + symlinks: ['.local-user-data/'], + postSpawn: [], + devCommand: null, + devUrls: [], + }); + // Default mocks: existsSync → false everywhere (incl. srcAbs), lstatSync throws. + symlinkConfigFiles(config, '/tmp/wt'); + + // Link still created (no throw); no 'dir' type since the source isn't a dir. + expect(symlinkSync).toHaveBeenCalledTimes(1); + expect(symlinkSync).toHaveBeenCalledWith( + '/projects/test/.local-user-data', + '/tmp/wt/.local-user-data', + undefined, + ); + }); + + it('skips a directory entry when the target dir already exists', async () => { + const { existsSync, symlinkSync } = await import('node:fs'); + getWorktreeConfigMock.mockReturnValueOnce({ + symlinks: ['.local-user-data/'], + postSpawn: [], + devCommand: null, + devUrls: [], + }); + vi.mocked(existsSync) + .mockReturnValueOnce(false) // root .env + .mockReturnValueOnce(false) // root .codev/config.json + .mockReturnValueOnce(true); // pathOccupied(target): existsSync → true + symlinkConfigFiles(config, '/tmp/wt'); + + expect(symlinkSync).not.toHaveBeenCalled(); + }); + + it('skips a directory entry when a dangling link already occupies the target', async () => { + const { lstatSync, symlinkSync } = await import('node:fs'); + getWorktreeConfigMock.mockReturnValueOnce({ + symlinks: ['.local-user-data/'], + postSpawn: [], + devCommand: null, + devUrls: [], + }); + // existsSync(target) → false (default), but lstatSync(target) succeeds: + // the link file is present though its target is absent (dangling). + vi.mocked(lstatSync).mockReturnValueOnce({} as any); + symlinkConfigFiles(config, '/tmp/wt'); + + // No EEXIST — re-run is idempotent. + expect(symlinkSync).not.toHaveBeenCalled(); + }); + + it('still skips a directory matched by a non-slash entry (footgun guard)', async () => { + const { symlinkSync } = await import('node:fs'); + getWorktreeConfigMock.mockReturnValueOnce({ + symlinks: ['apps/auth'], + postSpawn: [], + devCommand: null, + devUrls: [], + }); + // nodir:true filtered the directory out → no matches. + globSyncMock.mockReturnValueOnce([]); + symlinkConfigFiles(config, '/tmp/wt'); + + expect(globSyncMock).toHaveBeenCalledWith( + 'apps/auth', + expect.objectContaining({ nodir: true }), + ); + expect(symlinkSync).not.toHaveBeenCalled(); + }); + + it('warns and skips a trailing-slash entry containing glob metacharacters', async () => { + const { symlinkSync } = await import('node:fs'); + const { logger } = await import('../utils/logger.js'); + getWorktreeConfigMock.mockReturnValueOnce({ + symlinks: ['packages/*/data/'], + postSpawn: [], + devCommand: null, + devUrls: [], + }); + symlinkConfigFiles(config, '/tmp/wt'); + + expect(symlinkSync).not.toHaveBeenCalled(); + expect(globSyncMock).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + }); describe('runPostSpawnHooks', () => { From 23662e4f368145c1f47ba8b77144e65cec9861db Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:31:36 +1000 Subject: [PATCH 09/19] [PIR #805] Document trailing-slash directory opt-in in worktree config --- AGENTS.md | 2 +- CLAUDE.md | 2 +- codev/state/pir-805_thread.md | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa63633d0..eeb4f2e1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -364,7 +364,7 @@ When configured, each builder worktree (`.builders//`) becomes runnable — } ``` -- `symlinks`: globs resolve from the workspace root; matches symlink into the worktree at the same relative path. Root `.env` and `.codev/config.json` are *always* symlinked regardless. **Symlinks, not copies** — edits to main's env files reflect instantly in any running dev session. +- `symlinks`: globs resolve from the workspace root; matches symlink into the worktree at the same relative path. Root `.env` and `.codev/config.json` are *always* symlinked regardless. **Symlinks, not copies** — edits to main's env files reflect instantly in any running dev session. A directory match is silently skipped (so a glob can't mask the worktree's own source) **unless** the entry ends with a trailing slash: `".local-user-data/"` is treated as a literal path and symlinks the directory whole (shared with the parent, not branch-isolated; a dangling link is fine if the source doesn't exist yet). - `postSpawn`: each command runs sequentially with `cwd` = worktree path. Non-zero exit aborts the spawn loud (half-built worktree stays for inspection). - `devCommand`: the foreground command that starts your dev server. Required for `afx dev` to work. diff --git a/CLAUDE.md b/CLAUDE.md index aa63633d0..eeb4f2e1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -364,7 +364,7 @@ When configured, each builder worktree (`.builders//`) becomes runnable — } ``` -- `symlinks`: globs resolve from the workspace root; matches symlink into the worktree at the same relative path. Root `.env` and `.codev/config.json` are *always* symlinked regardless. **Symlinks, not copies** — edits to main's env files reflect instantly in any running dev session. +- `symlinks`: globs resolve from the workspace root; matches symlink into the worktree at the same relative path. Root `.env` and `.codev/config.json` are *always* symlinked regardless. **Symlinks, not copies** — edits to main's env files reflect instantly in any running dev session. A directory match is silently skipped (so a glob can't mask the worktree's own source) **unless** the entry ends with a trailing slash: `".local-user-data/"` is treated as a literal path and symlinks the directory whole (shared with the parent, not branch-isolated; a dangling link is fine if the source doesn't exist yet). - `postSpawn`: each command runs sequentially with `cwd` = worktree path. Non-zero exit aborts the spawn loud (half-built worktree stays for inspection). - `devCommand`: the foreground command that starts your dev server. Required for `afx dev` to work. diff --git a/codev/state/pir-805_thread.md b/codev/state/pir-805_thread.md index 4140357e2..4e4642162 100644 --- a/codev/state/pir-805_thread.md +++ b/codev/state/pir-805_thread.md @@ -37,3 +37,23 @@ idempotent-by-design, this path is real. Fix: added a `pathOccupied(target)` hel (`existsSync` OR `lstatSync` succeeds) so any occupied destination — real dir, resolvable link, or dangling link — is skipped. Never overwrites/merges. Added `lstatSync` to imports + test mock, plus a dedicated idempotency-on-dangling-link test. + +## Implement phase + +Applied the approved plan: +- `spawn-worktree.ts`: added `pathOccupied()` helper (existsSync || lstatSync), branched + the symlinks loop on trailing slash, literal-path + `'dir'` type for dir entries, + warn+skip for glob-metachar dir entries. Imports gained `statSync`, `lstatSync`. +- `types.ts`: JSDoc on `symlinks` documents the dir opt-in + footgun rationale. +- `spawn-worktree.test.ts`: added `statSync`/`lstatSync` to the `node:fs` mock + 6 new + cases (symlink-dir-with-type, dangling-link, idempotency real dir, idempotency + dangling link, non-slash dir still filtered, glob-metachar warn+skip). +- `CLAUDE.md` + `AGENTS.md`: one-sentence note on the trailing-slash opt-in. + +**Worktree setup note**: this worktree had no `node_modules` — node resolution was +walking up to the main checkout, which broke vitest's config loader and the +`@cluesmith/codev-core` subpath import. Ran `pnpm install --frozen-lockfile` + +`pnpm --filter @cluesmith/codev-core build` to get a self-contained worktree. + +**Tests**: `pnpm build` ✓, spawn-worktree suite 77 passed (6 new). Awaiting full-suite +run via `porch done` checks, then `dev-approval` gate. From b1e88217c0fd351e25769843fd277200b122a30e Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:32:15 +1000 Subject: [PATCH 10/19] chore(porch): 805 dev-approval gate-requested --- codev/projects/805-allow-directory-entries-in-wor/status.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index 2c534baff..b7c77885c 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -11,10 +11,11 @@ gates: approved_at: '2026-05-31T03:26:18.777Z' dev-approval: status: pending + requested_at: '2026-05-31T03:32:15.088Z' pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T03:26:25.200Z' +updated_at: '2026-05-31T03:32:15.089Z' From 008a695ef2fca1da9ccd6d80f682071a10920238 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:51:39 +1000 Subject: [PATCH 11/19] chore(porch): 805 dev-approval gate-approved --- .../projects/805-allow-directory-entries-in-wor/status.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index b7c77885c..2420f2df9 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -10,12 +10,13 @@ gates: requested_at: '2026-05-31T03:17:44.673Z' approved_at: '2026-05-31T03:26:18.777Z' dev-approval: - status: pending + status: approved requested_at: '2026-05-31T03:32:15.088Z' + approved_at: '2026-05-31T03:51:39.244Z' pr: status: pending iteration: 1 build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T03:32:15.089Z' +updated_at: '2026-05-31T03:51:39.244Z' From ba095893192d2122dfa453dc1b62b5c525ac09e9 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:51:46 +1000 Subject: [PATCH 12/19] chore(porch): 805 review phase-transition --- codev/projects/805-allow-directory-entries-in-wor/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index 2420f2df9..01699ca82 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -1,7 +1,7 @@ id: '805' title: allow-directory-entries-in-wor protocol: pir -phase: implement +phase: review plan_phases: [] current_plan_phase: null gates: @@ -19,4 +19,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T03:51:39.244Z' +updated_at: '2026-05-31T03:51:46.246Z' From 68d0dfa910222e3785001cfc067fcd2530962e9c Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:52:39 +1000 Subject: [PATCH 13/19] [PIR #805] Review + retrospective --- .../805-allow-directory-entries-in-wor.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 codev/reviews/805-allow-directory-entries-in-wor.md diff --git a/codev/reviews/805-allow-directory-entries-in-wor.md b/codev/reviews/805-allow-directory-entries-in-wor.md new file mode 100644 index 000000000..176a42334 --- /dev/null +++ b/codev/reviews/805-allow-directory-entries-in-wor.md @@ -0,0 +1,93 @@ +# PIR Review: Allow directory entries in `worktree.symlinks` via trailing-slash opt-in + +Fixes #805 + +## Summary + +`worktree.symlinks` previously accepted file entries only — directory entries were +silently dropped because the spawn-time symlink loop globbed with `nodir: true`. This +change adds a **trailing-slash opt-in**: an entry like `".local-user-data/"` is treated +as a literal path and symlinked into each new worktree whole, so builders can share a +gitignored runtime-state directory with the parent checkout instead of re-bootstrapping +it. Entries without a trailing slash keep their exact prior behaviour, preserving the +`nodir: true` guard that stops a glob from masking the worktree's own source. + +## Files Changed + +(vs merge-base `b4904bf9`, code + docs) + +- `packages/codev/src/agent-farm/commands/spawn-worktree.ts` (+58 / -14) — branch the + symlinks loop on trailing slash; add `pathOccupied()` helper; `statSync`/`lstatSync` imports +- `packages/codev/src/agent-farm/types.ts` (+16 / -) — `symlinks` field JSDoc documents the opt-in +- `packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts` (+120 / -) — 6 new cases + `node:fs` mock additions +- `CLAUDE.md` (+1 / -1), `AGENTS.md` (+1 / -1) — one-sentence note on the trailing-slash opt-in +- `codev/plans/805-…md`, `codev/state/pir-805_thread.md` — plan + cohort thread (ship with the PR) + +## Commits + +- `43db7bee` [PIR #805] Symlink directory entries via trailing-slash opt-in +- `be62b59b` [PIR #805] Test directory-symlink opt-in (dir type, dangling, idempotency, footgun) +- `23662e4f` [PIR #805] Document trailing-slash directory opt-in in worktree config +- (plan commits: `d91edb11` draft, `6489bac1` revised — dangling-symlink-safe idempotency) + +## Test Results + +- `pnpm build`: ✓ pass +- `pnpm test` (porch `tests` check, full suite): ✓ pass (20.5s) +- `spawn-worktree.test.ts`: ✓ 77 passed (6 new) +- Manual verification: human approved the running worktree at the `dev-approval` gate. + +New test cases: +1. Directory entry symlinks the dir whole, passing the `'dir'` type +2. Dangling link created when the source directory is absent (no throw) +3. Idempotency — skip when a real target dir already exists +4. Idempotency — skip when a *dangling* link already occupies the target (no `EEXIST`) +5. Non-slash directory entry stays filtered (footgun guard intact) +6. Trailing-slash entry with glob metacharacters → warn + skip + +## Architecture Updates + +No `arch.md` changes needed. This change extends the behaviour of one existing helper +(`symlinkConfigFiles`) within the already-documented runnable-worktree setup flow; it +introduces no new module, boundary, or pattern. The runnable-worktree config surface +(`worktree.symlinks` / `postSpawn` / `devCommand`) is already described in CLAUDE.md / +AGENTS.md, which this PR keeps in sync. + +## Lessons Learned Updates + +No `lessons-learned.md` entry added — the one reusable gotcha here is narrow enough to +live in this review rather than the curated lessons file (which MAINTAIN keeps lean): + +> **`existsSync` follows symlinks, so it reports `false` for a dangling link** even +> though the link file occupies the path. Any "create a symlink only if absent" guard +> that relies on `existsSync` will throw `EEXIST` on a re-run once a *dangling* link +> exists. Detect the link itself with `lstatSync` (which does not follow it). This +> matters specifically because directory symlinks here are allowed to dangle (the +> source may be created by runtime tooling after spawn) and `afx setup` re-runs the +> setup idempotently. + +## Things to Look At During PR Review + +- **Literal-path vs glob for trailing-slash entries.** Chosen literal-path because the + acceptance criterion requires a *dangling* link when the source is absent, and a glob + only ever matches existing paths. Trade-off: glob wildcards in a directory entry are + not expanded — `packages/*/data/` would be a literal `*`. Mitigated by a warn-and-skip + when a trailing-slash entry contains glob metacharacters (`/[*?[\]{}!()]/`). +- **`pathOccupied()` / `lstatSync` guard** — the dangling-link idempotency fix (see + Lessons). Test case 4 pins it. +- **Windows dir-symlink type** — `symlinkSync(src, dest, isDir ? 'dir' : undefined)`. + POSIX ignores the type; on Windows it's set only when the source exists at spawn. + Dangling Windows dir-symlinks are best-effort, matching the existing POSIX-leaning code. +- **Footgun guard preserved** — a non-slash entry resolving to a directory is still + filtered by `nodir: true` (test case 5). + +## How to Test Locally + +- **View diff**: VSCode sidebar → right-click builder `pir-805` → **View Diff** +- **Run dev server**: `afx dev pir-805` (or VSCode → **Run Dev Server**) +- **What to verify** (maps to the plan's Test Plan): + - Add `".local-user-data/"` to a test repo's `.codev/config.json` `worktree.symlinks`, + spawn a builder, confirm `/.local-user-data → /.local-user-data` + - A write through the link lands at the parent path + - Source absent at spawn → dangling link created, non-fatal + - A file-only config spawns identically to before (no behaviour change) From c587c315cb0906d703f21dfdd206f78037584448 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:52:55 +1000 Subject: [PATCH 14/19] chore(porch): 805 record PR #947 --- .../805-allow-directory-entries-in-wor/status.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index 01699ca82..680d5c128 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -19,4 +19,9 @@ iteration: 1 build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T03:51:46.246Z' +updated_at: '2026-05-31T03:52:55.600Z' +pr_history: + - phase: review + pr_number: 947 + branch: builder/pir-805 + created_at: '2026-05-31T03:52:55.599Z' From fa11bbae142dd6bfd65a90c6cb8df38d02778747 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 13:53:06 +1000 Subject: [PATCH 15/19] chore(porch): 805 review build-complete --- codev/projects/805-allow-directory-entries-in-wor/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index 680d5c128..de6f3c1aa 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -16,10 +16,10 @@ gates: pr: status: pending iteration: 1 -build_complete: false +build_complete: true history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T03:52:55.600Z' +updated_at: '2026-05-31T03:53:06.768Z' pr_history: - phase: review pr_number: 947 From 9e1ac346ba27db5694c49ad0a734b95d92178470 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 14:01:37 +1000 Subject: [PATCH 16/19] chore(porch): 805 pr gate-requested --- .../projects/805-allow-directory-entries-in-wor/status.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index de6f3c1aa..84de86d2a 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -15,13 +15,15 @@ gates: approved_at: '2026-05-31T03:51:39.244Z' pr: status: pending + requested_at: '2026-05-31T04:01:37.296Z' iteration: 1 -build_complete: true +build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T03:53:06.768Z' +updated_at: '2026-05-31T04:01:37.297Z' pr_history: - phase: review pr_number: 947 branch: builder/pir-805 created_at: '2026-05-31T03:52:55.599Z' +pr_ready_for_human: true From a29d180f19b9882ba19e9d0d5ce281bb589b4453 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 14:01:58 +1000 Subject: [PATCH 17/19] [PIR #805] Record 3-way consultation verdicts (all APPROVE) --- codev/state/pir-805_thread.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/codev/state/pir-805_thread.md b/codev/state/pir-805_thread.md index 4e4642162..f2d135b5a 100644 --- a/codev/state/pir-805_thread.md +++ b/codev/state/pir-805_thread.md @@ -55,5 +55,18 @@ walking up to the main checkout, which broke vitest's config loader and the `@cluesmith/codev-core` subpath import. Ran `pnpm install --frozen-lockfile` + `pnpm --filter @cluesmith/codev-core build` to get a self-contained worktree. -**Tests**: `pnpm build` ✓, spawn-worktree suite 77 passed (6 new). Awaiting full-suite -run via `porch done` checks, then `dev-approval` gate. +**Tests**: `pnpm build` ✓, spawn-worktree suite 77 passed (6 new). Full-suite via +`porch done` checks: build ✓ (6.1s), tests ✓ (20.5s). dev-approval gate approved. + +## Review phase + +Wrote `codev/reviews/805-allow-directory-entries-in-wor.md` (Summary / Files / Commits / +Test Results / Architecture Updates [none — extends one helper] / Lessons Learned +[recorded the `existsSync`-follows-symlinks gotcha in-review, not lessons-learned.md] / +Things to Look At / How to Test). Opened **PR #947**, recorded with porch +(`--pr 947`). Single-pass 3-way consultation: **gemini=APPROVE (HIGH), codex=APPROVE +(MEDIUM), claude=APPROVE (HIGH)** — unanimous, no REQUEST_CHANGES. Gemini's first +attempt failed on a Google API token-quota limit (transient infra, not a verdict); +one retry succeeded. Notified architect. **`pr` gate pending** — waiting for human to +review PR #947 on GitHub and approve the gate; only then do I merge (`gh pr merge +--merge` + `porch done --merged 947`). From 425dd40058479419ded323579564e56aafd09027 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 14:11:34 +1000 Subject: [PATCH 18/19] chore(porch): 805 pr gate-approved --- .../805-allow-directory-entries-in-wor/status.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index 84de86d2a..9f8172ef4 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -14,16 +14,17 @@ gates: requested_at: '2026-05-31T03:32:15.088Z' approved_at: '2026-05-31T03:51:39.244Z' pr: - status: pending + status: approved requested_at: '2026-05-31T04:01:37.296Z' + approved_at: '2026-05-31T04:11:34.590Z' iteration: 1 build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T04:01:37.297Z' +updated_at: '2026-05-31T04:11:34.592Z' pr_history: - phase: review pr_number: 947 branch: builder/pir-805 created_at: '2026-05-31T03:52:55.599Z' -pr_ready_for_human: true +pr_ready_for_human: false From c57a9fdccc72a8b120f483d8604b35a25e86d836 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sun, 31 May 2026 14:11:43 +1000 Subject: [PATCH 19/19] chore(porch): 805 protocol complete --- codev/projects/805-allow-directory-entries-in-wor/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/805-allow-directory-entries-in-wor/status.yaml b/codev/projects/805-allow-directory-entries-in-wor/status.yaml index 9f8172ef4..76e356201 100644 --- a/codev/projects/805-allow-directory-entries-in-wor/status.yaml +++ b/codev/projects/805-allow-directory-entries-in-wor/status.yaml @@ -1,7 +1,7 @@ id: '805' title: allow-directory-entries-in-wor protocol: pir -phase: review +phase: verified plan_phases: [] current_plan_phase: null gates: @@ -21,7 +21,7 @@ iteration: 1 build_complete: false history: [] started_at: '2026-05-31T03:14:03.261Z' -updated_at: '2026-05-31T04:11:34.592Z' +updated_at: '2026-05-31T04:11:43.163Z' pr_history: - phase: review pr_number: 947