Skip to content

Commit 7ce8cc5

Browse files
sriumcpclaude
andauthored
feat: NOUS_CAMPAIGN_PARENT env var relocates campaign artifacts outside target repo (AI-native-Systems-Research#239) (AI-native-Systems-Research#240)
* feat: NOUS_CAMPAIGN_PARENT env var relocates campaign artifacts outside target repo (AI-native-Systems-Research#239) Closes AI-native-Systems-Research#239. Today's friction: campaign work_dirs default to `<target_repo>/.nous/<run_id>/`, where they live as **untracked files** in the target repo's working tree. This causes: - `git stash -u` silently captures multi-GB of campaign output (recently caused 4.7 GB of ea-control-stack JSONs to be stashed; recovery required digging through `stash@{0}^3` and unstaging 60+ files) - `git status` clutter from thousands of untracked entries - `git add .` and `git checkout <commit> -- <path>` silently stage campaign content - PR review noise Fix: 1. Add `NOUS_CAMPAIGN_PARENT` env var. When set, work_dir lives at `$NOUS_CAMPAIGN_PARENT/<run_id>/`, fully outside the target. 2. Add `work_dir` field to state.json — per-campaign source of truth for location, robust to env var changes between runs. 3. Worktrees are NOT affected — they still live at `<target>/.nous-experiments/<run>/<arm>/` per AI-native-Systems-Research#133, because they ARE code FOR the target repo and must share its git history. Changes: - New `orchestrator/work_dir_resolver.py` (73 LOC): single source of truth for "where would this run_id live, given today's environment?" - `orchestrator/iteration.py:setup_work_dir` uses the resolver and records the resolved absolute path in state.json's new `work_dir` field. - `orchestrator/cli.py:resolve_work_dir` and `_cmd_run` use the same resolver — keeps all three call sites consistent (avoids the silent "work_dir mismatch" trap from AI-native-Systems-Research#184). - `orchestrator/templates/state.json` adds `work_dir: null` (set on setup). - `orchestrator/schemas/state.schema.json` extends to permit `work_dir`. - `orchestrator/create_campaign.py:_TEMPLATE` documents the env var for new campaigns. - `README.md` adds a recommended-setup section. - `tests/test_work_dir_resolver.py`: 12 behavioral tests covering both env-var-set and env-var-unset paths, state.json record robustness, expanduser, idempotency. No live LLM calls (per CLAUDE.md). Backward-compat: when `NOUS_CAMPAIGN_PARENT` is unset, behavior is byte-identical to today's legacy `<repo>/.nous/<run_id>/`. All 1,195 existing tests still pass. Out of scope (per-user / per-campaign one-time work): - Migrating existing campaigns: `mv <target>/.nous/<name> $NOUS_CAMPAIGN_PARENT/<name>` - Updating per-campaign internal scripts to self-locate via __file__ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address PR review: harden NOUS_CAMPAIGN_PARENT resolver + close in-progress detection trap (AI-native-Systems-Research#239) Comprehensive fixup addressing all 12 issues identified by review agents. CRITICAL fixes: - C1 / silent-failure-hunter C3: `_cmd_run` now uses `find_existing_work_dir` to check BOTH legacy and env-var locations for in-progress detection. Fixes the regression where toggling NOUS_CAMPAIGN_PARENT between runs could silently allow a parallel run that corrupts shared worktrees. - C2 / silent-failure-hunter C1: empty/whitespace `NOUS_CAMPAIGN_PARENT` now raises ValueError instead of silently falling through to legacy. Bash `export NOUS_CAMPAIGN_PARENT=$UNSET` is a common typo that the user explicitly tried to opt out of. - C3 / pr-test-analyzer gap AI-native-Systems-Research#2: `tests/conftest.py` autouse fixture now strips `NOUS_CAMPAIGN_PARENT` so existing tests don't fail for contributors who follow README's recommendation to export it. - C4 / comment-analyzer AI-native-Systems-Research#1: README "byte-identical" claim corrected; scope tightened to "the resolved work_dir is unchanged". IMPORTANT fixes: - I1 / pr-test-analyzer gap AI-native-Systems-Research#1: 4 new CLI tests cover yaml + bare-run-id with env-var, including the test that would have caught C1. - I2 / comment-analyzer AI-native-Systems-Research#3 / silent-failure-hunter C4: README adds migration command (`mv <repo>/.nous/<run> $NOUS_CAMPAIGN_PARENT/<run>`). `_cmd_run` emits a migration hint when a campaign is found at the legacy path while env var is set. - I3 / silent-failure-hunter I5: state.json's `work_dir` field is now read by `find_existing_work_dir` — prefers the recorded path when it points to an existing campaign elsewhere (handles post-creation `mv`). - I4 / comment-analyzer AI-native-Systems-Research#10: `cli.resolve_work_dir` legacy branch delegates to the resolver via `find_existing_work_dir` instead of reimplementing path logic. - I5 / silent-failure-hunter I1: `repo_path` validated for existence in resolver; raises FileNotFoundError on typos. - I6 / code-reviewer AI-native-Systems-Research#2: explicit `Path(...)` wrap restored at CLI boundary for type robustness. - I7 / pr-test-analyzer gap AI-native-Systems-Research#3: schema validator regression test added; state.json shape verified post-setup. - I8 / silent-failure-hunter D1: collision detection. `setup_work_dir` refuses to clobber a same-named campaign at the env-var path that records a different `repo_path`. Adds `repo_path` field to state.json schema. Suggestion fixes: - comment-analyzer AI-native-Systems-Research#2: Resolution rules consolidated in `work_dir_resolver.py:RESOLUTION RULES` marker comment; other docstrings reference it. Reduces drift across 5 places. - comment-analyzer AI-native-Systems-Research#5: schema description tightened (drop implementation detail; mark fields as machine-local). - comment-analyzer AI-native-Systems-Research#7: `_TEMPLATE` bullet structure restructured for unambiguous conditional reading. - comment-analyzer AI-native-Systems-Research#4: cross-machine portability story documented in schema descriptions (machine-local with caveat). - comment-analyzer AI-native-Systems-Research#8: README line 166 (post-AI-native-Systems-Research#239) updated to reflect the conditional default. - silent-failure-hunter I2: setup_work_dir wraps mkdir with OSError surfacing env-var context. - silent-failure-hunter I4: `_find_repo_root` failure message now mentions `NOUS_CAMPAIGN_PARENT` as an alternative. - pr-test-analyzer suggestions: edge-case tests added (whitespace env var, trailing slash, env-var-change scenario, corrupt state.json, recorded-path-wins, recorded-path-nonexistent-falls-back). Schema additions: - `state.json` gains `work_dir` and `repo_path` fields (both nullable strings; absolute paths recorded at setup_work_dir). - `state.schema.json` extends with both fields and machine-local caveats in descriptions. Test count: - Before: 12 tests (work_dir_resolver only) - After: 32 tests covering resolver + setup + CLI + schema validation - Full suite: 1,215 pass, 2 skip (was 1,195/2/0 — 20 new tests, 0 regressions). Backward compatibility preserved: - `NOUS_CAMPAIGN_PARENT` unset → resolved path is unchanged - Pre-AI-native-Systems-Research#239 campaigns at `<repo>/.nous/<run>/` continue to be found by `find_existing_work_dir` even when env var is set (migration grace). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d5764ce commit 7ce8cc5

9 files changed

Lines changed: 975 additions & 36 deletions

File tree

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,30 @@ export OPENAI_BASE_URL=https://your-litellm-proxy.example.com # or any OpenAI-c
7979

8080
If you're using Anthropic directly via a LiteLLM proxy, point both vars at the proxy. If these aren't set, gate summaries and report generation are skipped (non-fatal). The campaign still runs — you just won't get LLM-generated summaries at the gates or a final report.
8181

82+
**Recommended: relocate campaign artifacts outside the target repo (#239).**
83+
84+
By default, Nous creates each campaign's working directory at `<target_repo>/.nous/<run_id>/`. That puts campaign output (state, ledger, principles, JSON results, findings) inside the target repo's working tree as untracked files — which means `git stash -u` silently captures them, `git status` shows thousands of unrelated entries, and `git add .` accidentally stages campaign content. To avoid this, set:
85+
86+
```bash
87+
# Add to your shell rc (.zshrc / .bashrc):
88+
export NOUS_CAMPAIGN_PARENT=~/Documents/Projects/nous-campaigns
89+
```
90+
91+
When `NOUS_CAMPAIGN_PARENT` is set, campaign artifacts live at `$NOUS_CAMPAIGN_PARENT/<run_id>/` — wholly outside the target repo. Code worktrees (per-arm BLIS branches, #133) continue to live at `<target>/.nous-experiments/<run_id>/<arm>/` because they ARE code FOR the target. The target repo's working tree stays clean. Backward-compat: when the env var is unset, the resolved `work_dir` is unchanged — `<repo_path>/.nous/<run_id>/`, exactly as today.
92+
93+
The resolved absolute `work_dir` and `repo_path` are recorded in each campaign's `state.json` so the campaign location and target survive env-var changes between runs. `nous resume` and `nous status` look up campaigns at both the env-var location AND the legacy location, so existing pre-#239 campaigns continue to work without immediate migration.
94+
95+
**Migrating existing campaigns** to the new location is a one-time operation per campaign:
96+
97+
```bash
98+
# 1. Set the env var first (in your shell rc).
99+
# 2. Move existing campaign(s):
100+
mv <target_repo>/.nous/<run_id> $NOUS_CAMPAIGN_PARENT/<run_id>
101+
# 3. Continue using the campaign as before. Nous will find it at the new location.
102+
```
103+
104+
state.json's recorded `work_dir` will be stale until the campaign's next setup; that's fine — `find_existing_work_dir` checks the actual directory existence, not just state.json's recorded path.
105+
82106
### 1. Install Nous
83107

84108
```bash
@@ -139,7 +163,7 @@ target_system:
139163
repo_path: /path/to/your/repo
140164
```
141165
142-
When `repo_path` is set, the campaign directory is created inside the target repo at `.nous/<run_id>/`. All artifacts live there.
166+
When `repo_path` is set, the campaign directory is created at `$NOUS_CAMPAIGN_PARENT/<run_id>/` if you've set that env var (recommended — see [Environment setup](#environment-setup) above), or otherwise at the legacy `<target_repo>/.nous/<run_id>/`. All artifacts live there.
143167

144168
To discover the full schema (required vs optional fields, descriptions
145169
verbatim from the schema source), run:

orchestrator/cli.py

Lines changed: 106 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,33 @@ def _find_repo_root(start=None):
1414
if parent == current:
1515
break
1616
current = parent
17-
print("Could not find .nous/ directory in any parent", file=sys.stderr)
17+
print(
18+
f"Could not find .nous/ directory in any parent of {Path.cwd()}. "
19+
f"Either run from inside the target repo, pass an explicit "
20+
f"work_dir path, or set NOUS_CAMPAIGN_PARENT (#239).",
21+
file=sys.stderr,
22+
)
1823
sys.exit(1)
1924

2025

2126
def resolve_work_dir(target):
27+
"""Resolve a CLI ``target`` (yaml path | dir | run_id) to a work_dir.
28+
29+
Honors NOUS_CAMPAIGN_PARENT (#239) and finds existing campaigns
30+
that may live at the legacy ``<repo>/.nous/<run_id>/`` path even
31+
when the env var is set (so users with pre-#239 campaigns can still
32+
run ``nous status`` / ``nous resume`` without first migrating).
33+
34+
For an explicit dir target with state.json, the dir is taken at
35+
face value.
36+
"""
37+
import os
38+
39+
from orchestrator.work_dir_resolver import (
40+
ENV_VAR,
41+
find_existing_work_dir,
42+
)
43+
2244
if target.endswith(".yaml") or target.endswith(".yml"):
2345
p = Path(target)
2446
if not p.exists():
@@ -33,12 +55,32 @@ def resolve_work_dir(target):
3355
print(f"Campaign file {target} is empty or not a YAML mapping", file=sys.stderr)
3456
sys.exit(1)
3557
try:
36-
repo_path = Path(data["target_system"]["repo_path"])
58+
# Wrap in Path() for type-robustness: surfaces a clear error
59+
# immediately if repo_path is the wrong type (e.g. an int
60+
# from a hand-edited yaml) rather than failing later in the
61+
# resolver with a less helpful message.
62+
repo_path = (
63+
Path(data["target_system"]["repo_path"])
64+
if data["target_system"].get("repo_path") is not None
65+
else None
66+
)
3767
run_id = data["run_id"]
3868
except (KeyError, TypeError) as exc:
3969
print(f"Campaign file {target} missing required field: {exc}", file=sys.stderr)
4070
sys.exit(1)
41-
work_dir = repo_path / ".nous" / run_id
71+
try:
72+
work_dir = find_existing_work_dir(run_id, repo_path)
73+
except (ValueError, FileNotFoundError) as exc:
74+
print(f"Campaign location resolution failed: {exc}", file=sys.stderr)
75+
sys.exit(1)
76+
if work_dir is None:
77+
print(
78+
f"Work directory not found for run_id={run_id!r} "
79+
f"(checked {ENV_VAR} location and "
80+
f"{repo_path!s}/.nous/{run_id}/ if applicable).",
81+
file=sys.stderr,
82+
)
83+
sys.exit(1)
4284
return work_dir
4385

4486
p = Path(target)
@@ -50,10 +92,29 @@ def resolve_work_dir(target):
5092
sys.exit(1)
5193

5294
run_id = target
53-
root = _find_repo_root()
54-
work_dir = root / ".nous" / run_id
55-
if not work_dir.is_dir():
56-
print(f"Work directory not found: {work_dir}", file=sys.stderr)
95+
# Bare run_id: prefer find_existing_work_dir (env-var path), then
96+
# fall back to CWD-walk for the legacy invocation pattern.
97+
work_dir = None
98+
try:
99+
work_dir = find_existing_work_dir(run_id, repo_path=None)
100+
except ValueError as exc:
101+
print(f"Campaign location resolution failed: {exc}", file=sys.stderr)
102+
sys.exit(1)
103+
if work_dir is None and not os.environ.get(ENV_VAR):
104+
# No env var set: fall through to legacy CWD-walk for
105+
# backward-compat with `nous status <run_id>` invoked from
106+
# inside the target repo.
107+
root = _find_repo_root()
108+
candidate = root / ".nous" / run_id
109+
if candidate.is_dir():
110+
work_dir = candidate
111+
if work_dir is None:
112+
print(
113+
f"Work directory not found for run_id={run_id!r}. "
114+
f"Either run from inside the target repo, pass an explicit "
115+
f"work_dir path, or set {ENV_VAR}.",
116+
file=sys.stderr,
117+
)
57118
sys.exit(1)
58119
return work_dir
59120

@@ -88,20 +149,44 @@ def _cmd_run(args):
88149
run_id = args.run_id or campaign.get("run_id") or (campaign_path.parent.name + "-run")
89150
repo_path = campaign["target_system"].get("repo_path")
90151

91-
if repo_path:
92-
state_path = Path(repo_path) / ".nous" / run_id / "state.json"
93-
if state_path.exists():
94-
state = json.loads(state_path.read_text())
95-
# #236: read via helper so legacy ``phase`` keys still resolve.
96-
from orchestrator.engine import read_phase_field
97-
phase = read_phase_field(state)
98-
if phase != "INIT":
99-
print(
100-
f"Run '{run_id}' already in progress (phase={phase}). "
101-
f"Use 'nous resume' to continue.",
102-
file=sys.stderr,
103-
)
104-
sys.exit(1)
152+
# #239: in-progress detection must check BOTH the legacy and
153+
# env-var locations — otherwise toggling NOUS_CAMPAIGN_PARENT
154+
# between runs would silently allow a parallel run that corrupts
155+
# shared worktrees. find_existing_work_dir consults all candidates
156+
# plus state.json's recorded work_dir.
157+
import os as _os
158+
159+
from orchestrator.work_dir_resolver import (
160+
ENV_VAR,
161+
find_existing_work_dir,
162+
)
163+
try:
164+
existing = find_existing_work_dir(run_id, repo_path)
165+
except (ValueError, FileNotFoundError) as exc:
166+
print(f"Campaign location resolution failed: {exc}", file=sys.stderr)
167+
sys.exit(1)
168+
if existing is not None:
169+
state = json.loads((existing / "state.json").read_text())
170+
# #236: read via helper so legacy ``phase`` keys still resolve.
171+
from orchestrator.engine import read_phase_field
172+
phase = read_phase_field(state)
173+
if phase != "INIT":
174+
print(
175+
f"Run '{run_id}' already in progress at {existing} "
176+
f"(phase={phase}). Use 'nous resume' to continue.",
177+
file=sys.stderr,
178+
)
179+
sys.exit(1)
180+
# Migration hint: if env var is set but the existing campaign
181+
# lives at the legacy location, point the user at `mv`.
182+
if _os.environ.get(ENV_VAR) and ".nous" in existing.parts:
183+
print(
184+
f"Note: campaign found at legacy location {existing}. "
185+
f"To migrate to {ENV_VAR}: "
186+
f"`mv {existing} ${ENV_VAR}/{run_id}` and re-run. "
187+
f"Continuing at the legacy location for now.",
188+
file=sys.stderr,
189+
)
105190

106191
work_dir = setup_work_dir(run_id, repo_path=repo_path)
107192

orchestrator/create_campaign.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,27 @@
119119
- "TODO: replace with a real knob name"
120120
- "TODO: replace with a real knob name"
121121
122-
# Path to the target system's git repo. When set, the orchestrator
123-
# creates the campaign directory at <repo_path>/.nous/<run_id>/ and
124-
# uses worktree isolation per arm (#133). Set to null only if you
125-
# plan to override on the CLI; running `nous run` from a different
126-
# CWD will silently land artifacts in the wrong place (#184).
122+
# Path to the target system's git repo. Used for two distinct things
123+
# (#239 keeps them cleanly separated):
124+
#
125+
# 1. Code worktrees per arm (#133) live at
126+
# <repo_path>/.nous-experiments/<run_id>/<arm>/. Always —
127+
# they ARE code FOR the target repo.
128+
#
129+
# 2. Campaign artifacts (state, ledger, principles, findings, JSON
130+
# results) live at $NOUS_CAMPAIGN_PARENT/<run_id>/ if you've
131+
# set that env var (recommended — see below); otherwise at the
132+
# legacy <repo_path>/.nous/<run_id>/, which pollutes the
133+
# target's git status (#239).
134+
#
135+
# Recommended setup: export NOUS_CAMPAIGN_PARENT=~/Documents/Projects/nous-campaigns
136+
# in your shell rc. Campaign artifacts then live outside the target,
137+
# cleanly separated from regular development. The target's git status
138+
# stays clean; `git stash -u` won't capture campaign output.
139+
#
140+
# Set repo_path to null only if you plan to override on the CLI;
141+
# running `nous run` from a different CWD will silently land artifacts
142+
# in the wrong place (#184).
127143
repo_path: {repo_path}
128144
129145
prompts:

orchestrator/iteration.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import argparse
2222
import json
2323
import logging
24+
import os
2425
import re
2526
import shutil
2627
import sys
@@ -726,31 +727,85 @@ def _merge_principles(work_dir: Path, iter_dir: Path) -> None:
726727
def setup_work_dir(run_id: str, repo_path: str | None = None) -> Path:
727728
"""Create and initialize a working directory from templates.
728729
729-
If repo_path is provided, the campaign directory is created inside
730-
the target repo at .nous/<run_id>/. Otherwise falls back to creating
731-
<run_id>/ in the current directory.
730+
See ``orchestrator/work_dir_resolver.py`` for the canonical
731+
RESOLUTION RULES — this function delegates path resolution there.
732+
Briefly: ``$NOUS_CAMPAIGN_PARENT/<run_id>/`` if env var set, else
733+
legacy ``<repo_path>/.nous/<run_id>/``, else ``<run_id>/`` relative
734+
to CWD (#239).
735+
736+
The resolved absolute work_dir AND repo_path are recorded in
737+
state.json — this is the per-campaign source of truth for location.
738+
Used by collision detection (refuses to clobber a same-named
739+
campaign that targets a different repo, #239 D1) and by future
740+
resume/discovery tooling.
741+
742+
Worktrees are NOT affected: they continue to live at
743+
``<repo_path>/.nous-experiments/<run_id>/<arm>/`` because they are
744+
code FOR the target repo and must share its git history. See
745+
``orchestrator/worktree.py``.
732746
733747
Also writes a per-campaign ``.claude/settings.json`` permission policy
734748
(issue #135) so dispatchers can pass ``--settings <path>`` instead of
735749
``--dangerously-skip-permissions``.
750+
751+
Raises:
752+
ValueError: ``NOUS_CAMPAIGN_PARENT`` set to empty/whitespace, OR
753+
an existing state.json at the resolved path records a
754+
different ``repo_path`` (run_id collision under env var).
755+
FileNotFoundError: ``repo_path`` provided but doesn't exist.
756+
OSError: filesystem error creating the work_dir (wrapped with
757+
env-var context if ``NOUS_CAMPAIGN_PARENT`` is set).
736758
"""
737759
from orchestrator.settings_template import (
738760
render_campaign_settings,
739761
settings_path_for,
740762
write_campaign_settings,
741763
)
764+
from orchestrator.work_dir_resolver import ENV_VAR, resolve_work_dir
765+
766+
work_dir = resolve_work_dir(run_id, repo_path)
767+
768+
# #239 D1: collision detection. If state.json already exists with a
769+
# different recorded repo_path, the user has accidentally collided
770+
# two campaigns under the same run_id. Refuse loudly rather than
771+
# silently corrupt the existing campaign.
772+
existing_state = work_dir / "state.json"
773+
if existing_state.exists():
774+
try:
775+
prior = json.loads(existing_state.read_text())
776+
except (json.JSONDecodeError, OSError):
777+
prior = {}
778+
prior_repo = prior.get("repo_path")
779+
new_repo = str(Path(repo_path).resolve()) if repo_path else None
780+
if prior_repo and new_repo and prior_repo != new_repo:
781+
raise ValueError(
782+
f"run_id collision at {work_dir}: existing state.json "
783+
f"records repo_path={prior_repo!r} but this run targets "
784+
f"repo_path={new_repo!r}. Run_ids must be globally unique "
785+
f"under {ENV_VAR}; rename one campaign's run_id."
786+
)
787+
788+
try:
789+
work_dir.mkdir(parents=True, exist_ok=True)
790+
except OSError as exc:
791+
env_hint = ""
792+
if os.environ.get(ENV_VAR):
793+
env_hint = f" (resolved via {ENV_VAR}={os.environ[ENV_VAR]!r})"
794+
raise OSError(
795+
f"could not create campaign work_dir at {work_dir}{env_hint}: {exc}"
796+
) from exc
742797

743-
if repo_path:
744-
work_dir = Path(repo_path) / ".nous" / run_id
745-
else:
746-
work_dir = Path(run_id)
747-
work_dir.mkdir(parents=True, exist_ok=True)
748798
for t in ["state.json", "ledger.json", "principles.json"]:
749799
dest = work_dir / t
750800
if not dest.exists():
751801
shutil.copy(TEMPLATES_DIR / t, dest)
752802
state = json.loads((work_dir / "state.json").read_text())
753803
state["run_id"] = run_id
804+
# #239: record resolved paths as per-campaign source of truth.
805+
# work_dir survives env var changes; repo_path enables collision
806+
# detection and future cross-machine discovery.
807+
state["work_dir"] = str(work_dir.resolve())
808+
state["repo_path"] = str(Path(repo_path).resolve()) if repo_path else None
754809
atomic_write(work_dir / "state.json", json.dumps(state, indent=2) + "\n")
755810

756811
# Per-campaign permission policy. Idempotent: don't overwrite a settings

orchestrator/schemas/state.schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
"config_ref": {
3838
"type": ["string", "null"],
3939
"description": "Path to the campaign configuration file (campaign.yaml). Null before setup."
40+
},
41+
"work_dir": {
42+
"type": ["string", "null"],
43+
"description": "Absolute filesystem path to the campaign's work_dir, recorded at setup_work_dir (#239). Per-campaign source of truth for location, robust to NOUS_CAMPAIGN_PARENT changes between runs. **Machine-local**: tools that travel state.json across machines must validate Path(work_dir).exists() before trusting it (or rederive from the local environment). Null before setup."
44+
},
45+
"repo_path": {
46+
"type": ["string", "null"],
47+
"description": "Absolute path to the target system's repo, recorded at setup_work_dir (#239). Used for collision detection (refusing to clobber a same-named campaign that targets a different repo when NOUS_CAMPAIGN_PARENT is set) and to identify which target a campaign belongs to. **Machine-local**, same caveats as work_dir. Null before setup or when the campaign was authored without a target_system.repo_path."
4048
}
4149
}
4250
}

orchestrator/templates/state.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@
44
"run_id": "TODO-SET-RUN-ID",
55
"family": null,
66
"timestamp": "1970-01-01T00:00:00Z",
7-
"config_ref": null
7+
"config_ref": null,
8+
"work_dir": null,
9+
"repo_path": null
810
}

0 commit comments

Comments
 (0)