From c171eb69bb5c49420c2112d4bf64589ff5651d11 Mon Sep 17 00:00:00 2001 From: Igor Beylin Date: Sat, 19 Sep 2026 10:11:51 -0400 Subject: [PATCH 01/19] fix(c-009): add make cypher-lint and close live interpolation holes C-009 named make cypher-lint as automated enforcement but the target did not exist. Add the scanner, wire it to Make and pre-commit, and parameterize the live quoted interpolations it now finds. Issue-Remediation-Cycle: Quantum-L9/Cognitive.Engine.Graphs#272/cycle-1 Co-authored-by: Cursor --- .pre-commit-config.yaml | 6 ++ Makefile | 7 +- engine/gates/types/all_gates.py | 5 +- engine/gds/scheduler.py | 30 ++++--- engine/handlers.py | 2 +- engine/scoring/assembler.py | 27 ++++-- engine/utils/security.py | 9 ++ tests/unit/test_cypher_lint.py | 42 +++++++++ tools/cypher_lint.py | 154 ++++++++++++++++++++++++++++++++ 9 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 tests/unit/test_cypher_lint.py create mode 100644 tools/cypher_lint.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5acb3b5..edd2afd4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -78,6 +78,12 @@ repos: fi; exit 0' language: system pass_filenames: false + - id: cypher-lint + name: make cypher-lint (C-009) + entry: python tools/cypher_lint.py + language: python + pass_filenames: false + types: [python] # L9 contract enforcement (24 invariants, 27 docs) - repo: local diff --git a/Makefile b/Makefile index 06f64a9c..e8427e03 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ # ───────────────────────────────────────────────────────────── .PHONY: dev dev-build dev-down dev-logs dev-restart health -.PHONY: test test-unit test-integration seed shell neo4j-shell +.PHONY: test test-unit test-integration seed shell neo4j-shell cypher-lint # ── Governance ───────────────────────────────────────────── @@ -209,13 +209,16 @@ clean: ## Remove volumes + containers # ── Quality Gates (local, no Docker) ─────────────────────── -.PHONY: lint lint-fix typecheck check +.PHONY: lint lint-fix typecheck check cypher-lint lint: ## Ruff lint + format check (no mutation) + MyPy — matches CI's blocking gate ruff check . ruff format --check . mypy engine/ +cypher-lint: ## C-009: scan generated Cypher for injection vectors + python3 tools/cypher_lint.py + lint-fix: ## Autofix: ruff check --fix + ruff format . (run this when `make lint` fails) ruff check . --fix ruff format . diff --git a/engine/gates/types/all_gates.py b/engine/gates/types/all_gates.py index e04e6505..7336eca4 100644 --- a/engine/gates/types/all_gates.py +++ b/engine/gates/types/all_gates.py @@ -17,6 +17,7 @@ from abc import ABC, abstractmethod from engine.config.schema import DomainSpec, GateSpec +from engine.utils.security import cypher_quoted_ident logger = logging.getLogger(__name__) @@ -192,8 +193,8 @@ def compile(self) -> str: # Build CASE WHEN for complex mapping cases = [] for query_val, candidate_vals in self.spec.mapping.items(): - val_list = ", ".join([f"'{v}'" for v in candidate_vals]) - cases.append(f"WHEN {param} = '{query_val}' THEN {prop} IN [{val_list}]") + val_list = ", ".join(cypher_quoted_ident(str(v)) for v in candidate_vals) + cases.append(f"WHEN {param} = {cypher_quoted_ident(str(query_val))} THEN {prop} IN [{val_list}]") case_expr = " ".join(cases) return f"CASE {case_expr} ELSE false END" diff --git a/engine/gds/scheduler.py b/engine/gds/scheduler.py index efe5abde..219f67f9 100644 --- a/engine/gds/scheduler.py +++ b/engine/gds/scheduler.py @@ -31,7 +31,7 @@ from engine.config.schema import AggregationStrategy, DomainSpec, EdgeCategory, GDSJobSpec from engine.graph.driver import GraphDriver -from engine.utils.security import sanitize_label +from engine.utils.security import cypher_quoted_ident, sanitize_label # S2-10: EdgeCategory → recommended GDS configuration mapping. # Paper: different relation types benefit from different algorithms/aggregation. @@ -305,14 +305,15 @@ async def _run_louvain(self, job_spec: GDSJobSpec) -> dict[str, Any]: graph_name = f"{safe_job_name}_graph" # Pre-cleanup: drop stale projection if it exists (fixes crash on re-run) - pre_drop = f""" - CALL gds.graph.exists('{graph_name}') YIELD exists + gds_params = {"graph_name": graph_name} + pre_drop = """ + CALL gds.graph.exists($graph_name) YIELD exists WITH exists WHERE exists - CALL gds.graph.drop('{graph_name}') YIELD graphName + CALL gds.graph.drop($graph_name) YIELD graphName RETURN graphName """ try: - await self.graph_driver.execute_query(pre_drop, database=db) + await self.graph_driver.execute_query(pre_drop, parameters=gds_params, database=db) except Exception as exc: exc_msg = str(exc).lower() if "not found" in exc_msg or "does not exist" in exc_msg: @@ -327,27 +328,28 @@ async def _run_louvain(self, job_spec: GDSJobSpec) -> dict[str, Any]: # Sanitize write property name write_prop = sanitize_label(job_spec.writeproperty or "communityId") + gds_params["write_prop"] = write_prop project_cypher = f""" - CALL gds.graph.project('{graph_name}', {node_labels}, {edge_types}) + CALL gds.graph.project($graph_name, {node_labels}, {edge_types}) YIELD graphName, nodeCount, relationshipCount RETURN graphName, nodeCount, relationshipCount """ try: - await self.graph_driver.execute_query(project_cypher, database=db) + await self.graph_driver.execute_query(project_cypher, parameters=gds_params, database=db) - louvain_cypher = f""" - CALL gds.louvain.write('{graph_name}', {{writeProperty: '{write_prop}'}}) + louvain_cypher = """ + CALL gds.louvain.write($graph_name, {writeProperty: $write_prop}) YIELD communityCount, modularity RETURN communityCount, modularity """ - result = await self.graph_driver.execute_query(louvain_cypher, database=db) + result = await self.graph_driver.execute_query(louvain_cypher, parameters=gds_params, database=db) data = result[0] if result else {} logger.info(f"Louvain: {data}") return {"communities": data.get("communityCount"), "modularity": data.get("modularity")} finally: - drop_cypher = f"CALL gds.graph.drop('{graph_name}') YIELD graphName RETURN graphName" + drop_cypher = "CALL gds.graph.drop($graph_name) YIELD graphName RETURN graphName" try: - await self.graph_driver.execute_query(drop_cypher, database=db) + await self.graph_driver.execute_query(drop_cypher, parameters=gds_params, database=db) except Exception: logger.exception(f"Failed to drop projected graph '{graph_name}'") @@ -588,7 +590,9 @@ async def _run_equipment_sync(self, job_spec: GDSJobSpec) -> dict[str, Any]: equipment_props = self._get_equipment_properties(job_spec) # Build dynamic CASE statements for equipment detection - case_statements = [f"CASE WHEN f.{prop} = true THEN '{name}' END" for prop, name in equipment_props] + case_statements = [ + f"CASE WHEN f.{prop} = true THEN {cypher_quoted_ident(name)} END" for prop, name in equipment_props + ] case_list = ",\n ".join(case_statements) cypher = f""" diff --git a/engine/handlers.py b/engine/handlers.py index fb1b426d..23a1ad8f 100644 --- a/engine/handlers.py +++ b/engine/handlers.py @@ -481,7 +481,7 @@ async def handle_match(tenant: str, payload: dict[str, Any]) -> dict[str, Any]: ) try: - parameters = {**resolved_query, "top_n": top_n} + parameters = {**resolved_query, "top_n": top_n, **scoring_assembler.last_query_params} results = await graph_driver.execute_query( cypher=cypher, parameters=parameters, diff --git a/engine/scoring/assembler.py b/engine/scoring/assembler.py index b8a4c272..2681a8e9 100644 --- a/engine/scoring/assembler.py +++ b/engine/scoring/assembler.py @@ -91,6 +91,7 @@ def __init__( self.domain_spec = domain_spec self.scoring_spec = domain_spec.scoring self._last_active_dims: list[str] = [] + self._query_params: dict[str, Any] = {} self._graph_driver = graph_driver self._learned_weights: dict[str, float] | None = None self._population_means: dict[str, float] = {} # S2-01: cached population means @@ -152,6 +153,7 @@ def assemble_scoring_clause( """ from engine.config.settings import settings + self._query_params = {} pareto_metadata: dict[str, Any] | None = None # Pareto pre-filter (lazy import to avoid circular deps) @@ -214,6 +216,11 @@ def last_active_dimension_names(self) -> list[str]: """Return dimension names from the most recent assemble_scoring_clause call.""" return list(self._last_active_dims) + @property + def last_query_params(self) -> dict[str, Any]: + """Copy of Cypher parameters collected during the last assemble call.""" + return dict(self._query_params) + def _compile_dimension(self, dim: ScoringDimensionSpec) -> str: """Dispatch to computation-specific compiler. @@ -454,21 +461,31 @@ def _compile_preference_attention(self, dim: ScoringDimensionSpec) -> str: outcome_rel = sanitize_label(metadata.get("outcome_relation", "RESULTED_IN")) outcome_node = sanitize_label(metadata.get("outcome_node", "TransactionOutcome")) success_prop = sanitize_label(metadata.get("success_property", "outcome_type")) - success_value = sanitize_label(metadata.get("success_value", "closed_won")) + success_value = metadata.get("success_value", "closed_won") + if not isinstance(success_value, str): + msg = f"Dimension '{dim.name}': success_value must be a string" + raise ValueError(msg) cand_community_prop = sanitize_label(dim.candidateprop or "community_id") + safe_dim = sanitize_label(dim.name) + success_key = f"pref_success_{safe_dim}" + default_key = f"pref_default_{safe_dim}" + sample_key = f"pref_sample_k_{safe_dim}" + self._query_params[success_key] = success_value + self._query_params[default_key] = default + self._query_params[sample_key] = int(sample_k) return ( f"CASE " f" WHEN size([(qe)-[:{outcome_rel}]->(o:{outcome_node}) " - f" WHERE o.{success_prop} = '{success_value}' | o]) = 0 THEN {default} " + f" WHERE o.{success_prop} = ${success_key} | o]) = 0 THEN ${default_key} " f" ELSE toFloat(" f" size([(qe)-[:{outcome_rel}]->(o:{outcome_node}) " - f" WHERE o.{success_prop} = '{success_value}' " - f" AND o.community_id = candidate.{cand_community_prop} | o][0..{sample_k}])" + f" WHERE o.{success_prop} = ${success_key} " + f" AND o.community_id = candidate.{cand_community_prop} | o][0..${sample_key}])" f" ) / toFloat(" f" size([(qe)-[:{outcome_rel}]->(o:{outcome_node}) " - f" WHERE o.{success_prop} = '{success_value}' | o][0..{sample_k}])" + f" WHERE o.{success_prop} = ${success_key} | o][0..${sample_key}])" f" ) " f"END" ) diff --git a/engine/utils/security.py b/engine/utils/security.py index a1e813ef..0eefb3ce 100644 --- a/engine/utils/security.py +++ b/engine/utils/security.py @@ -38,3 +38,12 @@ def sanitize_label(label: str) -> str: msg = f"Invalid label or type: {label!r}" raise ValueError(msg) return label + + +def cypher_quoted_ident(value: str) -> str: + """Return a single-quoted Cypher string literal of a sanitized identifier. + + Compile-time Cypher fragments that cannot take ``$params`` use this instead + of interpolating ``'{value}'`` so the source never contains that pattern. + """ + return "'" + sanitize_label(value) + "'" diff --git a/tests/unit/test_cypher_lint.py b/tests/unit/test_cypher_lint.py new file mode 100644 index 00000000..11bb12a9 --- /dev/null +++ b/tests/unit/test_cypher_lint.py @@ -0,0 +1,42 @@ +"""Unit tests — tools/cypher_lint.py C-009 scanner.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tools.cypher_lint import scan_tree + + +@pytest.mark.unit +def test_quoted_interpolation_fails(tmp_path: Path) -> None: + engine = tmp_path / "engine" + engine.mkdir() + target = engine / "sync" + target.mkdir() + (target / "generator.py").write_text( + 'cypher = f"SET n.status = \'{status}\'"\n', + encoding="utf-8", + ) + + findings = scan_tree(tmp_path) + + assert findings, "quoted value interpolation must fail the scanner" + assert findings[0].rel_path == "engine/sync/generator.py" + assert "status" in findings[0].pattern + + +@pytest.mark.unit +def test_sanitize_label_interpolation_passes(tmp_path: Path) -> None: + engine = tmp_path / "engine" + engine.mkdir() + (engine / "ok.py").write_text( + "from engine.utils.security import sanitize_label\n" + 'cypher = f"MATCH (n:{sanitize_label(label)}) RETURN n"\n', + encoding="utf-8", + ) + + findings = scan_tree(tmp_path) + + assert findings == [] diff --git a/tools/cypher_lint.py b/tools/cypher_lint.py new file mode 100644 index 00000000..710ee3cf --- /dev/null +++ b/tools/cypher_lint.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# --- L9_META --- +# l9_schema: 1 +# origin: engine-specific +# engine: graph +# layer: [tools, security] +# tags: [cypher, lint, C-009, injection] +# owner: platform +# status: active +# --- /L9_META --- +"""Scan engine/**/*.py for unparameterized Cypher interpolations (C-009). + +Fails on: +- quoted f-string value interpolations (``= '{x}'`` / ``'{var}'`` in Cypher) +- ``LIMIT {n}`` without ``$`` +- label interpolations whose expression is not ``sanitize_label(...)`` + (and whose name was not assigned from ``sanitize_label``) + +Skips raise / logger / msg= / ValueError lines. Cypher tokens are +case-sensitive so English "does not match" is not a hit. +""" + +from __future__ import annotations + +import io +import re +import sys +import tokenize +from dataclasses import dataclass +from pathlib import Path + +CYPHER_TOKEN_RE = re.compile(r"\b(?:MATCH|MERGE|CREATE|SET|WHERE|CALL|LIMIT|UNWIND|THEN|OPTIONAL)\b") +SKIP_LINE_RE = re.compile(r"\b(?:raise|logger|ValueError)\b|msg\s*=") +QUOTED_INTERP_RE = re.compile(r"'\{[^{}]+\}'") +LIMIT_INTERP_RE = re.compile(r"\bLIMIT\s*(?!\$)\{") +LABEL_INTERP_RE = re.compile(r":\{([^{}]+)\}") +SANITIZE_ASSIGN_RE = re.compile( + r"\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*" + r"(?:sanitize_label\s*\(|self\._get_candidate_label\s*\(|\[sanitize_label\b)" +) + + +@dataclass(frozen=True) +class Finding: + rel_path: str + line_no: int + pattern: str + kind: str = "Unparameterized value interpolation detected" + + +def _fstring_lines(source: str) -> set[int]: + """Line numbers that participate in an f-string. + + Python 3.12+ emits FSTRING_* tokens; 3.9-3.11 still use STRING with an + ``f``/``F`` prefix. ``make cypher-lint`` must run on both. + """ + lines: set[int] = set() + fstring_types = { + getattr(tokenize, name) + for name in ("FSTRING_START", "FSTRING_MIDDLE", "FSTRING_END") + if getattr(tokenize, name, None) is not None + } + try: + tokens = tokenize.generate_tokens(io.StringIO(source).readline) + for tok in tokens: + if tok.type in fstring_types or ( + tok.type == tokenize.STRING and tok.string[:1] in "fF" + ): + for ln in range(tok.start[0], tok.end[0] + 1): + lines.add(ln) + except (tokenize.TokenError, SyntaxError): + for idx, line in enumerate(source.splitlines(), start=1): + if re.search(r"\bf['\"]", line): + lines.add(idx) + return lines + + +def _sanitized_names(source: str) -> set[str]: + return set(SANITIZE_ASSIGN_RE.findall(source)) + + +def _label_is_safe(expr: str, sanitized: set[str]) -> bool: + stripped = expr.strip() + if "sanitize_label" in stripped: + return True + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", stripped) and stripped in sanitized: + return True + return False + + +def scan_file(path: Path, *, root: Path) -> list[Finding]: + source = path.read_text(encoding="utf-8") + rel = path.relative_to(root).as_posix() + fstring_lines = _fstring_lines(source) + sanitized = _sanitized_names(source) + findings: list[Finding] = [] + + for line_no, line in enumerate(source.splitlines(), start=1): + if line_no not in fstring_lines: + continue + if SKIP_LINE_RE.search(line): + continue + if not CYPHER_TOKEN_RE.search(line): + continue + + stripped = line.strip() + if QUOTED_INTERP_RE.search(line): + findings.append(Finding(rel, line_no, stripped)) + continue + if LIMIT_INTERP_RE.search(line): + findings.append(Finding(rel, line_no, stripped)) + continue + for match in LABEL_INTERP_RE.finditer(line): + if not _label_is_safe(match.group(1), sanitized): + findings.append(Finding(rel, line_no, stripped)) + break + + return findings + + +def scan_tree(root: Path) -> list[Finding]: + engine = root / "engine" + if not engine.is_dir(): + return [] + findings: list[Finding] = [] + for path in sorted(engine.rglob("*.py")): + if not path.is_file(): + continue + findings.extend(scan_file(path, root=root)) + return findings + + +def render_findings(findings: list[Finding]) -> str: + blocks = [] + for item in findings: + blocks.append( + f"❌ {item.kind}\nFile: {item.rel_path}:{item.line_no}\nPattern: {item.pattern}" + ) + return "\n\n".join(blocks) + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + root = Path(args[0]).resolve() if args else Path.cwd() + findings = scan_tree(root) + if findings: + print(render_findings(findings)) + return 1 + print("OK: cypher-lint — 0 injection vectors") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e9efff7dacbdf27ce1113544fefe26592fab99ed Mon Sep 17 00:00:00 2001 From: Igor Beylin Date: Sat, 19 Sep 2026 10:12:51 -0400 Subject: [PATCH 02/19] style: commit gate writer rewrites so make pr finishes once --- tests/unit/test_cypher_lint.py | 5 ++--- tools/cypher_lint.py | 8 ++------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_cypher_lint.py b/tests/unit/test_cypher_lint.py index 11bb12a9..10121e3e 100644 --- a/tests/unit/test_cypher_lint.py +++ b/tests/unit/test_cypher_lint.py @@ -16,7 +16,7 @@ def test_quoted_interpolation_fails(tmp_path: Path) -> None: target = engine / "sync" target.mkdir() (target / "generator.py").write_text( - 'cypher = f"SET n.status = \'{status}\'"\n', + "cypher = f\"SET n.status = '{status}'\"\n", encoding="utf-8", ) @@ -32,8 +32,7 @@ def test_sanitize_label_interpolation_passes(tmp_path: Path) -> None: engine = tmp_path / "engine" engine.mkdir() (engine / "ok.py").write_text( - "from engine.utils.security import sanitize_label\n" - 'cypher = f"MATCH (n:{sanitize_label(label)}) RETURN n"\n', + 'from engine.utils.security import sanitize_label\ncypher = f"MATCH (n:{sanitize_label(label)}) RETURN n"\n', encoding="utf-8", ) diff --git a/tools/cypher_lint.py b/tools/cypher_lint.py index 710ee3cf..2426cb8c 100644 --- a/tools/cypher_lint.py +++ b/tools/cypher_lint.py @@ -63,9 +63,7 @@ def _fstring_lines(source: str) -> set[int]: try: tokens = tokenize.generate_tokens(io.StringIO(source).readline) for tok in tokens: - if tok.type in fstring_types or ( - tok.type == tokenize.STRING and tok.string[:1] in "fF" - ): + if tok.type in fstring_types or (tok.type == tokenize.STRING and tok.string[:1] in "fF"): for ln in range(tok.start[0], tok.end[0] + 1): lines.add(ln) except (tokenize.TokenError, SyntaxError): @@ -133,9 +131,7 @@ def scan_tree(root: Path) -> list[Finding]: def render_findings(findings: list[Finding]) -> str: blocks = [] for item in findings: - blocks.append( - f"❌ {item.kind}\nFile: {item.rel_path}:{item.line_no}\nPattern: {item.pattern}" - ) + blocks.append(f"❌ {item.kind}\nFile: {item.rel_path}:{item.line_no}\nPattern: {item.pattern}") return "\n\n".join(blocks) From 4a553b3fc953363070290f1b315a24cb5f959132 Mon Sep 17 00:00:00 2001 From: Igor Beylin Date: Sat, 19 Sep 2026 10:13:13 -0400 Subject: [PATCH 03/19] fix(c-009): return label-safety check as a boolean Ruff SIM103 failed the PR writer wave on the two-return form. Issue-Remediation-Cycle: Quantum-L9/Cognitive.Engine.Graphs#272/cycle-1 Co-authored-by: Cursor --- tools/cypher_lint.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tools/cypher_lint.py b/tools/cypher_lint.py index 2426cb8c..a8be7d35 100644 --- a/tools/cypher_lint.py +++ b/tools/cypher_lint.py @@ -81,9 +81,7 @@ def _label_is_safe(expr: str, sanitized: set[str]) -> bool: stripped = expr.strip() if "sanitize_label" in stripped: return True - if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", stripped) and stripped in sanitized: - return True - return False + return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", stripped) and stripped in sanitized) def scan_file(path: Path, *, root: Path) -> list[Finding]: From 38ece3f32cbf078750a6460434de438d2a2d44d2 Mon Sep 17 00:00:00 2001 From: Igor Beylin Date: Sat, 19 Sep 2026 10:14:57 -0400 Subject: [PATCH 04/19] test(c-009): cover cypher_quoted_ident wrap and reject Carry the isolate follow-up tests onto the open PR so identifier quoting has the same unit coverage as sanitize_label. Issue-Remediation-Cycle: Quantum-L9/Cognitive.Engine.Graphs#272/cycle-1 Co-authored-by: Cursor --- tests/unit/test_cypher_utils.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/test_cypher_utils.py b/tests/unit/test_cypher_utils.py index 1a1b32e1..8e2edb11 100644 --- a/tests/unit/test_cypher_utils.py +++ b/tests/unit/test_cypher_utils.py @@ -45,3 +45,19 @@ def test_sanitize_label_rejects_too_long(): with pytest.raises((ValueError, Exception)): sanitize_label("A" * 200) + + +@pytest.mark.unit +def test_cypher_quoted_ident_wraps_sanitized_label(): + from engine.utils.security import cypher_quoted_ident + + assert cypher_quoted_ident("Facility") == "'Facility'" + assert cypher_quoted_ident("closed_won") == "'closed_won'" + + +@pytest.mark.unit +def test_cypher_quoted_ident_rejects_injection(): + from engine.utils.security import cypher_quoted_ident + + with pytest.raises((ValueError, Exception)): + cypher_quoted_ident("'; DROP TABLE") From 97b41d5d4b056c4d1829c2b3793fecfd4e6c662d Mon Sep 17 00:00:00 2001 From: Igor Beylin Date: Sat, 19 Sep 2026 10:17:05 -0400 Subject: [PATCH 05/19] fix(ci): declare consumer repo_class python for org-ci language detect CEG is Python-only but had no .l9/ci.json, so REPO_CLASS=auto left Analyze (central Core) fail-closed on ambiguous SDK language detect. Consumer metadata is the documented escape hatch and does not touch workflows. Issue-Remediation-Cycle: Quantum-L9/Cognitive.Engine.Graphs#273/cycle-1 Co-authored-by: Cursor --- .l9/ci.json | 4 ++++ TODO.md | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 .l9/ci.json diff --git a/.l9/ci.json b/.l9/ci.json new file mode 100644 index 00000000..ff5d59f1 --- /dev/null +++ b/.l9/ci.json @@ -0,0 +1,4 @@ +{ + "schema": "l9.ci-consumer/v1", + "repo_class": "python" +} diff --git a/TODO.md b/TODO.md index 44c0dcf1..fd4d52ad 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,9 @@ ## Issue unblock (session reference) -**Cluster:** CEG#138 CLOSED on PR 248; CEG#139 CLOSED already-fixed -**Owning fix:** https://github.com/Quantum-L9/Cognitive.Engine.Graphs/pull/248 -**Next:** pause issues for this repo; PR remediator later -**Pickup:** Graphiti PICKUP written 2026-08-30 +**Cluster:** Quantum-L9/Cognitive.Engine.Graphs#273 (+ #274 leftover, #275, #276, #277 duplicate of #276, #279) +**Owning fix:** leftover consumer `.l9/ci.json` (`repo_class: python`) stacked on PR 280 +**Next:** do not merge from this skill; leftover issues already CLOSED; remaining CEG HUMAN issues stay OPEN +**Pickup:** Graphiti PICKUP written 2026-09-19