@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.
- package/.agent-src/contexts/execution/roadmap-process-loop.md +30 -4
- package/.agent-src/rules/linked-projects-onboarding-gate.md +82 -0
- package/.agent-src/rules/roadmap-progress-sync.md +39 -5
- package/.agent-src/scripts/update_roadmap_progress.py +63 -7
- package/.agent-src/skills/roadmap-management/SKILL.md +121 -21
- package/.agent-src/skills/roadmap-writing/SKILL.md +63 -0
- package/.agent-src/templates/agent-settings.md +16 -0
- package/.agent-src/templates/roadmaps.md +22 -1
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +20 -3
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +33 -11
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +5 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/router.json +1 -1
- package/docs/architecture.md +1 -1
- package/docs/catalog.md +3 -2
- package/docs/decisions/ADR-032-linked-projects-scope.md +118 -0
- package/docs/decisions/INDEX.md +1 -0
- package/docs/getting-started.md +1 -1
- package/docs/guides/cross-repo-linked-projects.md +86 -0
- package/package.json +1 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/_lib/agent_settings.py +20 -3
- package/scripts/_lib/linked_projects.py +238 -0
- 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.
|
package/docs/decisions/INDEX.md
CHANGED
|
@@ -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
|
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
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.
|
|
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,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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())
|