@event4u/agent-config 5.0.0 → 5.1.0

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.
Files changed (35) hide show
  1. package/.agent-src/contexts/execution/roadmap-process-loop.md +30 -4
  2. package/.agent-src/rules/linked-projects-onboarding-gate.md +82 -0
  3. package/.agent-src/rules/roadmap-progress-sync.md +39 -5
  4. package/.agent-src/scripts/update_roadmap_progress.py +63 -7
  5. package/.agent-src/skills/roadmap-management/SKILL.md +121 -21
  6. package/.agent-src/skills/roadmap-writing/SKILL.md +63 -0
  7. package/.agent-src/templates/agent-settings.md +16 -0
  8. package/.agent-src/templates/roadmaps.md +22 -1
  9. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +20 -3
  10. package/.claude-plugin/marketplace.json +1 -1
  11. package/CHANGELOG.md +33 -0
  12. package/README.md +1 -1
  13. package/dist/discovery/deprecation-report.md +1 -1
  14. package/dist/discovery/discovery-manifest.json +33 -11
  15. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  16. package/dist/discovery/discovery-manifest.summary.md +3 -3
  17. package/dist/discovery/orphan-report.md +1 -1
  18. package/dist/discovery/packs.json +6 -5
  19. package/dist/discovery/trust-report.md +3 -3
  20. package/dist/discovery/workspaces.json +5 -4
  21. package/dist/mcp/registry-manifest.json +2 -2
  22. package/dist/router.json +1 -1
  23. package/docs/architecture.md +1 -1
  24. package/docs/catalog.md +3 -2
  25. package/docs/decisions/ADR-032-linked-projects-scope.md +118 -0
  26. package/docs/decisions/INDEX.md +1 -0
  27. package/docs/getting-started.md +1 -1
  28. package/docs/guides/cross-repo-linked-projects.md +86 -0
  29. package/package.json +1 -1
  30. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  31. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  33. package/scripts/_lib/agent_settings.py +20 -3
  34. package/scripts/_lib/linked_projects.py +238 -0
  35. package/scripts/check_no_local_settings_committed.py +51 -0
@@ -0,0 +1,118 @@
1
+ ---
2
+ adr: 032
3
+ status: accepted
4
+ date: 2026-05-29
5
+ decision: linked-projects-scope-go-option-a
6
+ supersedes: —
7
+ superseded_by: —
8
+ phase: v3.x · multi-project-scope evaluation
9
+ type: structural
10
+ review_date: 2027-05-29
11
+ ---
12
+
13
+ # ADR-032 — Linked-projects scope: GO on opt-in auto-detection (Option A, passive awareness)
14
+
15
+ ## Status
16
+
17
+ **Accepted** · 2026-05-29. Approves an opt-in auto-detection feature for
18
+ IDE-attached sibling repositories, scoped to **passive awareness** (Option A).
19
+ A same-day earlier draft recorded NO-GO; that verdict was reversed after the
20
+ proactivity-gap argument (below). Time-boxed: review on **2027-05-29** or
21
+ earlier if a kill-switch trigger fires.
22
+
23
+ Not to be confused with [`ADR-029`](ADR-029-multi-workspace-deferred.md): that
24
+ defers a restructure of the **package's own root layout**. This ADR is about
25
+ the **agent's working scope over a sibling project repository**.
26
+
27
+ ## Context
28
+
29
+ Developers routinely check out sibling repos that change together (e.g.
30
+ `galawork-api` + `galawork-web`) and attach them in the IDE. Detection is
31
+ deterministic from on-disk config (`.idea/modules.xml` + `vcs.xml`,
32
+ `*.code-workspace`).
33
+
34
+ A Phase-0 spike found Claude Code can already read/write a sibling outside its
35
+ working directory **unconditionally** — no rule needed. An initial reading
36
+ concluded the feature was therefore only an "awareness signal" a doc could
37
+ deliver, and drafted NO-GO.
38
+
39
+ ## The reversal — proactivity gap
40
+
41
+ That NO-GO mis-framed the value. The point is **not** capability (the agent can
42
+ write everywhere); it is **proactivity**: the agent does **not** consider a
43
+ sibling unless explicitly told, so cross-repo dependencies — an API change that
44
+ breaks the frontend, a shared type that drifts — are missed by default. A
45
+ manual doc/snippet presupposes the very awareness the target user lacks: the
46
+ developer who needs this most is exactly the one who won't think to write the
47
+ note. **Auto-detection is zero-knowledge** — it reads the relationship the
48
+ developer already encoded by attaching the repo in their IDE.
49
+
50
+ AI Council (anthropic/claude-sonnet-4-5 + openai/gpt-4o, 3 rounds + Karpathy
51
+ peer-review, 2026-05-29) flipped to **GO** on this reasoning.
52
+
53
+ ## Decision — GO, scoped to Option A (passive awareness)
54
+
55
+ Build an **opt-in** auto-detection feature:
56
+
57
+ 1. **Detect** IDE-attached siblings from on-disk config (config-driven only;
58
+ never arbitrary adjacent directories).
59
+ 2. **Opt-in once** per sibling; persist the choice **local-only** in
60
+ `.agent-settings.local.yml` (in agents/settings/) (gitignored, per-machine — sibling paths differ
61
+ per developer and must never be committed).
62
+ 3. **Behavioral directive** for in-scope siblings: proactively check cross-repo
63
+ impact on relevant changes (API contract, shared types) and **warn**.
64
+ **Do NOT bulk-include** the sibling's files (interpretation C — token
65
+ blowup — stays **out of scope**). Out-of-root writes still pass the host
66
+ agent's own permission gate.
67
+
68
+ ### A/B/C scoping
69
+
70
+ - **A — passive awareness (CHOSEN):** know + warn, no bulk inclusion. Cheap, low risk.
71
+ - **B — proactive dependency scanning:** auto-scan on every change. Deferred (needs heuristics).
72
+ - **C — implicit inclusion of all sibling files:** **rejected** — token blowup, context pollution.
73
+
74
+ ### Fork resolutions
75
+
76
+ - **Fork A** — `.agent-settings.local.yml` (in agents/settings/), deepest cascade layer reusing `_deep_merge` (not a bespoke override).
77
+ - **Fork B** — key `linked_projects` (avoids ADR-007 "scope"/"workspace", ADR-029 "multi-workspace").
78
+ - **Fork C** — cross-cwd writes documented, never auto-configured; host permission gate applies.
79
+
80
+ ## Consequences
81
+
82
+ - New: detector (`scripts/_lib/linked_projects.py`), the
83
+ `.agent-settings.local.yml` (in agents/settings/) cascade layer, a committed-local lint, and the
84
+ `linked-projects-onboarding-gate` rule (tier-2b, **experimental**, **removable**).
85
+ - The intra-repo module system (`enumerate_modules()`) is untouched.
86
+ - Size never excludes a sibling — a real frontend (galawork-web ≈ 38k files)
87
+ must surface; it is flagged `large` (awareness only). The council's literal
88
+ "skip >20k files" guardrail was corrected: it conflated Option C's
89
+ file-inclusion cost with Option A, under which repo size is cost-irrelevant.
90
+ - Per install decision **D2**, the installer does not touch the consumer
91
+ `.gitignore`; consumers gitignore `.agent-settings.local.yml` (in agents/settings/) themselves
92
+ (documented in the guide).
93
+
94
+ ## Kill-switch
95
+
96
+ Experimental + removable by construction. If opt-in is consistently declined or
97
+ siblings are never cited in practice, remove the rule. Signal stays local — no
98
+ telemetry.
99
+
100
+ ## Open follow-ups
101
+
102
+ - **Consumer detector reachability:** the detector lives in `scripts/_lib/`;
103
+ exposing it as an `agent-config` CLI subcommand for consumer installs is a
104
+ follow-up. Import-reachable in this repo / co-located maintainer setups today.
105
+ - **Multi-agent verification:** only Claude Code was empirically validated.
106
+ Cursor / Augment / Copilot are unverified — the guide's manual snippet covers
107
+ them until an interactive per-IDE test is run.
108
+
109
+ ## Alternatives considered
110
+
111
+ - **NO-GO + docs only** — rejected: a manual note fails the target user who lacks the awareness to write it.
112
+ - **Build Option C** — rejected: token blowup.
113
+
114
+ ## References
115
+
116
+ - [`docs/guides/cross-repo-linked-projects.md`](../guides/cross-repo-linked-projects.md)
117
+ - [`ADR-007`](ADR-007-agent-discovery-scopes.md) — owns "scope"/"workspace".
118
+ - [`ADR-029`](ADR-029-multi-workspace-deferred.md) — unrelated package-root multi-workspace defer.
@@ -35,6 +35,7 @@ _Auto-generated by `scripts/adr/regenerate_index.py`. Do not edit._
35
35
  | [ADR-029](ADR-029-multi-workspace-deferred.md) | Multi Workspace Deferred | accepted | 2026-05-25 | — |
36
36
  | [ADR-030](ADR-030-claude-code-command-projection.md) | Claude Code Command Projection | accepted | 2026-05-28 | — |
37
37
  | [ADR-031](ADR-031-validation-severity-tiers-and-projection-roundtrip.md) | Validation Severity Tiers And Projection Roundtrip | accepted | 2026-05-29 | — |
38
+ | [ADR-032](ADR-032-linked-projects-scope.md) | Linked Projects Scope Go Option A | accepted | 2026-05-29 | — |
38
39
 
39
40
  ## Unnumbered (legacy)
40
41
 
@@ -106,7 +106,7 @@ Your agent is now:
106
106
  - **Respecting your codebase** — no conflicting patterns
107
107
  - **Following standards** — consistent code quality
108
108
 
109
- This is enforced automatically by 77 rules. No configuration needed.
109
+ This is enforced automatically by 78 rules. No configuration needed.
110
110
 
111
111
  ---
112
112
 
@@ -0,0 +1,86 @@
1
+ # Working across linked sibling projects
2
+
3
+ When two repositories change together — an API and its frontend, a service and
4
+ a shared library — a change in one can silently break the other. The agent can
5
+ already read and write a sibling repo, but it won't **proactively consider** one
6
+ unless it knows the sibling is relevant. This feature closes that gap: it
7
+ detects the sibling your IDE already attached and, after a one-time opt-in,
8
+ makes the agent flag cross-repo impact by default.
9
+
10
+ > **Scope — passive awareness (Option A).** The agent gains *awareness*: it
11
+ > warns about cross-repo impact on relevant changes and can read/edit the
12
+ > sibling on demand. It does **not** bulk-load the sibling's files into context
13
+ > (that would blow up token cost). See
14
+ > [ADR-032](../decisions/ADR-032-linked-projects-scope.md). Unrelated to
15
+ > [ADR-029](../decisions/ADR-029-multi-workspace-deferred.md) (package root
16
+ > layout).
17
+
18
+ ## Auto-detection (Claude Code — verified)
19
+
20
+ If you attach a sibling repo in your IDE, the agent detects it from on-disk
21
+ config and prompts **once** to opt it in:
22
+
23
+ - **PhpStorm / IntelliJ** — a sibling attached via `.idea/modules.xml` /
24
+ `.idea/vcs.xml` (e.g. `../galawork-web`).
25
+ - **VS Code** — folders in a `*.code-workspace`.
26
+
27
+ On the first turn (and on a new attachment) the agent asks per detected sibling:
28
+ include / decline / always / never-ask. Your choice is stored **local-only** in
29
+ `.agent-settings.local.yml` (in agents/settings/) (gitignored, per-machine — see below). A declined
30
+ sibling is never prompted again.
31
+
32
+ Once a sibling is in scope, the agent proactively checks it for impact when a
33
+ change here may affect it (API contract, shared types) and warns you — without
34
+ loading its files wholesale. Large siblings (a real frontend easily exceeds
35
+ 20 000 files) are flagged `large` and surfaced as awareness only, never skipped.
36
+
37
+ ## Manual setup (other agents / any editor)
38
+
39
+ Auto-detection is verified for Claude Code only. For Cursor, Augment, Copilot,
40
+ or any editor without IDE attachment, add the sibling by hand to
41
+ `.agent-settings.local.yml` (in agents/settings/):
42
+
43
+ ~~~yaml
44
+ linked_projects:
45
+ - path: /abs/path/to/web # or a path relative to the project
46
+ include: true
47
+ ~~~
48
+
49
+ Or, if your agent reads a rules file, drop a short note there:
50
+
51
+ ~~~markdown
52
+ ## Linked sibling project: ../web
53
+
54
+ `../web` changes alongside this repo. When an API/contract or shared-type
55
+ change here may affect it, check `../web` for impact and warn. Don't load its
56
+ files wholesale; access specific files on demand.
57
+ ~~~
58
+
59
+ ## Keep it local, never committed
60
+
61
+ `.agent-settings.local.yml` (in agents/settings/) is **gitignored on purpose** — sibling paths are
62
+ per-developer and per-machine. The installer does **not** touch your
63
+ `.gitignore` (decision D2 — you own your ignore file), so if your project does
64
+ not already ignore it, add:
65
+
66
+ ~~~gitignore
67
+ .agent-settings.local.yml
68
+ ~~~
69
+
70
+ ## Validate it works
71
+
72
+ Ask the agent:
73
+
74
+ > Read `package.json` (or `composer.json`) from the linked sibling and tell me the project name.
75
+
76
+ If it reports the name, cross-repo access works. An out-of-root edit will prompt
77
+ for confirmation, then succeed — that is expected (the agent's permission gate
78
+ still applies).
79
+
80
+ ## Tell us what works
81
+
82
+ Auto-detection is verified for Claude Code only. If you use Cursor, Augment, or
83
+ Copilot, please report whether the rule note alone worked, you needed to add the
84
+ folder to the IDE workspace, or neither — that evidence is the trigger to extend
85
+ verified auto-detection to your agent. See
86
+ [ADR-032](../decisions/ADR-032-linked-projects-scope.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Universal AI Agent OS \u2014 audited skills, governance rules, commands, and templates for AI coding tools (Claude Code, Cursor, Windsurf, Copilot).",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -61,6 +61,21 @@ from . import user_global_paths
61
61
  logger = logging.getLogger(__name__)
62
62
 
63
63
  DEFAULT_PROJECT_FILE = ".agent-settings.yml"
64
+ #: Per-machine override file. Gitignored. A SINGLE project-level file living
65
+ #: under ``agents/settings/`` (with the rest of the project's settings layer,
66
+ #: not at the repo root). It is appended as the deepest cascade layer so a
67
+ #: developer's local values override every committed ``.agent-settings.yml``
68
+ #: without ever being committed. Missing file is harmless (read as {}).
69
+ LOCAL_PROJECT_FILE = ".agent-settings.local.yml"
70
+ #: Project-relative directory the local override lives in.
71
+ LOCAL_PROJECT_SUBDIR = ("agents", "settings")
72
+
73
+
74
+ def _local_settings_path(project_root: Path) -> Path:
75
+ """Single local override: ``<root>/agents/settings/.agent-settings.local.yml``."""
76
+ return project_root.joinpath(*LOCAL_PROJECT_SUBDIR, LOCAL_PROJECT_FILE)
77
+
78
+
64
79
  DEFAULT_TEAM_FILE = ".agent-project-settings.yml"
65
80
  USER_GLOBAL_FILENAME = "agent-settings.yml"
66
81
 
@@ -415,12 +430,12 @@ def _resolve_cascade_paths(
415
430
  """
416
431
  if cwd is None:
417
432
  legacy = Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE)
418
- return [legacy]
433
+ return [legacy, _local_settings_path(legacy.parent)]
419
434
 
420
435
  root = find_project_root(cwd)
421
436
  if root is None:
422
437
  legacy = Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE)
423
- return [legacy]
438
+ return [legacy, _local_settings_path(legacy.parent)]
424
439
 
425
440
  cwd_resolved = cwd.resolve()
426
441
  # Build the chain root → … → cwd (shallowest first, deepest last).
@@ -435,7 +450,9 @@ def _resolve_cascade_paths(
435
450
  break
436
451
  cursor = parent
437
452
  chain.reverse()
438
- return [d / DEFAULT_PROJECT_FILE for d in chain]
453
+ # Committed cascade root cwd, then the single project-level local override
454
+ # under agents/settings/ as the deepest (winning) layer.
455
+ return [d / DEFAULT_PROJECT_FILE for d in chain] + [_local_settings_path(root)]
439
456
 
440
457
 
441
458
  def load_agent_settings(
@@ -0,0 +1,238 @@
1
+ """Detect IDE-attached sibling projects (linked-projects scope, Option A).
2
+
3
+ Pure, dependency-free detector. Reads on-disk IDE config the developer already
4
+ created by attaching a sibling repo, and returns the sibling project roots that
5
+ sit *outside* the current project. Config-driven only — never guesses from
6
+ arbitrary adjacent directories.
7
+
8
+ Sources:
9
+ * PhpStorm / IntelliJ — ``.idea/modules.xml`` (``<module fileurl>``) and
10
+ ``.idea/vcs.xml`` (``<mapping directory>``).
11
+ * VS Code — ``*.code-workspace`` (``folders[].path``).
12
+
13
+ Guardrails (per the linked-projects council, Option A):
14
+ * a candidate must resolve OUTSIDE the project root, exist, and contain a
15
+ ``.git/`` directory;
16
+ * a candidate whose file count exceeds ``max_files`` (default 20000) is
17
+ **flagged** ``large: true`` — NOT excluded. Under Option A the agent only
18
+ carries a passive awareness note and never bulk-includes sibling files, so
19
+ repo size is cost-irrelevant to detection; a real frontend repo routinely
20
+ exceeds 20000 files (excluding node_modules) and must still be surfaced.
21
+ The flag lets the awareness note say "large repo — check targeted impact,
22
+ do not scan the whole tree";
23
+ * the bloat directories ``node_modules``/``.git``/``dist``/``build``/
24
+ ``.venv``/``target`` are never descended into while counting.
25
+
26
+ The detector returns awareness candidates; it does NOT include any sibling
27
+ files in context and does NOT persist anything. Opt-in + persistence is the
28
+ caller's job.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import logging
35
+ import re
36
+ import xml.etree.ElementTree as ET
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ #: File-count ceiling above which a sibling is skipped (token-blowup guard).
43
+ DEFAULT_MAX_FILES = 20000
44
+
45
+ #: Directories never descended into while counting a sibling's size.
46
+ SKIP_DIRS: frozenset[str] = frozenset(
47
+ {"node_modules", ".git", "dist", "build", ".venv", "target", ".idea"}
48
+ )
49
+
50
+
51
+ def detect_linked_projects(
52
+ project_root: Path | str,
53
+ *,
54
+ max_files: int = DEFAULT_MAX_FILES,
55
+ ) -> list[dict[str, Any]]:
56
+ """Return IDE-attached sibling projects outside ``project_root``.
57
+
58
+ Each entry is ``{"path": <absolute str>, "detected_via": <source>,
59
+ "large": <bool>}`` where source is one of ``phpstorm_modules`` /
60
+ ``phpstorm_vcs`` / ``vscode_workspace`` and ``large`` is true when the
61
+ sibling's file count (excluding bloat dirs) exceeds ``max_files``. Results
62
+ are de-duplicated by resolved path (first source wins) and sorted by path.
63
+ Size never excludes — see the module docstring.
64
+ """
65
+ root = Path(project_root).resolve()
66
+ if not root.is_dir():
67
+ logger.info("linked_projects: project_root %s is not a directory", root)
68
+ return []
69
+
70
+ candidates: list[tuple[Path, str]] = []
71
+ candidates.extend((p, "phpstorm_modules") for p in _phpstorm_modules(root))
72
+ candidates.extend((p, "phpstorm_vcs") for p in _phpstorm_vcs(root))
73
+ candidates.extend((p, "vscode_workspace") for p in _vscode_workspace(root))
74
+
75
+ seen: set[Path] = set()
76
+ out: list[dict[str, Any]] = []
77
+ for path, source in candidates:
78
+ try:
79
+ resolved = path.resolve()
80
+ except OSError:
81
+ logger.info("linked_projects: cannot resolve %s", path)
82
+ continue
83
+ if resolved in seen:
84
+ continue
85
+ if not _is_valid_sibling(resolved, root):
86
+ continue
87
+ large = _exceeds_size(resolved, max_files)
88
+ if large:
89
+ logger.info(
90
+ "linked_projects: %s exceeds %d files — flagged large (awareness only)",
91
+ resolved,
92
+ max_files,
93
+ )
94
+ seen.add(resolved)
95
+ out.append(
96
+ {"path": str(resolved), "detected_via": source, "large": large}
97
+ )
98
+
99
+ out.sort(key=lambda e: e["path"])
100
+ return out
101
+
102
+
103
+ def _is_valid_sibling(candidate: Path, root: Path) -> bool:
104
+ """A sibling must be outside the project root, exist, and be a git repo."""
105
+ try:
106
+ if candidate == root or root in candidate.parents:
107
+ return False # inside the project — that's the module system's job
108
+ if candidate in root.parents:
109
+ return False # an ancestor of the project, not a sibling
110
+ if not candidate.is_dir():
111
+ logger.info("linked_projects: candidate missing/not-a-dir %s", candidate)
112
+ return False
113
+ if not (candidate / ".git").exists():
114
+ logger.info("linked_projects: candidate not a git repo %s", candidate)
115
+ return False
116
+ except OSError:
117
+ logger.info("linked_projects: unreadable candidate %s", candidate)
118
+ return False
119
+ return True
120
+
121
+
122
+ def _exceeds_size(candidate: Path, max_files: int) -> bool:
123
+ """True if the tree (minus SKIP_DIRS) holds more than ``max_files`` files."""
124
+ import os
125
+
126
+ count = 0
127
+ for dirpath, dirnames, filenames in os.walk(candidate):
128
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
129
+ count += len(filenames)
130
+ if count > max_files:
131
+ return True
132
+ return False
133
+
134
+
135
+ def _phpstorm_modules(root: Path) -> list[Path]:
136
+ """Sibling roots from ``.idea/modules.xml`` ``<module fileurl>`` entries."""
137
+ path = root / ".idea" / "modules.xml"
138
+ elems = _iter_xml_attrs(path, "module", ("fileurl", "filepath"))
139
+ out: list[Path] = []
140
+ for attrs in elems:
141
+ raw = attrs.get("fileurl") or attrs.get("filepath")
142
+ if not raw:
143
+ continue
144
+ resolved = _resolve_idea_url(raw, root)
145
+ if resolved is None:
146
+ continue
147
+ # raw points at <sibling>/.idea/<name>.iml → sibling is .idea's parent.
148
+ if resolved.parent.name == ".idea":
149
+ out.append(resolved.parent.parent)
150
+ else:
151
+ out.append(resolved)
152
+ return out
153
+
154
+
155
+ def _phpstorm_vcs(root: Path) -> list[Path]:
156
+ """Sibling roots from ``.idea/vcs.xml`` ``<mapping directory>`` entries."""
157
+ path = root / ".idea" / "vcs.xml"
158
+ out: list[Path] = []
159
+ for attrs in _iter_xml_attrs(path, "mapping", ("directory",)):
160
+ raw = attrs.get("directory")
161
+ if not raw:
162
+ continue
163
+ resolved = _resolve_idea_url(raw, root)
164
+ if resolved is not None:
165
+ out.append(resolved)
166
+ return out
167
+
168
+
169
+ def _vscode_workspace(root: Path) -> list[Path]:
170
+ """Sibling roots from ``*.code-workspace`` ``folders[].path`` entries."""
171
+ out: list[Path] = []
172
+ try:
173
+ workspaces = sorted(root.glob("*.code-workspace"))
174
+ except OSError:
175
+ return out
176
+ for ws in workspaces:
177
+ data = _read_jsonc(ws)
178
+ if not isinstance(data, dict):
179
+ continue
180
+ folders = data.get("folders")
181
+ if not isinstance(folders, list):
182
+ continue
183
+ for folder in folders:
184
+ if not isinstance(folder, dict):
185
+ continue
186
+ rel = folder.get("path")
187
+ if not isinstance(rel, str) or not rel.strip():
188
+ continue
189
+ out.append((root / rel).resolve())
190
+ return out
191
+
192
+
193
+ def _resolve_idea_url(raw: str, root: Path) -> Path | None:
194
+ """Resolve a PhpStorm path token to an absolute Path, or None."""
195
+ value = raw.strip()
196
+ if value.startswith("file://"):
197
+ value = value[len("file://") :]
198
+ value = value.replace("$PROJECT_DIR$", str(root))
199
+ if not value:
200
+ return None
201
+ try:
202
+ return (Path(value) if Path(value).is_absolute() else root / value).resolve()
203
+ except OSError:
204
+ return None
205
+
206
+
207
+ def _iter_xml_attrs(
208
+ path: Path, tag: str, _attrs: tuple[str, ...]
209
+ ) -> list[dict[str, str]]:
210
+ """Return the attribute dicts of every ``<tag>`` in ``path`` (tolerant)."""
211
+ if not path.is_file():
212
+ return []
213
+ try:
214
+ tree = ET.parse(path)
215
+ except (ET.ParseError, OSError) as exc:
216
+ logger.info("linked_projects: malformed/unreadable %s (%s)", path, exc)
217
+ return []
218
+ return [dict(el.attrib) for el in tree.iter(tag)]
219
+
220
+
221
+ def _read_jsonc(path: Path) -> Any:
222
+ """Parse JSON that may carry ``//`` comments and trailing commas (VS Code)."""
223
+ try:
224
+ text = path.read_text(encoding="utf-8")
225
+ except OSError:
226
+ return None
227
+ try:
228
+ return json.loads(text)
229
+ except json.JSONDecodeError:
230
+ pass
231
+ # tolerant fallback: strip line comments + trailing commas, retry once.
232
+ stripped = re.sub(r"^\s*//.*$", "", text, flags=re.MULTILINE)
233
+ stripped = re.sub(r",(\s*[}\]])", r"\1", stripped)
234
+ try:
235
+ return json.loads(stripped)
236
+ except json.JSONDecodeError as exc:
237
+ logger.info("linked_projects: malformed workspace JSON %s (%s)", path, exc)
238
+ return None
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Fail if any ``.agent-settings.local.yml`` is tracked by git.
3
+
4
+ `.agent-settings.local.yml` is the per-developer, per-machine override layer
5
+ (see ``scripts/_lib/agent_settings.py`` ``LOCAL_PROJECT_FILE``). It is
6
+ gitignored on purpose — committing one would leak one developer's local
7
+ machine paths (e.g. linked-project siblings) into everyone's checkout.
8
+
9
+ Exit 0 when none are tracked, 1 (with the offending paths) otherwise.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ import sys
16
+
17
+ LOCAL_FILE = ".agent-settings.local.yml"
18
+
19
+
20
+ def tracked_local_settings() -> list[str]:
21
+ try:
22
+ out = subprocess.run(
23
+ ["git", "ls-files"],
24
+ check=True,
25
+ capture_output=True,
26
+ text=True,
27
+ ).stdout
28
+ except (subprocess.CalledProcessError, FileNotFoundError):
29
+ # Not a git repo / git missing — nothing to enforce here.
30
+ return []
31
+ return [
32
+ line
33
+ for line in out.splitlines()
34
+ if line.split("/")[-1] == LOCAL_FILE
35
+ ]
36
+
37
+
38
+ def main() -> int:
39
+ offenders = tracked_local_settings()
40
+ if not offenders:
41
+ print(f"✅ No tracked {LOCAL_FILE} files.")
42
+ return 0
43
+ print(f"❌ {LOCAL_FILE} must never be committed (per-machine local layer):")
44
+ for path in offenders:
45
+ print(f" 🔴 {path}")
46
+ print(f"\nRun: git rm --cached <path> — and confirm {LOCAL_FILE} is gitignored.")
47
+ return 1
48
+
49
+
50
+ if __name__ == "__main__":
51
+ sys.exit(main())