Skip to content

Commit 51dd45d

Browse files
committed
feat(registry): publish Notion projection policy
1 parent 8adc9de commit 51dd45d

3 files changed

Lines changed: 117 additions & 8 deletions

File tree

config/project-registry-overrides.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"_comment": "Operator-editable enrollment for the canonical project registry. 'overrides' pins hard normalization failures (drifted identifier -> canonical project_key). 'supplementary' enrolls real operator-OS projects the auditor does not track as git repos. 'memory_meta' maps ~/.claude memory note-slugs to their parent project (or '' for pure meta-notes). Consumed by src/project_registry.py; falls back to built-in defaults if this file is absent.",
2+
"_comment": "Operator-editable enrollment for the canonical project registry. 'overrides' pins hard normalization failures (drifted identifier -> canonical project_key). 'supplementary' enrolls real operator-OS projects the auditor does not track as git repos. 'memory_meta' maps ~/.claude memory note-slugs to their parent project (or '' for pure meta-notes). 'notion_title_aliases' and 'notion_projection_only_rows' explain Local Portfolio projection differences that are not distinct GithubRepoAuditor truth projects. Consumed by src/project_registry.py and cross-system smoke checks; falls back to built-in defaults if this file is absent.",
33
"overrides": {
44
"jcc": "JobCommandCenter",
55
"jsm_export": "JSMTicketAnalyticsExport",
@@ -33,5 +33,20 @@
3333
"github_repo_auditor_future_arcs": "GithubRepoAuditor",
3434
"skill_library_port_2026-05": "",
3535
"skill_eval_harness_2026-05": ""
36+
},
37+
"notion_title_aliases": {
38+
"DesktopPEt-ready": "DesktopPEt",
39+
"EarthPulse-readiness": "EarthPulse",
40+
"GithubRepoAuditor-public": "GithubRepoAuditor",
41+
"Notion Operating System": "Notion",
42+
"OrbitForge (staging)": "OrbitForge",
43+
"Personal Ops": "operator-os-docs",
44+
"PomGambler-prod": "PomGambler"
45+
},
46+
"notion_projection_only_rows": {
47+
"app": "local runtime/app shell placeholder; not a portfolio-truth repo",
48+
"claude-code-harness": "local agent harness projection; outside repo-root truth",
49+
"Sandbox Local Portfolio Project": "actuation sandbox fixture row",
50+
"SecondBrain": "knowledge vault under /Users/d/Documents; not a /Users/d/Projects repo"
3651
}
3752
}

src/project_registry.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,23 @@
7575
"skill_eval_harness_2026-05": "",
7676
}
7777

78+
DEFAULT_NOTION_TITLE_ALIASES: dict[str, str] = {
79+
"DesktopPEt-ready": "DesktopPEt",
80+
"EarthPulse-readiness": "EarthPulse",
81+
"GithubRepoAuditor-public": "GithubRepoAuditor",
82+
"Notion Operating System": "Notion",
83+
"OrbitForge (staging)": "OrbitForge",
84+
"Personal Ops": "operator-os-docs",
85+
"PomGambler-prod": "PomGambler",
86+
}
87+
88+
DEFAULT_NOTION_PROJECTION_ONLY_ROWS: dict[str, str] = {
89+
"app": "local runtime/app shell placeholder; not a portfolio-truth repo",
90+
"claude-code-harness": "local agent harness projection; outside repo-root truth",
91+
"Sandbox Local Portfolio Project": "actuation sandbox fixture row",
92+
"SecondBrain": "knowledge vault under /Users/d/Documents; not a /Users/d/Projects repo",
93+
}
94+
7895
# Operator-machine source locations (overridable via the "sources" block of
7996
# config/project-registry-overrides.json). Every source is optional.
8097
DEFAULT_SOURCES: dict[str, str] = {
@@ -107,19 +124,31 @@ def _strip_alias_prefix(alias: str) -> str:
107124

108125
def load_overrides_config(
109126
config_path: Path | None,
110-
) -> tuple[dict[str, str], list[dict], dict[str, str]]:
127+
) -> tuple[dict[str, str], list[dict], dict[str, str], dict[str, str], dict[str, str]]:
111128
"""Load overrides + supplementary + memory-meta, falling back to defaults."""
112129
if config_path is None or not config_path.exists():
113130
return (
114131
dict(DEFAULT_OVERRIDES),
115132
[dict(s) for s in DEFAULT_SUPPLEMENTARY],
116133
dict(DEFAULT_MEMORY_META),
134+
dict(DEFAULT_NOTION_TITLE_ALIASES),
135+
dict(DEFAULT_NOTION_PROJECTION_ONLY_ROWS),
117136
)
118137
data = json.loads(config_path.read_text())
119138
overrides = data.get("overrides", DEFAULT_OVERRIDES)
120139
supplementary = data.get("supplementary", DEFAULT_SUPPLEMENTARY)
121140
memory_meta = data.get("memory_meta", DEFAULT_MEMORY_META)
122-
return dict(overrides), [dict(s) for s in supplementary], dict(memory_meta)
141+
title_aliases = data.get("notion_title_aliases", DEFAULT_NOTION_TITLE_ALIASES)
142+
projection_only = data.get(
143+
"notion_projection_only_rows", DEFAULT_NOTION_PROJECTION_ONLY_ROWS
144+
)
145+
return (
146+
dict(overrides),
147+
[dict(s) for s in supplementary],
148+
dict(memory_meta),
149+
dict(title_aliases),
150+
dict(projection_only),
151+
)
123152

124153

125154
def load_source_paths(config_path: Path | None) -> dict[str, object]:
@@ -281,7 +310,13 @@ def build_project_registry(
281310
``snapshot`` is the serialized portfolio-truth (``snapshot.to_dict()``).
282311
All other sources are optional and degrade gracefully.
283312
"""
284-
overrides, supplementary, memory_meta = load_overrides_config(overrides_config_path)
313+
(
314+
overrides,
315+
supplementary,
316+
memory_meta,
317+
notion_title_aliases,
318+
notion_projection_only_rows,
319+
) = load_overrides_config(overrides_config_path)
285320
generated_at = generated_at or datetime.now(timezone.utc)
286321

287322
entries: list[_Entry] = [
@@ -323,8 +358,14 @@ def build_project_registry(
323358
}
324359
)
325360
override_norm = {normalize(raw): key for raw, key in overrides.items()}
361+
title_alias_norm = {
362+
normalize(raw): target for raw, target in notion_title_aliases.items()
363+
}
364+
projection_only_norm = {
365+
normalize(raw): raw for raw in notion_projection_only_rows
366+
}
326367

327-
def resolve_entry(raw: str) -> _Entry | None:
368+
def resolve_entry_direct(raw: str) -> _Entry | None:
328369
norm = normalize(raw)
329370
if not norm:
330371
return None
@@ -334,12 +375,31 @@ def resolve_entry(raw: str) -> _Entry | None:
334375
return target
335376
return index.get(norm)
336377

378+
def resolve_entry(raw: str) -> _Entry | None:
379+
entry = resolve_entry_direct(raw)
380+
if entry is not None:
381+
return entry
382+
alias_target = title_alias_norm.get(normalize(raw))
383+
if alias_target:
384+
return resolve_entry_direct(alias_target)
385+
return None
386+
337387
notion_orphans: list[str] = []
388+
notion_projection_only: list[dict[str, str]] = []
338389
for title in _read_notion_titles(notion_snapshot_path):
339390
entry = resolve_entry(title)
340391
if entry is not None:
341392
entry.notion_local_title = title
342393
entry.add_alias(f"notion:{title}")
394+
elif normalize(title) in projection_only_norm:
395+
notion_projection_only.append(
396+
{
397+
"title": title,
398+
"reason": notion_projection_only_rows.get(title)
399+
or notion_projection_only_rows.get(projection_only_norm[normalize(title)])
400+
or "",
401+
}
402+
)
343403
else:
344404
notion_orphans.append(title)
345405

@@ -402,7 +462,14 @@ def resolve_entry(raw: str) -> _Entry | None:
402462
},
403463
"entry_count": len(entries),
404464
"resolution_overrides": overrides,
465+
"projection_policy": {
466+
"notion_title_aliases": notion_title_aliases,
467+
"notion_projection_only_rows": notion_projection_only_rows,
468+
},
405469
"entries": [e.to_dict() for e in entries],
470+
"projection_only": {
471+
"notion_local": sorted(notion_projection_only, key=lambda row: row["title"])
472+
},
406473
"unmatched": {
407474
"bridge": sorted(bridge_orphans),
408475
"memory": memory_orphans,

tests/test_project_registry.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def _ident(project_key: str, display_name: str, repo: str | None = None) -> dict
4545
),
4646
_ident("Notion", "Notion", "saagpatel/notion-operating-system"),
4747
_ident("PortfolioCommandCenter", "PortfolioCommandCenter", "saagpatel/PortfolioCommandCenter"),
48+
_ident("Fun:GamePrjs/DesktopPEt", "DesktopPEt", "saagpatel/DesktopPEt"),
4849
)
4950

5051

@@ -109,6 +110,13 @@ def test_resolve_supplementary_from_each_spelling():
109110
assert resolve("SecondBrain", index)["canonical_key"] == "supp:SecondBrain"
110111

111112

113+
def test_projection_policy_is_published_from_defaults():
114+
registry = build_project_registry(SNAPSHOT, overrides_config_path=None)
115+
policy = registry["projection_policy"]
116+
assert policy["notion_title_aliases"]["DesktopPEt-ready"] == "DesktopPEt"
117+
assert "SecondBrain" in policy["notion_projection_only_rows"]
118+
119+
112120
def test_resolve_returns_none_for_non_projects():
113121
registry = build_project_registry(SNAPSHOT, overrides_config_path=None)
114122
index = build_index(registry)
@@ -134,7 +142,17 @@ def test_build_degrades_gracefully_without_external_sources():
134142
def test_build_attaches_external_sources(tmp_path: Path):
135143
bridge = _bridge_db(tmp_path, ["MCPAudit", "PortfolioCommandCenter", "weekly-review"])
136144
snap = tmp_path / "snapshot.json"
137-
snap.write_text(json.dumps({"projects": [{"title": "MCP Audit"}, {"title": "app"}]}))
145+
snap.write_text(
146+
json.dumps(
147+
{
148+
"projects": [
149+
{"title": "MCP Audit"},
150+
{"title": "DesktopPEt-ready"},
151+
{"title": "app"},
152+
]
153+
}
154+
)
155+
)
138156
memdir = tmp_path / "memory"
139157
memdir.mkdir()
140158
(memdir / "project_mcpaudit.md").write_text("x")
@@ -151,9 +169,18 @@ def test_build_attaches_external_sources(tmp_path: Path):
151169
assert mcp["bridge_project_names"] == ["MCPAudit"]
152170
assert mcp["notion_local_title"] == "MCP Audit"
153171
assert mcp["memory_slug"] == "project_mcpaudit"
154-
# bridge noise + notion junk land in unmatched, not on an entry
172+
desktop = by_key["Fun:GamePrjs/DesktopPEt"]
173+
assert desktop["notion_local_title"] == "DesktopPEt-ready"
174+
assert "notion:DesktopPEt-ready" in desktop["aliases"]
175+
# bridge noise lands in unmatched; projection-only Notion rows are explained separately
155176
assert "weekly-review" in registry["unmatched"]["bridge"]
156-
assert "app" in registry["unmatched"]["notion_local"]
177+
assert registry["unmatched"]["notion_local"] == []
178+
assert registry["projection_only"]["notion_local"] == [
179+
{
180+
"title": "app",
181+
"reason": "local runtime/app shell placeholder; not a portfolio-truth repo",
182+
}
183+
]
157184
# PortfolioCommandCenter resolves from bridge but has no Notion/memory row
158185
assert by_key["PortfolioCommandCenter"]["bridge_project_names"] == ["PortfolioCommandCenter"]
159186
assert by_key["PortfolioCommandCenter"]["notion_local_title"] is None

0 commit comments

Comments
 (0)