icdev-core 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
icdev/core/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ # CUI // SP-CTI
2
+ """ICDEV core contract — the seam between the domain-neutral kernel and a parent.
3
+
4
+ A parent (ICDEV[IT], ICDEV[FT], ...) declares itself in an ``icdev_domain.yaml``
5
+ at its repository root. This package reads that declaration and answers the
6
+ three questions every kernel module used to answer for itself, 2,054 times
7
+ over, with ``Path(__file__).resolve().parent...``:
8
+
9
+ * :mod:`icdev.core.paths` — where is the repository root and its data?
10
+ * :mod:`icdev.core.domain` — which domain is this, and what did it declare?
11
+ * :mod:`icdev.core.context` — is this process allowed to run HERE, against
12
+ THIS database? (``assert_identity``)
13
+
14
+ Nothing in this package imports anything outside the standard library and
15
+ PyYAML, so it can be imported before ``tools.db.storage`` and from either the
16
+ ``tools.*`` shim or the ``icdev.tools.*`` canonical namespace.
17
+
18
+ Programme: docs/programmes/icdev-domain-split.md (xit-decl-01).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ __all__ = ["paths", "domain", "context"]
icdev/core/context.py ADDED
@@ -0,0 +1,205 @@
1
+ # CUI // SP-CTI
2
+ """Process identity: is THIS process allowed to run HERE, against THIS database?
3
+
4
+ Two ICDEV parents run on one machine, share one shell and one PostgreSQL
5
+ server, and both read ``<PREFIX>_PG_DATABASE`` / ``<PREFIX>_DATABASE_URL``
6
+ from the process environment. An ICDEV[FT] session that inherits ICDEV[IT]'s
7
+ ``.env`` would open ``icdev`` and write trading rows into it; nothing today
8
+ would notice. :func:`assert_identity` is the refusal, called at the start of
9
+ every long-lived or state-changing entry point (dashboard, genesis daemon,
10
+ ``tools/db/migrate.py``, ``tools/kanban/cli.py``).
11
+
12
+ The check is FAIL-CLOSED ON A DECLARED MISMATCH and NEVER on silence: a
13
+ process whose env names no database at all is reported ``unmeasured`` and
14
+ allowed through, because SQLite-only deployments, CI and tests legitimately
15
+ run without one. A declaration that lists no ``db.databases`` asserts
16
+ nothing and is likewise ``unmeasured``.
17
+
18
+ python -m icdev.core.context --check # human summary, exit 1 on mismatch
19
+ python -m icdev.core.context --check --json
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+ from dataclasses import asdict, dataclass
28
+ from pathlib import Path
29
+ from urllib.parse import urlsplit
30
+
31
+ from icdev.core import paths as core_paths
32
+ from icdev.core.domain import Domain, DomainError, load_domain
33
+
34
+ IDENTITY_GUARD_ENV = "ICDEV_IDENTITY_GUARD" # =0 -> report only, never refuse
35
+
36
+
37
+ class IdentityMismatch(RuntimeError):
38
+ """The process environment names a database this parent did not declare."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class IdentityReport:
43
+ domain_key: str
44
+ domain_name: str
45
+ domain_source: str
46
+ root: str
47
+ database_declared: tuple[str, ...]
48
+ database_observed: str | None
49
+ database_source: str | None # name_env | dsn_env | None
50
+ verdict: str # match | mismatch | unmeasured
51
+ enforced: bool
52
+ detail: str
53
+
54
+ def to_dict(self) -> dict:
55
+ d = asdict(self)
56
+ d["database_declared"] = list(self.database_declared)
57
+ return d
58
+
59
+
60
+ def _database_from_dsn(dsn: str) -> str | None:
61
+ """Return the database name from a libpq URL, or None when it has none."""
62
+ try:
63
+ parts = urlsplit(dsn)
64
+ except ValueError:
65
+ return None
66
+ if parts.scheme not in ("postgresql", "postgres"):
67
+ return None
68
+ name = parts.path.lstrip("/")
69
+ return name or None
70
+
71
+
72
+ def observed_database(domain: Domain, environ: os._Environ | dict | None = None) -> tuple[str | None, str | None]:
73
+ """Return ``(database_name, which_env_var)`` as the process env declares it.
74
+
75
+ ``name_env`` wins over ``dsn_env`` because that is the precedence
76
+ ``tools/db/storage.py`` gives ``ICDEV_PG_DATABASE`` when both are set for
77
+ the keyword form; a DSN's path is consulted only when the name is absent.
78
+ """
79
+ env = os.environ if environ is None else environ
80
+ name = (env.get(domain.db.name_env) or "").strip()
81
+ if name:
82
+ return name, domain.db.name_env
83
+ dsn = (env.get(domain.db.dsn_env) or "").strip()
84
+ if dsn:
85
+ parsed = _database_from_dsn(dsn)
86
+ if parsed:
87
+ return parsed, domain.db.dsn_env
88
+ return None, None
89
+
90
+
91
+ def load_env(root: Path | None = None) -> Path | None:
92
+ """Load ``<root>/.env`` (the PARENT's, never cwd's). Returns the path loaded."""
93
+ root = root or core_paths.repo_root()
94
+ env_file = root / ".env"
95
+ if not env_file.is_file():
96
+ return None
97
+ try:
98
+ from dotenv import load_dotenv
99
+ except ImportError: # pragma: no cover — dotenv is a declared dependency
100
+ return None
101
+ load_dotenv(env_file, override=False)
102
+ return env_file
103
+
104
+
105
+ def check_identity(
106
+ *,
107
+ anchor: str | os.PathLike[str] | None = None,
108
+ domain: Domain | None = None,
109
+ environ: dict | None = None,
110
+ ) -> IdentityReport:
111
+ """Compute the identity verdict without raising."""
112
+ dom = domain or load_domain(anchor=anchor)
113
+ observed, which = observed_database(dom, environ)
114
+ declared = tuple(dom.db.databases)
115
+ enforced = os.environ.get(IDENTITY_GUARD_ENV, "1").strip().lower() not in ("0", "false", "no", "monitor")
116
+ if not declared:
117
+ verdict, detail = "unmeasured", "the declaration lists no db.databases, so it asserts nothing"
118
+ elif observed is None:
119
+ verdict, detail = "unmeasured", (
120
+ f"neither {dom.db.name_env} nor {dom.db.dsn_env} names a database in this process"
121
+ )
122
+ elif observed in declared:
123
+ verdict, detail = "match", f"{which}={observed} is declared by {dom.key}"
124
+ else:
125
+ verdict, detail = "mismatch", (
126
+ f"{which} names database {observed!r} but domain {dom.key!r} declares only "
127
+ f"{list(declared)} — this process is running against another parent's database"
128
+ )
129
+ return IdentityReport(
130
+ domain_key=dom.key,
131
+ domain_name=dom.name,
132
+ domain_source=dom.source,
133
+ root=str(dom.root),
134
+ database_declared=declared,
135
+ database_observed=observed,
136
+ database_source=which,
137
+ verdict=verdict,
138
+ enforced=enforced,
139
+ detail=detail,
140
+ )
141
+
142
+
143
+ def assert_identity(
144
+ *,
145
+ anchor: str | os.PathLike[str] | None = None,
146
+ domain: Domain | None = None,
147
+ environ: dict | None = None,
148
+ ) -> IdentityReport:
149
+ """Refuse (``IdentityMismatch``) on a declared mismatch; return the report otherwise.
150
+
151
+ Stand it down with ``ICDEV_IDENTITY_GUARD=0`` (the report is still
152
+ computed and returned, so a caller can log it), never with a shell
153
+ neutraliser.
154
+ """
155
+ report = check_identity(anchor=anchor, domain=domain, environ=environ)
156
+ if report.verdict == "mismatch" and report.enforced:
157
+ raise IdentityMismatch(report.detail)
158
+ return report
159
+
160
+
161
+ def describe(anchor: str | os.PathLike[str] | None = None) -> dict:
162
+ """Everything ``icdev status`` prints about where and who this process is."""
163
+ out: dict = {"paths": core_paths.describe(anchor)}
164
+ try:
165
+ dom = load_domain(anchor=anchor)
166
+ except DomainError as exc:
167
+ out["domain"] = None
168
+ out["error"] = str(exc)
169
+ return out
170
+ out["domain"] = dom.to_dict()
171
+ out["identity"] = check_identity(domain=dom).to_dict()
172
+ return out
173
+
174
+
175
+ def main(argv: list[str] | None = None) -> int:
176
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
177
+ ap.add_argument("--check", action="store_true", help="exit 1 on a declared mismatch")
178
+ ap.add_argument("--json", action="store_true")
179
+ ap.add_argument("--no-env", action="store_true", help="do not load <root>/.env first")
180
+ args = ap.parse_args(argv)
181
+
182
+ if not args.no_env:
183
+ load_env()
184
+ try:
185
+ info = describe()
186
+ except DomainError as exc:
187
+ print(f"domain declaration error: {exc}", file=sys.stderr)
188
+ return 2
189
+ if args.json:
190
+ print(json.dumps(info, indent=2))
191
+ else:
192
+ d = info.get("domain") or {}
193
+ ident = info.get("identity") or {}
194
+ print(f"domain : {d.get('key')} ({d.get('name')}) from {d.get('source')}")
195
+ print(f"root : {info['paths']['root']} [{info['paths']['source']}]")
196
+ print(f"database : declared {d.get('db', {}).get('databases')} observed "
197
+ f"{ident.get('database_observed')!r} via {ident.get('database_source')}")
198
+ print(f"identity : {ident.get('verdict', 'unknown').upper()} — {ident.get('detail', info.get('error'))}")
199
+ if args.check and (info.get("identity") or {}).get("verdict") == "mismatch":
200
+ return 1
201
+ return 0
202
+
203
+
204
+ if __name__ == "__main__": # pragma: no cover
205
+ raise SystemExit(main())
icdev/core/domain.py ADDED
@@ -0,0 +1,265 @@
1
+ # CUI // SP-CTI
2
+ """The ``icdev_domain.yaml`` declaration — what a parent tells the core.
3
+
4
+ A parent declares its key, env prefix, database, sensitivity model, component
5
+ registry, reflex packs, dashboard port, board identity, MCP composition, trust
6
+ policy and CI gate set in one file at its repository root. The core reads that
7
+ file and nothing else to learn whose domain it is serving.
8
+
9
+ Two deliberate properties:
10
+
11
+ * **The key comes from the FILE, never from an env var.** Two parents on one
12
+ machine share a shell; a ``$ICDEV_DOMAIN=ft`` exported once would make the
13
+ IT checkout load FT's database. The file found next to the code (or, for an
14
+ installed kernel, in the current directory) is the only authority.
15
+ * **A missing file is NOT an error by default.** The published wheel and every
16
+ project scaffolded by ``icdev init`` have no declaration and must keep
17
+ working byte-for-byte. They receive :data:`BUILTIN_DEFAULT`, which encodes
18
+ ICDEV[IT]'s constants as they stood on 2026-08-21 and reports
19
+ ``source == "builtin_default"`` so ``icdev status`` can say so. Set
20
+ ``ICDEV_REQUIRE_DOMAIN=1`` on a deployment that must refuse to start
21
+ undeclared.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any, Mapping
29
+
30
+ from icdev.core.paths import DOMAIN_FILE, repo_root
31
+
32
+ REQUIRE_DOMAIN_ENV = "ICDEV_REQUIRE_DOMAIN"
33
+ SCHEMA_VERSION = 1
34
+
35
+ #: ICDEV[IT] as it stood before any parent declared itself. Used when no
36
+ #: ``icdev_domain.yaml`` is found; MUST match the checked-in file's values.
37
+ BUILTIN_DEFAULT: dict[str, Any] = {
38
+ "schema_version": SCHEMA_VERSION,
39
+ "domain": {"key": "it", "name": "ICDEV[IT]", "env_prefix": "ICDEV"},
40
+ "paths": {"data": "data", "forge": ["goals", "args", "context", "hardprompts"]},
41
+ "db": {
42
+ "backend": "postgresql",
43
+ "name_env": "ICDEV_PG_DATABASE",
44
+ "dsn_env": "ICDEV_DATABASE_URL",
45
+ "sqlite_path_env": "ICDEV_DB_PATH",
46
+ "databases": ["icdev"],
47
+ "migrations": ["tools/db/migrations"],
48
+ },
49
+ "sensitivity": {
50
+ "column": "classification",
51
+ "labels_file": "args/classification_profiles.yaml",
52
+ "default": "public",
53
+ "egress_restricted": ["cui", "cui_sp_cti", "secret", "itar"],
54
+ "levels": ["IL2", "IL4", "IL5", "IL6"],
55
+ },
56
+ "components": "args/component_registry.yaml",
57
+ "reflexes": {"packs": []},
58
+ "dashboard": {"port": 5050, "blueprints": "registry"},
59
+ "kanban": {"board": "it", "external_repos": "args/kanban_external_repos.yaml"},
60
+ "mcp": {"servers": ["core", "compliance", "devsecops", "builder", "knowledge", "maintenance"]},
61
+ "trust": {"citation_required": True, "promotion_gates": ["coherence", "gated_tests"]},
62
+ "ci": {
63
+ "gated_lists": "args/ci_test_files",
64
+ "compat_suite": "args/ci_test_files/core_compat.txt",
65
+ "census_dir": "args",
66
+ },
67
+ }
68
+
69
+ _KEY_CHARS = set("abcdefghijklmnopqrstuvwxyz0123456789_")
70
+
71
+
72
+ class DomainError(RuntimeError):
73
+ """The declaration is missing when required, unreadable, or invalid."""
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class DbDeclaration:
78
+ backend: str
79
+ name_env: str
80
+ dsn_env: str
81
+ sqlite_path_env: str
82
+ databases: tuple[str, ...]
83
+ migrations: tuple[str, ...]
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class SensitivityDeclaration:
88
+ column: str
89
+ labels_file: str
90
+ default: str
91
+ egress_restricted: tuple[str, ...]
92
+ levels: tuple[str, ...]
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Domain:
97
+ key: str
98
+ name: str
99
+ env_prefix: str
100
+ root: Path
101
+ source: str # "file" | "builtin_default"
102
+ path: Path | None
103
+ db: DbDeclaration
104
+ sensitivity: SensitivityDeclaration
105
+ data_dir: str = "data"
106
+ forge_dirs: tuple[str, ...] = ("goals", "args", "context", "hardprompts")
107
+ components: str = "args/component_registry.yaml"
108
+ reflex_packs: tuple[str, ...] = ()
109
+ dashboard_port: int = 5050
110
+ dashboard_blueprints: str = "registry"
111
+ kanban_board: str = "it"
112
+ kanban_external_repos: str = "args/kanban_external_repos.yaml"
113
+ mcp_servers: tuple[str, ...] = ()
114
+ trust: Mapping[str, Any] = field(default_factory=dict)
115
+ ci: Mapping[str, Any] = field(default_factory=dict)
116
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
117
+
118
+ def env(self, suffix: str) -> str:
119
+ """Name of this parent's env var for ``suffix`` (``env("PG_DATABASE")``)."""
120
+ return f"{self.env_prefix}_{suffix}"
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ return {
124
+ "key": self.key,
125
+ "name": self.name,
126
+ "env_prefix": self.env_prefix,
127
+ "root": str(self.root),
128
+ "source": self.source,
129
+ "path": str(self.path) if self.path else None,
130
+ "db": {
131
+ "backend": self.db.backend,
132
+ "name_env": self.db.name_env,
133
+ "dsn_env": self.db.dsn_env,
134
+ "databases": list(self.db.databases),
135
+ },
136
+ "dashboard_port": self.dashboard_port,
137
+ "kanban_board": self.kanban_board,
138
+ }
139
+
140
+
141
+ def _require(mapping: Mapping[str, Any], key: str, where: str) -> Any:
142
+ if key not in mapping or mapping[key] in (None, ""):
143
+ raise DomainError(f"{where}: missing required field {key!r}")
144
+ return mapping[key]
145
+
146
+
147
+ def _tuple(value: Any, where: str) -> tuple[str, ...]:
148
+ if value is None:
149
+ return ()
150
+ if isinstance(value, (str, bytes)) or not hasattr(value, "__iter__"):
151
+ raise DomainError(f"{where}: expected a list, got {type(value).__name__}")
152
+ return tuple(str(v) for v in value)
153
+
154
+
155
+ def parse_domain(data: Mapping[str, Any], *, root: Path, source: str, path: Path | None) -> Domain:
156
+ """Validate a declaration mapping and build a :class:`Domain`."""
157
+ if not isinstance(data, Mapping):
158
+ raise DomainError(f"{path or source}: top level must be a mapping")
159
+ version = data.get("schema_version", SCHEMA_VERSION)
160
+ if version != SCHEMA_VERSION:
161
+ raise DomainError(
162
+ f"{path or source}: schema_version {version!r} is not supported "
163
+ f"(this core reads {SCHEMA_VERSION})"
164
+ )
165
+ dom = _require(data, "domain", str(path or source))
166
+ key = str(_require(dom, "key", "domain")).strip()
167
+ if not key or set(key) - _KEY_CHARS or key[0].isdigit():
168
+ raise DomainError(
169
+ f"domain.key {key!r} must be lowercase [a-z0-9_] and start with a letter"
170
+ )
171
+ env_prefix = str(_require(dom, "env_prefix", "domain")).strip()
172
+ if not env_prefix.isidentifier() or env_prefix != env_prefix.upper():
173
+ raise DomainError(f"domain.env_prefix {env_prefix!r} must be an UPPERCASE identifier")
174
+
175
+ db_raw = _require(data, "db", str(path or source))
176
+ db = DbDeclaration(
177
+ backend=str(db_raw.get("backend", "postgresql")),
178
+ name_env=str(db_raw.get("name_env", f"{env_prefix}_PG_DATABASE")),
179
+ dsn_env=str(db_raw.get("dsn_env", f"{env_prefix}_DATABASE_URL")),
180
+ sqlite_path_env=str(db_raw.get("sqlite_path_env", f"{env_prefix}_DB_PATH")),
181
+ databases=_tuple(db_raw.get("databases"), "db.databases"),
182
+ migrations=_tuple(db_raw.get("migrations"), "db.migrations"),
183
+ )
184
+ sens_raw = data.get("sensitivity") or {}
185
+ sensitivity = SensitivityDeclaration(
186
+ column=str(sens_raw.get("column", "classification")),
187
+ labels_file=str(sens_raw.get("labels_file", "")),
188
+ default=str(sens_raw.get("default", "public")),
189
+ egress_restricted=_tuple(sens_raw.get("egress_restricted"), "sensitivity.egress_restricted"),
190
+ levels=_tuple(sens_raw.get("levels"), "sensitivity.levels"),
191
+ )
192
+ paths = data.get("paths") or {}
193
+ dashboard = data.get("dashboard") or {}
194
+ kanban = data.get("kanban") or {}
195
+ port = dashboard.get("port", 5050)
196
+ if not isinstance(port, int) or isinstance(port, bool) or not (1 <= port <= 65535):
197
+ raise DomainError(f"dashboard.port {port!r} must be an integer in 1..65535")
198
+ return Domain(
199
+ key=key,
200
+ name=str(dom.get("name", key)),
201
+ env_prefix=env_prefix,
202
+ root=root,
203
+ source=source,
204
+ path=path,
205
+ db=db,
206
+ sensitivity=sensitivity,
207
+ data_dir=str(paths.get("data", "data")),
208
+ forge_dirs=_tuple(paths.get("forge", BUILTIN_DEFAULT["paths"]["forge"]), "paths.forge"),
209
+ components=str(data.get("components", "args/component_registry.yaml")),
210
+ reflex_packs=_tuple((data.get("reflexes") or {}).get("packs"), "reflexes.packs"),
211
+ dashboard_port=port,
212
+ dashboard_blueprints=str(dashboard.get("blueprints", "registry")),
213
+ kanban_board=str(kanban.get("board", key)),
214
+ kanban_external_repos=str(kanban.get("external_repos", "args/kanban_external_repos.yaml")),
215
+ mcp_servers=_tuple((data.get("mcp") or {}).get("servers"), "mcp.servers"),
216
+ trust=dict(data.get("trust") or {}),
217
+ ci=dict(data.get("ci") or {}),
218
+ raw=dict(data),
219
+ )
220
+
221
+
222
+ def _read_yaml(path: Path) -> Mapping[str, Any]:
223
+ import yaml # local import: keep module import free of third-party deps
224
+
225
+ try:
226
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
227
+ except (OSError, yaml.YAMLError) as exc:
228
+ raise DomainError(f"{path}: cannot read declaration ({exc})") from exc
229
+ if loaded is None:
230
+ raise DomainError(f"{path}: declaration is empty")
231
+ return loaded
232
+
233
+
234
+ def load_domain(
235
+ path: str | os.PathLike[str] | None = None,
236
+ *,
237
+ anchor: str | os.PathLike[str] | None = None,
238
+ require: bool | None = None,
239
+ ) -> Domain:
240
+ """Load the declaration that governs this process.
241
+
242
+ ``path`` forces a specific file (tests, tooling). Otherwise the file is
243
+ ``<repo_root(anchor)>/icdev_domain.yaml``. When absent, the builtin IT
244
+ default is returned unless ``require`` (or ``$ICDEV_REQUIRE_DOMAIN``) says
245
+ to refuse.
246
+ """
247
+ if path is not None:
248
+ p = Path(path).resolve()
249
+ if not p.is_file():
250
+ raise DomainError(f"{p}: declaration not found")
251
+ return parse_domain(_read_yaml(p), root=p.parent, source="file", path=p)
252
+
253
+ root = repo_root(anchor)
254
+ p = root / DOMAIN_FILE
255
+ if p.is_file():
256
+ return parse_domain(_read_yaml(p), root=root, source="file", path=p)
257
+
258
+ if require is None:
259
+ require = os.environ.get(REQUIRE_DOMAIN_ENV, "").strip().lower() in ("1", "true", "yes")
260
+ if require:
261
+ raise DomainError(
262
+ f"no {DOMAIN_FILE} at {root} and {REQUIRE_DOMAIN_ENV} is set — "
263
+ "this deployment refuses to run undeclared"
264
+ )
265
+ return parse_domain(BUILTIN_DEFAULT, root=root, source="builtin_default", path=None)
icdev/core/paths.py ADDED
@@ -0,0 +1,179 @@
1
+ # CUI // SP-CTI
2
+ """One repository-root resolver for every ICDEV parent.
3
+
4
+ Three resolvers existed before this module and agreed only by accident:
5
+ ``icdev/_paths.py`` (``ICDEV_PROJECT_ROOT`` -> walk up for ``pyproject.toml``
6
+ -> ``icdev/data``), ``tools/llm/config_path.py`` (``ICDEV_LLM_CONFIG`` -> walk
7
+ up -> packaged copy) and ``tools/db/storage.py::_resolve_repo_base``
8
+ (``pyproject.toml`` / ``.git`` marker, with a name-based fallback). Each public
9
+ name survives as a thin delegate onto this module, so no call site changes.
10
+
11
+ Resolution order, documented once::
12
+
13
+ 1. $ICDEV_PROJECT_ROOT explicit override (must be a directory)
14
+ 2. the SOURCE checkout that holds the calling file — the nearest ancestor
15
+ of ``anchor`` carrying icdev_domain.yaml, pyproject.toml or .git
16
+ 3. the nearest icdev_domain.yaml walking up from the CURRENT DIRECTORY —
17
+ the pip-installed case, where the kernel lives in site-packages and is
18
+ serving whichever parent the process was started in
19
+ 4. the packaged fallback: the ``icdev/`` package directory itself
20
+
21
+ Step 2 comes BEFORE step 3 on purpose, and it is a deliberate deviation from
22
+ the first draft of the design. A session's shell resets its working directory
23
+ to the main checkout after every command, and several worktrees of the same
24
+ repository are live at once on one machine; a source checkout that took its
25
+ root from CWD would silently bind a worktree's code to another checkout's
26
+ data — the exact cross-load the identity check exists to refuse. CWD is
27
+ consulted only when the code itself is not in a source checkout.
28
+
29
+ A "source checkout" is recognised by a MARKER FILE, never by a directory
30
+ NAME: ``_resolve_repo_base`` used to compare names and over-walked on GitHub
31
+ Actions, whose workspace is ``/home/runner/work/icdev/icdev``.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import sys
37
+ from pathlib import Path
38
+ from typing import Iterable
39
+
40
+ DOMAIN_FILE = "icdev_domain.yaml"
41
+ PROJECT_ROOT_ENV = "ICDEV_PROJECT_ROOT"
42
+
43
+ #: Markers that identify a source checkout, most specific first.
44
+ SOURCE_MARKERS: tuple[str, ...] = (DOMAIN_FILE, "pyproject.toml", ".git")
45
+
46
+ _ICDEV_PKG_DIR = Path(__file__).resolve().parent.parent # icdev/
47
+ _PACKAGED_DATA_DIR = _ICDEV_PKG_DIR / "data"
48
+
49
+
50
+ def _is_installed(path: Path) -> bool:
51
+ """True when ``path`` lives under site-packages or the interpreter prefix."""
52
+ p = str(path).replace("\\", "/").lower()
53
+ if "site-packages" in p or "dist-packages" in p:
54
+ return True
55
+ try:
56
+ prefix = str(Path(sys.prefix).resolve()).replace("\\", "/").lower()
57
+ except OSError: # pragma: no cover — sys.prefix always resolves in practice
58
+ return False
59
+ return bool(prefix) and p.startswith(prefix + "/")
60
+
61
+
62
+ def _walk_up(start: Path, markers: Iterable[str]) -> Path | None:
63
+ """Return the nearest directory at or above ``start`` holding any marker."""
64
+ markers = tuple(markers)
65
+ start = start if start.is_dir() else start.parent
66
+ for candidate in (start, *start.parents):
67
+ for m in markers:
68
+ if (candidate / m).exists():
69
+ return candidate
70
+ return None
71
+
72
+
73
+ def find_domain_file(start: Path | None = None) -> Path | None:
74
+ """Return the nearest ``icdev_domain.yaml`` at or above ``start`` (default CWD)."""
75
+ base = _walk_up(Path(start or Path.cwd()).resolve(), (DOMAIN_FILE,))
76
+ return None if base is None else base / DOMAIN_FILE
77
+
78
+
79
+ def repo_root(anchor: str | os.PathLike[str] | None = None) -> Path:
80
+ """Return the repository root this process should treat as home.
81
+
82
+ ``anchor`` is the calling module's ``__file__`` (or any path inside the
83
+ checkout). When omitted, the ``icdev`` package directory is used, which is
84
+ correct for the kernel's own modules and for the wheel.
85
+ """
86
+ env = os.environ.get(PROJECT_ROOT_ENV, "").strip()
87
+ if env:
88
+ p = Path(env).expanduser()
89
+ if p.is_dir():
90
+ return p.resolve()
91
+
92
+ anchor_path = Path(anchor).resolve() if anchor is not None else _ICDEV_PKG_DIR
93
+ if not _is_installed(anchor_path):
94
+ found = _walk_up(anchor_path, SOURCE_MARKERS)
95
+ if found is not None:
96
+ return found
97
+
98
+ cwd_root = _walk_up(Path.cwd().resolve(), (DOMAIN_FILE,))
99
+ if cwd_root is not None:
100
+ return cwd_root
101
+
102
+ return _ICDEV_PKG_DIR
103
+
104
+
105
+ def data_path(name: str, anchor: str | os.PathLike[str] | None = None) -> Path:
106
+ """Resolve a FORGE data directory (args, context, goals, hardprompts, ...).
107
+
108
+ Falls back to the packaged ``icdev/data/<name>`` when the repository root
109
+ has no such directory (the wheel case), then to a CWD-relative path so a
110
+ caller that only wants to CREATE the directory still gets a sane answer.
111
+ """
112
+ root = repo_root(anchor)
113
+ candidate = root / name
114
+ if candidate.is_dir():
115
+ return candidate
116
+ packaged = _PACKAGED_DATA_DIR / name
117
+ if packaged.is_dir():
118
+ return packaged
119
+ return Path(name)
120
+
121
+
122
+ def config_path(
123
+ relpath: str | os.PathLike[str],
124
+ *,
125
+ env: str | None = None,
126
+ anchor: str | os.PathLike[str] | None = None,
127
+ packaged: str | os.PathLike[str] | None = None,
128
+ ) -> Path:
129
+ """Resolve one configuration file: ``$env`` -> ``<repo_root>/relpath`` -> packaged.
130
+
131
+ ``relpath`` is relative to the repository root (``args/llm_config.yaml``).
132
+ ``packaged`` is the last-resort copy shipped inside the package; when not
133
+ given, ``icdev/data/<relpath>`` is tried and finally ``<repo_root>/relpath``
134
+ is returned even if it does not exist, so the caller's error names the
135
+ path that SHOULD have been there.
136
+ """
137
+ if env:
138
+ override = os.environ.get(env, "").strip()
139
+ if override:
140
+ return Path(override).expanduser().resolve()
141
+ rel = Path(relpath)
142
+ candidate = repo_root(anchor) / rel
143
+ if candidate.is_file():
144
+ return candidate
145
+ if packaged is not None:
146
+ return Path(packaged)
147
+ pkg = _PACKAGED_DATA_DIR / rel
148
+ if pkg.is_file():
149
+ return pkg
150
+ return candidate
151
+
152
+
153
+ def describe(anchor: str | os.PathLike[str] | None = None) -> dict:
154
+ """Diagnostics for ``icdev status``: which root won, and why."""
155
+ env = os.environ.get(PROJECT_ROOT_ENV, "").strip()
156
+ anchor_path = Path(anchor).resolve() if anchor is not None else _ICDEV_PKG_DIR
157
+ installed = _is_installed(anchor_path)
158
+ source = None if installed else _walk_up(anchor_path, SOURCE_MARKERS)
159
+ cwd_root = _walk_up(Path.cwd().resolve(), (DOMAIN_FILE,))
160
+ root = repo_root(anchor)
161
+ if env and Path(env).expanduser().is_dir():
162
+ how = "env"
163
+ elif source is not None and source == root:
164
+ how = "source_checkout"
165
+ elif cwd_root is not None and cwd_root == root:
166
+ how = "cwd_domain_file"
167
+ else:
168
+ how = "packaged"
169
+ return {
170
+ "root": str(root),
171
+ "source": how,
172
+ "env_var": PROJECT_ROOT_ENV,
173
+ "env_value": env or None,
174
+ "anchor": str(anchor_path),
175
+ "anchor_installed": installed,
176
+ "source_candidate": str(source) if source else None,
177
+ "cwd_candidate": str(cwd_root) if cwd_root else None,
178
+ "domain_file": str(root / DOMAIN_FILE) if (root / DOMAIN_FILE).is_file() else None,
179
+ }