Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions .add/state.json
Original file line number Diff line number Diff line change
Expand Up @@ -9326,10 +9326,50 @@
"email": "tindang.ht97@gmail.com",
"source": "git"
}
},
"installer-shared-namespace-guard": {
"title": "Installer: .claude/agents is a shared namespace \u2014 never clean-replace it whole",
"phase": "done",
"gate": "PASS",
"milestone": "six-phase-loop",
"depends_on": [],
"created": "2026-07-14T05:19:59+00:00",
"updated": "2026-07-14T05:38:57+00:00",
"fast": true,
"freeze": {
"version": "v1",
"frozen_at": "2026-07-14T05:21:55+00:00",
"approved_by": "Tin Dang",
"actor": {
"name": "Tin Dang",
"email": "tindang.ht97@gmail.com",
"source": "git"
}
},
"flag_verified": true,
"tripwire": {
"contract_md5": "67f269a177c87328091886ba51da9ea4",
"tests": {
"add-method/tooling/test_installer_shared_namespace.py": "66d3417d56dad26dbf0de369c7dbd9c8"
}
},
"scope": {
"declared": [
"add-method/bin/cli.js",
"add-method/src/add_method/_installer.py",
"add-method/tooling/"
],
"snapshot_md5": "98ebf94ca83d7cc9f2919d99ffe40f03"
},
"gate_actor": {
"name": "Tin Dang",
"email": "tindang.ht97@gmail.com",
"source": "git"
}
}
},
"created": "2026-05-28T15:39:04+00:00",
"updated": "2026-07-14T04:33:38+00:00",
"updated": "2026-07-14T05:39:19+00:00",
"milestones": {
"v1-1": {
"title": "v1.1 \u2014 adoption & ergonomics",
Expand Down Expand Up @@ -10609,7 +10649,7 @@
"plan-legibility": "relations-surface",
"ceremony-to-effort": "fold-draft-at-close",
"call-floor": "skill-orient-split",
"six-phase-loop": "build-entry-spec-echo"
"six-phase-loop": "installer-shared-namespace-guard"
},
"todos": [
{
Expand Down Expand Up @@ -10809,6 +10849,12 @@
"text": "lock-reclaim TOCTOU race: test_concurrent_stale_reclaim_survives_scheduling_delay observed peak=2 holders twice on py3.10 CI (also the older test_project_scope_lock sibling) \u2014 the reclaim window is real on slow runners; harden the ticket-race path or gate the reclaim on a holder heartbeat",
"created": "2026-07-13T11:37:30+00:00",
"status": "open"
},
{
"id": 34,
"text": "bench meter: the judge rubric's 'single float and nothing else' protocol caps spec_fidelity at ~0.98 (LLM hedging artifact \u2014 the SAME sonnet judge forced to itemize MET/UNMET with evidence scored sixphase-r1 1.0, all requirements MET); meter v3 candidate = itemize-then-float rubric, but changing it breaks comparability with every archived round",
"created": "2026-07-14T04:38:54+00:00",
"status": "open"
}
]
}
103 changes: 103 additions & 0 deletions .add/tasks/installer-shared-namespace-guard/TASK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# TASK: Installer: .claude/agents is a shared namespace — never clean-replace it whole

slug: installer-shared-namespace-guard · created: 2026-07-14 · stage: mvp
milestone: six-phase-loop
autonomy: auto
phase: done
fast: true

> Fast lane — one small task, minimal sections, filled top-to-bottom. The trust floor still
> holds: a FROZEN §3 contract · ≥1 red test before build · a recorded §6 gate (security = HARD-STOP).
> The acceptance scenario collapses into §1 `Accept:`; the observe note is one optional line at the gate.

---

## 1 · SPECIFY — the rules

> Project the expectations from the milestone Ground + this request — light, not re-invented.
Feature: installer shared-namespace guard — .claude/agents holds the USER's own subagents; ADD's install/update may own only its shipped roster files there, never the directory (bug: clean-replace swept every non-ADD agent as an orphan)
Must:
- a foreign file in .claude/agents (any name not shipped and not tombstoned) survives init AND update byte-identical, in BOTH installers (npm bin/cli.js + pip _installer.py)
- shipped roster files still land/refresh (bytes == packaged agents/ copy), each via a per-file temp-sibling + rename (atomic overwrite; dest dir created when missing)
- upstream-retired roster names are removed ONLY via an explicit tombstone list (empty today) — no heuristic sweep, no prefix-based deletion
- every other managed tree keeps its whole-dir clean-replace semantics byte-unchanged (orphans there still swept)
Reject:
- clean-replacing .claude/agents as a whole directory -> "data_loss" (the reported bug — user agents deleted as orphans); never again
- deleting by name pattern (e.g. add-*.md heuristic) -> forbidden; a user file named add-anything.md survives unless explicitly tombstoned
Accept: Given a project with .claude/agents/my-custom.md and a modified add-verify.md, When cli.js update (and the pip twin) runs, Then my-custom.md is byte-identical, add-verify.md matches the shipped copy, and a docs-tree orphan is still swept.
Boundary: three destination states x two installers — fresh (no .claude/agents: dir created, roster lands) · populated with foreign files (all survive) · a tombstoned name present (removed)
Assumptions: ⚠ no OTHER code path clean-replaces .claude/agents outside the MANAGED-table loops (heal/reconcile/global restore route through the same helpers) — why: grep shows :690/:1466 (py) + dropFiles/update (js) as the call sites; if wrong: the fence names it, the same shared-branch applies there (cost: one more call site)

---

## 3 · PLAN — the change plan: ground · contract · build-strategy

### Grounding
Touches (files · symbols): add-method/bin/cli.js:MANAGED (:755 agents row) + cleanReplaceTree call sites (dropFiles + update loop) · add-method/src/add_method/_installer.py:MANAGED (:35) + _clean_replace call sites (:690, :1466) · NEW sharedFileReplace (js) / _shared_file_replace (py)
Context (working folder): npm<->pip parity comments bind the twins ("Mirror of _installer.py:_clean_replace"); this repo's own .claude/agents is dogfood evidence (roster + the user's 12+ own agents)
Honors (patterns / conventions): per-file temp-sibling + rename (the cleanReplaceTree staging idiom, applied per file) · design-for-failure (a crashed landing leaves dest valid) · explicit-tombstone-over-heuristic (the delta-evidence discipline) · Seams consulted: .add/SEAMS.md
Anchors the contract cites: MANAGED · cleanReplaceTree · _clean_replace
Ground SHA: e2cde5c — stamped by freeze

### Contract

```
SHARED namespace set (both installers; today exactly {"agents"}):
a MANAGED tree whose destination is a directory OTHER TOOLS also write.
install/update routes it to the per-file lander instead of the dir swap:

js: sharedFileReplace(src, dest) -> {restored, refreshed}
py: _shared_file_replace(src, dest) -> {"restored": n, "refreshed": n}
- mkdir dest if missing
- per shipped file: copy to dest/<name>.add-tmp-* then rename onto
dest/<name> (atomic overwrite; refreshed if it existed, restored if not)
- remove ONLY names in RETIRED_AGENTS / _RETIRED_AGENTS (explicit list,
empty today); every other destination file is never opened
non-shared MANAGED trees: cleanReplaceTree/_clean_replace UNCHANGED
```

`Least-sure flag surfaced at freeze:` [contract] the update path may report per-tree file counts a fixed harness test pins (restored/refreshed roll-up shape) — why: cleanReplaceTree's return is consumed by reporting code I haven't fully traced; if wrong: the shared lander returns the same {restored, refreshed} shape so the consumer is agnostic (cost: small re-read, no shape change)
Status: FROZEN @ v1 — approved by Tin Dang
### Build-strategy
Scope (may touch): `add-method/bin/cli.js` `add-method/src/add_method/_installer.py` `add-method/tooling/`
Strategy & known-problem fixes: 1) red test_installer_shared_namespace.py (subprocess fixtures mirroring test_installer_handoff) 2) py _shared_file_replace + route :690/:1466 3) js sharedFileReplace + route dropFiles/update 4) parity comments updated 5) fence; traps: BOTH init and update paths must route (two call sites in py) · the .claude/skills/add dest is already namespaced (NOT shared — do not touch) · keep {restored, refreshed} roll-up shape for the reporting consumer
Approach (domain strategy): ownership-scoped writes in shared namespaces — per-file atomic rename, explicit tombstones over heuristics, correctness-first

### AI-verify record (required when gate_mode: ai-plan-verify)
- [ ] §3 PLAN grounding anchors resolve in the current tree
- [ ] §1 every Must + every Reject present, each Reject paired with an error code
- [ ] §3 Contract shape is concrete (no template placeholder text remains)
- [ ] Lowest-confidence flag surfaced and substantive (mirrors unflagged_freeze's own bar)
Verified by: <agent-id> · at: <ISO-8601 UTC timestamp>

---

## 4 · TESTS — failing-first (red)

Plan: test_installer_shared_namespace.py — foreign-file survival through init+update x both installers (subprocess, scrubbed env) · roster files land/refresh to shipped bytes · fresh dir created · tombstone mechanism removes a listed name (py unit-level with an injected list) · a non-shared tree (docs) still sweeps an orphan · user file named add-custom.md survives (no prefix heuristic).
Tests live in: `add-method/tooling/test_installer_shared_namespace.py` · MUST run red before Build.

---

## 5 · BUILD — AI writes the code (execution)

> The change plan was frozen in §3 PLAN. Build to it: honor the §3 Build-strategy Scope; improve on the strategy if the code teaches you better.
Strategy actually used: as planned — _SHARED/_RETIRED_AGENTS + _shared_file_replace routed in _reconcile (py; init AND update both funnel through it), SHARED/RETIRED_AGENTS + sharedFileReplace routed in reconcile (js); GLOBAL_TREES excludes agents in both so no other call site; roll-up shape kept
Code lives in: `./src/` · Constraints: change no test, no frozen contract; stay inside the §3 Build-strategy Scope; allow-list packages only.

---

## 6 · VERIFY — evidence + gate

- [x] all tests pass · coverage held · no test or contract altered during build
- [x] green was EARNED — no overfit / vacuous asserts / stubbed-away logic
- [x] input dialect held — tests speak the spec's example formats (spec-dialect floor)
- [x] no exposed secrets, injection openings, or unexpected dependencies (security = HARD-STOP)

Build expectations (from §1 Accept + §3 CONTRACT): after update, .claude/agents contains the user's files byte-identical + the shipped roster refreshed, while .add/docs orphans are still swept — confirmed by test_installer_shared_namespace.py + the full fence

### GATE RECORD
Outcome: PASS
Reviewed by: Tin Dang · date: 2026-07-14
[SPEC · open] `update --global` propagation never refreshes the roster: agents are absent from _GLOBAL_TREES/GLOBAL_TREES, so the home mirror soft-skips them — user agents SAFE (verified live: my-precious.md survived, stale add-verify.md kept its local edit), but a roster refresh needs a project-level `update`. Candidate follow-up: add agents to the global mirror, routed through the shared lander. Also observed: the opt-in --global-data snapshot excludes .claude/agents (a MANAGED dest), so user agents are not backed up by it. (evidence: global-audit live run 2026-07-14)

44 changes: 43 additions & 1 deletion add-method/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,16 @@ const MANAGED = [
// _installer.py:OPTIONAL. Design-for-failure. `agents` joins here (roster-install-drift): the
// phase-agent roster is a spawn-acceleration enhancement, not core runtime.
const OPTIONAL = new Set(["personas-teacher", "agents"]);
// SHARED-namespace managed trees (installer-shared-namespace-guard): destinations OTHER
// TOOLS also write — `.claude/agents` holds the user's own Claude Code subagents. A
// whole-dir clean-replace there sweeps the user's files as "orphans" (the reported
// data-loss bug), so these trees route to sharedFileReplace: per-file atomic landings of
// the shipped files + removal of ONLY the explicit tombstones below. Twin of
// _installer.py:_SHARED / _RETIRED_AGENTS.
const SHARED = new Set(["agents"]);
// Roster names retired upstream — the ONLY names the shared lander may remove (never a
// pattern/prefix heuristic: a USER file named add-anything.md must survive). Empty today.
const RETIRED_AGENTS = [];
const STAMP_FILE = ".add-version";
const LOCK_FILE = ".update.lock"; // the `update --global` home lock (never user-data)
const LOCK_STALE_DEFAULT = 600; // seconds (10 min); ADD_LOCK_STALE_SECONDS env-overridable
Expand Down Expand Up @@ -962,6 +972,36 @@ function managedStatus(target) {
return status;
}

// installer-shared-namespace-guard: land a SHARED-namespace managed tree per FILE.
// `.claude/agents` belongs to the user as much as to ADD — only the shipped files are
// written (temp-sibling + atomic rename each, so a crash never leaves a torn file) and
// only the explicit RETIRED_AGENTS tombstones are removed; every other destination file
// is never opened. Returns the same { restored, refreshed } roll-up shape as
// cleanReplaceTree so the reconcile reporting is agnostic. Mirror of
// _installer.py:_shared_file_replace.
function sharedFileReplace(src, dest) {
fs.mkdirSync(dest, { recursive: true });
let restored = 0, refreshed = 0;
const entries = fs.readdirSync(src).filter((n) => fs.statSync(path.join(src, n)).isFile()).sort();
for (const name of entries) {
const target = path.join(dest, name);
const existed = fs.existsSync(target);
const tmp = path.join(dest, name + ".add-tmp-" + Math.random().toString(36).slice(2, 10));
try {
fs.copyFileSync(path.join(src, name), tmp);
fs.renameSync(tmp, target); // atomic overwrite (same directory)
} catch (e) {
fs.rmSync(tmp, { force: true }); // dest entry untouched or already whole
throw e;
}
if (existed) refreshed++; else restored++;
}
for (const name of RETIRED_AGENTS) {
fs.rmSync(path.join(dest, name), { force: true }); // absent tombstone — never fatal
}
return { restored: restored, refreshed: refreshed };
}

// reconcile: restore-missing + refresh-present (sweep orphans) across the managed trees,
// reporting per-tree status. Honors --no-skill (the plugin provides the skill). Touches
// ONLY managed trees — never user data. Prechecks ALL sources first (design-for-failure:
Expand All @@ -979,7 +1019,9 @@ function reconcile(args, target, srcRoot) {
const status = managedStatus(target);
let restored = 0, refreshed = 0;
for (const [sub, destParts, stripTests] of trees) {
const roll = cleanReplaceTree(path.join(srcRoot, sub), path.join(target, ...destParts), stripTests);
const roll = SHARED.has(sub)
? sharedFileReplace(path.join(srcRoot, sub), path.join(target, ...destParts))
: cleanReplaceTree(path.join(srcRoot, sub), path.join(target, ...destParts), stripTests);
restored += roll.restored;
refreshed += roll.refreshed;
const dest = destParts.join("/");
Expand Down
45 changes: 44 additions & 1 deletion add-method/src/add_method/_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@
# enhancement — the CLI+skill loop is fully usable without it, and an older/malformed package
# predating this fix must still install its core cleanly.
OPTIONAL = frozenset({"personas-teacher", "agents"})
# SHARED-namespace managed trees (installer-shared-namespace-guard): destinations OTHER
# TOOLS also write — `.claude/agents` holds the user's own Claude Code subagents. A
# whole-dir clean-replace there sweeps the user's files as "orphans" (the reported
# data-loss bug), so these trees route to _shared_file_replace: per-file atomic landings
# of the shipped files + removal of ONLY the explicit tombstones below. Mirrored in cli.js.
_SHARED = frozenset({"agents"})
# Roster names retired upstream — the ONLY names the shared lander may remove (never a
# pattern/prefix heuristic: a USER file named add-anything.md must survive). Empty today.
_RETIRED_AGENTS = ()
STAMP_FILE = ".add-version" # records the materialized version, under .add/
# Forward-only, idempotent state migrations keyed by the version that introduces them.
# Empty today — the framework exists so the NEXT schema change is an in-place update,
Expand Down Expand Up @@ -1440,6 +1449,37 @@ def _clean_replace(src: Path, dest: Path, *, strip_tests: bool = False) -> dict:
"personas-teacher": "personas"}


def _shared_file_replace(src: Path, dest: Path) -> dict:
"""installer-shared-namespace-guard: land a SHARED-namespace managed tree per FILE.
`.claude/agents` belongs to the user as much as to ADD — only the shipped files are
written (temp-sibling + atomic os.replace each, so a crash never leaves a torn file)
and only the explicit _RETIRED_AGENTS tombstones are removed; every other destination
file is never opened. Returns the same {"restored", "refreshed"} roll-up shape as
_clean_replace so the _reconcile reporting is agnostic. Mirror of cli.js:sharedFileReplace."""
dest.mkdir(parents=True, exist_ok=True)
restored = refreshed = 0
for f in sorted(p for p in src.iterdir() if p.is_file()):
target = dest / f.name
existed = target.exists()
tmp = dest / f"{f.name}.add-tmp-{uuid.uuid4().hex[:8]}"
try:
shutil.copyfile(f, tmp)
os.replace(str(tmp), str(target)) # atomic on POSIX + Windows (same filesystem)
except BaseException:
tmp.unlink(missing_ok=True) # dest entry untouched or already whole
raise
if existed:
refreshed += 1
else:
restored += 1
for name in _RETIRED_AGENTS:
try:
(dest / name).unlink()
except OSError:
pass # absent (or unremovable) tombstone — never fatal
return {"restored": restored, "refreshed": refreshed}


def _managed_status(target_path: Path) -> dict:
"""Per managed tree: 'missing' (dest absent OR empty) or 'present'."""
status = {}
Expand All @@ -1463,7 +1503,10 @@ def _reconcile(target_path: Path, bundled_root: Path) -> dict:
for sub, dest_rel, strip in MANAGED:
if sub in OPTIONAL and not (bundled_root / sub).exists():
continue # optional enhancement absent from this package — nothing to materialize
roll = _clean_replace(bundled_root / sub, target_path / dest_rel, strip_tests=strip)
if sub in _SHARED:
roll = _shared_file_replace(bundled_root / sub, target_path / dest_rel)
else:
roll = _clean_replace(bundled_root / sub, target_path / dest_rel, strip_tests=strip)
restored += roll["restored"]
refreshed += roll["refreshed"]
if status[sub] == "missing":
Expand Down
Loading