From 0f2db85295ff28ced364bd358fef16ae85b472fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:11:28 +0900 Subject: [PATCH 01/92] feat: extract shared Agent-construction wiring into noema-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ADR-0012 recording the architecture decision for unifying Noema into one shared runtime: three candidates were evaluated (shared-package, shared-service, contract-only), and shared-package won on evidence — the only real, current, same-language duplicate is the pydantic-ai Agent-construction wiring independently built in this repository's reviewer/agent.py and naruon's noema_agent.py, not the broader claims the shared-service/contract-only candidates rested on. Implements the ADR's first concrete PR: extracts the AsyncOpenAI -> OpenAIChatModel -> OpenAIProvider -> Agent(...) wiring from reviewer/noema_reviewer into a new packages/noema-core subpackage, plus a shared NOEMA_PERSONA identity fragment. reviewer/ is the sole consumer (self-consumption only); no behavior change — the existing 478-test, 100% coverage/docstring reviewer suite passes unmodified, and noema-core carries its own equivalent 100%/100% suite. Not yet published to an index; both CI (central-review.yml) and local pytest reach it via PYTHONPATH, the same mechanism already used for noema_reviewer itself. naruon's adoption, the identity/verdict-schema contract grafted from the contract-only candidate, and publishing noema-core to an index are scoped as explicit next steps in the ADR, not bundled into this PR. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/central-review.yml | 2 +- CHANGELOG.md | 1 + docs/adr/0012-shared-noema-core-package.md | 269 ++++++++++++++++++ docs/adr/README.md | 1 + packages/noema-core/.gitignore | 5 + packages/noema-core/README.md | 39 +++ packages/noema-core/pyproject.toml | 38 +++ .../noema-core/src/noema_core/__init__.py | 14 + packages/noema-core/src/noema_core/agent.py | 91 ++++++ packages/noema-core/tests/__init__.py | 0 packages/noema-core/tests/test_agent.py | 50 ++++ reviewer/README.md | 17 +- reviewer/noema_reviewer/agent.py | 6 +- reviewer/noema_reviewer/config.py | 16 +- reviewer/pyproject.toml | 7 +- 15 files changed, 542 insertions(+), 14 deletions(-) create mode 100644 docs/adr/0012-shared-noema-core-package.md create mode 100644 packages/noema-core/.gitignore create mode 100644 packages/noema-core/README.md create mode 100644 packages/noema-core/pyproject.toml create mode 100644 packages/noema-core/src/noema_core/__init__.py create mode 100644 packages/noema-core/src/noema_core/agent.py create mode 100644 packages/noema-core/tests/__init__.py create mode 100644 packages/noema-core/tests/test_agent.py diff --git a/.github/workflows/central-review.yml b/.github/workflows/central-review.yml index e38198a06..27bad96c0 100644 --- a/.github/workflows/central-review.yml +++ b/.github/workflows/central-review.yml @@ -429,7 +429,7 @@ jobs: - name: Run independent PydanticAI review and publish current-head verdict env: GH_TOKEN: ${{ steps.noema_write_app.outputs.token }} - PYTHONPATH: ${{ github.workspace }}/reviewer + PYTHONPATH: ${{ github.workspace }}/reviewer:${{ github.workspace }}/packages/noema-core/src NOEMA_REVIEW_TOKEN_SOURCE: noema-github-app NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL }} NOEMA_LLM_MODEL: ${{ vars.NOEMA_LLM_MODEL }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 11519b54d..824356ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- `reviewer/noema_reviewer`의 PydanticAI `Agent` 구성 배선(`AsyncOpenAI` → `OpenAIChatModel` → `OpenAIProvider` → `Agent(...)`)을 신규 `packages/noema-core` 서브패키지로 추출한다(`docs/adr/0012-shared-noema-core-package.md`). 이 배선은 naruon의 `noema_agent.py:build_noema_agent`에도 독립적으로 존재하는 실제 중복이며, `noema-core`는 그 중복만 제거한다 — verdict 스키마, gating, tool/deps 기계, 자격 증명 해석 정책은 각 소비자에 그대로 남는다. `reviewer/`는 이번 PR에서 유일한 소비자이며(self-consumption only), 동작 변화 없이 기존 100% coverage/docstring 테스트 스위트가 그대로 통과한다. `noema-core`는 아직 게시되지 않아 CI/로컬 테스트 모두 `PYTHONPATH`로만 제공되며(기존에 `noema_reviewer` 자체를 제공하던 방식과 동일), PyPI 게시와 naruon 쪽 채택은 별도 후속 PR이다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. - 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. - External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed. diff --git a/docs/adr/0012-shared-noema-core-package.md b/docs/adr/0012-shared-noema-core-package.md new file mode 100644 index 000000000..4efdf0a83 --- /dev/null +++ b/docs/adr/0012-shared-noema-core-package.md @@ -0,0 +1,269 @@ +# ADR-0012: `noema-core` — a minimal shared package for Agent-construction wiring + +- **Status:** Proposed +- **Decision owner:** Noema repository governance +- **Scope:** `ContextualWisdomLab/noema` (`reviewer/`, new `packages/noema-core/`); informs `ContextualWisdomLab/naruon` and, later, `ContextualWisdomLab/.github` + +## Context + +[`docs/CWL-MASTER-CONTEXT.md`](https://github.com/ContextualWisdomLab/.github/blob/main/docs/CWL-MASTER-CONTEXT.md) +(`ContextualWisdomLab/.github`, §3/§6, ecosystem UML) defines **noema** as one +shared agent runtime (Pydantic-AI/Codex-Python) used by three consumers: the +GitHub review agent (this repository's `reviewer/`), a do-anything tenant +agent inside `naruon`, and `wardnet`'s AI-SOC/quarantine-detonation judge. In +practice it drifted into three independent implementations that share only +the name "Noema" and an OpenAI-compatible-endpoint convention: + +- **This repository's `reviewer/noema_reviewer`** — a PydanticAI `Agent` + with a typed `ReviewVerdict` output, deterministic post-model gates + (`gating.py`), and CI-specific evidence plumbing. Model wiring lives in + `noema_reviewer/config.py:resolve_model` (KV/env resolution, fail-closed + validation, then `AsyncOpenAI` → `OpenAIChatModel` → `OpenAIProvider`) and + `noema_reviewer/agent.py:PydanticAIReviewAgent.__init__` (the `Agent(...)` + construction itself). +- **`naruon`'s `backend/services/noema_agent.py`** — an async multi-tool + PydanticAI `Agent` over tenant-scoped deps (`NoemaAgentDeps`), six + registered `@agent.tool` closures, and free-text output (no verdict + schema). `build_noema_agent()` (`noema_agent.py:465-550`) independently + builds the identical `AsyncOpenAI` → `OpenAIChatModel` → `OpenAIProvider` + chain at `:473-497`, then `Agent(model, deps_type=NoemaAgentDeps, + system_prompt=SYSTEM_PROMPT)` at `:498-502`. Its LLM provider resolution + (`resolve_runtime_llm_provider()`, tenant-scoped, Fernet-encrypted direct + provider records) is **not** orchestrator-gateway-routed today — that is + stalled PR `naruon#1384`. +- **`ContextualWisdomLab/.github`'s `scripts/ci/noema_review_gate.py`** — a + self-contained, stdlib-only (`urllib.request`) script with no PydanticAI + import anywhere in the file: one hardcoded prompt string, a manual + `urllib` POST, and its own JSON verdict parsing + (`extract_json_object`/`validate_substantive_verdict`). Structurally + unrelated to the other two. + +Two naruon PRs compound the confusion: `naruon#1486`'s description frames +`.github`'s and naruon's Noema as "two separate agents that intentionally +share only a name" — an over-hasty "stay separate" framing corrected the same +day this ADR was written, in the same investigation that produced it. Both +`naruon#1486` and `naruon#1384` are open, both edit `noema_agent.py`, and +both add a colliding `docs/adr/0005-*.md` in that repository — an unresolved +merge-order hazard independent of this decision. + +[`docs/product-goal-directive.md` §5](https://github.com/ContextualWisdomLab/.github/blob/main/docs/product-goal-directive.md) +(`ContextualWisdomLab/.github`) directs minimizing a Shared Kernel to the +smallest stable surface and keeping each bounded context's domain model an +Anti-Corruption Layer away from it — not collapsing genuinely different +contexts into one framework. + +### Alternatives considered + +**A — Shared package (`noema-core`), chosen.** Extract only the +`AsyncOpenAI`→`OpenAIChatModel`→`OpenAIProvider`→`Agent(...)` construction +wiring — the one piece independently duplicated, in the same language, in +the same framework, in *current, unstalled* code — into an installable +package each consumer imports and calls. Nothing about verdict schema, +tool/deps machinery, credential policy, or tenant isolation moves. + +**B — Shared service (`noema-service`, `/v1/review` + `/v1/agent/turn` + +`/v1/detonate`).** One always-on HTTP service fronting all three consumers. +Rejected for now on evidence, not principle: two of its three endpoints have +no caller today. `wardnet` has zero artifact-analysis code (grepped the +whole repo for `yara|capa|lief|gvisor|firecracker|ebpf|detonat|IOC|submit( +artifact` — no hits beyond an unrelated UI column literally named +"Verdict"), and `quarantine-sandbox-runtime` has no Podman-backed +`CommandExecutionBackend` and no transport (CLI or HTTP) at all — both +scoped out by that repository's own ADR-0007, partly blocked on +`ContextualWisdomLab/.github#1590` (a dedicated LSM-capable CI runner). +`/v1/agent/turn` is the design's own admitted hard part: naruon's multi-turn +tool loop over stateless HTTP function-calling is unproven, and per-tool-call +network round-trips are a real latency cost nobody has asked to pay. Standing +up a three-endpoint always-on service where two endpoints are stubs violates +both this repository's own one-phase-at-a-time convention and the "does this +need to exist yet" first rung — not until `wardnet` and +`quarantine-sandbox-runtime` clear their own, independently blocked, +prerequisites. + +**C — Contract-only (schema, no shared code).** Publish/extend an identity +and verdict-shape contract (`agent_name`/`authority`/`inference_route`/ +`credential_source`) that each implementation asserts against in its own +test suite, and leave all three implementations exactly as they are +otherwise. Correct that the three sit in genuinely different bounded +contexts (CI diff-review vs. tenant multi-tool agent vs. future sandboxed +detonation judge), and right that `validate_substantive_verdict`, naruon's +tool/deps machinery, and wardnet's evidence model must never be pulled into +a shared kernel. But alone it does not deliver "real compatibility" — this +repository's `call_llm`-equivalent and `.github`'s already share the +`NOEMA_LLM_*` env-var contract with zero code sharing today +(`contracts/orchestrator-gateway.json` in this repository is exactly that: +a schema, not shared code), so recommending contract-only as the *whole* +answer reads as the same "stay separate" conclusion `naruon#1486`'s +description drew, just with a schema stapled on. + +## Decision + +Adopt **A — shared package**, scoped to exactly the `Agent`-construction +wiring plus a shared persona-identity fragment, landing as `packages/noema-core/` +in this repository (see `README.md` there for the two functions and one +constant it exports). This is a small, stable, low-change kernel — precisely +product-goal-directive.md §5's "minimize Shared Kernel" reading, not a +framework the bounded contexts become subordinate to. Each consumer's domain +model — this repository's verdict schema and gates, naruon's tool/deps and +tenant isolation, wardnet's future evidence model — stays untouched and +local, satisfying the ACL requirement. + +`ContextualWisdomLab/.github`'s `noema_review_gate.py` is explicitly left out +of v1: migrating a stdlib-only script onto PydanticAI is a rewrite, not an +extraction, and this repository's own one-phase-at-a-time convention rules +that out of this PR. + +**Grafted from C (do in parallel, not deferred):** amend `naruon#1486`'s +description (doc-only) to drop the "intentionally share only a name" framing +this ADR corrects; add one assertion each to this repository's `reviewer/` +test suite and to `.github`'s `noema_review_gate` test suite against a new +`noema-identity.schema.json` (`agent_name`/`authority`/`inference_route`/ +`credential_source`). Cheap (a few asserts against existing test suites), +immediate, and it disambiguates `naruon#1486` from the colliding +`naruon#1384` ADR file before either merges. **Not implemented by this PR** — +tracked as a next step below. + +**Named as the explicit phase-2 trigger from B (not built now):** a thin +ASGI wrapper (`/v1/review`) around a future noema-core orchestrator-client +piece, for the one gap noema-core cannot solve — `wardnet` is Rust and will +never `pip install` a Python package. Build this only once `wardnet` has an +actual artifact-analysis pipeline to route (it has none today) and +`quarantine-sandbox-runtime` clears its own independently blocked +Podman-backend/transport work. Do not build a rebuild of the three-endpoint +`noema-service` design when that day comes — build the smallest wrapper +around whatever noema-core's orchestrator-client piece has become by then. + +## First concrete PR (this change) + +Extracted from `reviewer/noema_reviewer` into `packages/noema-core/src/noema_core/agent.py`: + +- `build_openai_model(*, base_url, api_key, model_name, timeout=None, max_retries=1) -> Model` + — the `AsyncOpenAI` → `OpenAIChatModel` → `OpenAIProvider` chain, called + from `noema_reviewer/config.py:resolve_model` after that module's existing + KV/env resolution and fail-closed validation (routing-alias and endpoint + safety checks), which stay local since they are CI-specific policy, not + shared wiring. +- `build_agent(model, *, system_prompt, output_type=str, deps_type=None, + retries=3) -> Agent` — the `Agent(...)` construction, called from + `noema_reviewer/agent.py:PydanticAIReviewAgent.__init__` (imported under + the alias `build_core_agent` to avoid colliding with this repository's + own pre-existing, differently-shaped `build_agent(config) -> + PydanticAIReviewAgent` production factory in the same module). +- `NOEMA_PERSONA` — a shared identity fragment now prepended to + `noema_reviewer/agent.py:SYSTEM_PROMPT`, demonstrating the + persona-injection point without altering the prompt's meaning or any + test-asserted behavior. + +`reviewer/` is the sole consumer (self-consumption only; zero new external +consumers in this PR). No behavior change: `reviewer/`'s existing 478-test, +100%-line/branch-coverage, 100%-docstring suite passes unmodified against +the refactored code (verified locally: `python -m pytest` and `python -m +interrogate` both report the same 100% before and after). `noema-core` has +its own equivalent 100%/100% suite. Not yet published to an index — both CI +(`.github/workflows/central-review.yml`) and local pytest reach it via +`PYTHONPATH`, the same mechanism this repository already uses to provide +`noema_reviewer` itself. + +This is smaller and lower-risk than starting in `naruon`: single repository, +no production tenant-agent touched, and no collision with naruon's two +currently-open competing PRs. Naruon's adoption (importing `noema-core`, +replacing `noema_agent.py:473-497`'s inline wiring) is PR #2, explicitly +sequenced after this one and after naruon's `#1486`/`#1384` merge-order +conflict is resolved — not bundled here. + +## Consequences + +### Positive + +- The one real, current, same-language duplicate (Agent-construction wiring) + has one implementation instead of two, with room for a third (naruon) to + adopt it without inventing a new interface. +- No bounded context's domain model moves: verdict schema, gating, tool/deps + machinery, tenant isolation, and credential policy all stay exactly where + they were. +- The persona fragment gives future consumers one place to keep "Noema"'s + identity consistent without hardcoding it three times. +- The kernel is small enough to review in one PR and verify with an existing + test suite — no new production surface, no new secret, no new network + call. + +### Costs and limitations + +- `noema-core` is not yet on an index; every consumer needs the same + `PYTHONPATH` accommodation this repository already carries for + `noema_reviewer`, which is one more thing to keep in sync until it is + published. +- The shared kernel's own CI enforcement (its 100% coverage/docstring gates) + runs only via `packages/noema-core`'s local `pyproject.toml` today; it is + not yet wired into a dedicated CI job, only exercised indirectly through + `reviewer/`'s test run. +- `.github`'s Noema stays architecturally divergent (no PydanticAI) + indefinitely under this decision; that gap is not solved here. +- The full CWL-MASTER-CONTEXT vision (`wardnet`'s AI-SOC calling a shared + quarantine-sandbox judge) stays unfulfilled for an indefinite period under + any of the three candidates — a scope/sequencing reality, not a flaw + specific to this decision. + +## Open risks for the owner + +1. The "orchestrator client" half of this decision's original justification + does not hold today — naruon is not gateway-routed until `naruon#1384` + lands (it still calls `resolve_runtime_llm_provider()` directly). Confirm + whether `#1384` landing is a prerequisite for extracting an + orchestrator-client piece into `noema-core`, or whether that piece should + wait until naruon's routing story is settled, to avoid designing an + interface against a consumer that does not exist yet. +2. Package hosting/publishing mechanics are undecided: which repository owns + `noema-core`'s source of truth long-term, PyPI-public vs. a private + index, and how this repository's hash-pinned-requirements discipline + extends to a second consuming repository (`naruon`) pulling a new + cross-repository dependency. +3. This ADR leaves `.github`'s `noema_review_gate.py` permanently + stdlib-only and outside noema-core in v1 — confirm the owner is fine with + that staying architecturally divergent indefinitely, since migrating it + is a rewrite this ADR rules out of scope, not a deferred extraction. +4. `naruon#1486` and `naruon#1384` both currently edit `noema_agent.py` and + both add a colliding `docs/adr/0005-*.md` in that repository — resolve + this merge-order hazard before naruon's noema-core adoption PR (PR #2) + opens. +5. `wardnet`'s and `quarantine-sandbox-runtime`'s paths to the canonical + "used by wardnet's AI SOC" vision are both blocked on infrastructure this + decision cannot resolve (`ContextualWisdomLab/.github#1590`, and + `wardnet`'s own not-yet-built artifact-analysis pipeline). +6. A separate agent was independently committing to + `quarantine-sandbox-runtime`'s local unpushed branch during the + investigation behind this ADR (2 commits, not yet pushed to origin) — + unrelated to this decision, but worth confirming that work is tracked and + lands deliberately. + +## Next steps (not built by this PR) + +- Land the identity/verdict-schema assertions grafted from Alternative C: a + `noema-identity.schema.json` plus one test assertion each in this + repository's `reviewer/` suite and in `ContextualWisdomLab/.github`'s + `noema_review_gate` suite; amend `naruon#1486`'s description. +- `naruon`'s noema-core adoption PR (PR #2), after `naruon#1486`/`#1384`'s + merge-order conflict resolves. +- Publish `noema-core` v0.1.0 to an index once this PR is reviewed and + merged, then convert `reviewer/pyproject.toml`'s TODO comment into a real + pinned dependency. +- Decide package hosting/publishing mechanics (risk 2) and, if an + orchestrator-client piece is extracted later, sequence it against + `naruon#1384` (risk 1). + +## References + +`ContextualWisdomLab/.github`. *CWL Master Context* (`docs/CWL-MASTER-CONTEXT.md`, +§3, §6) — the shared-noema-runtime design this ADR reconciles current code +against. + +`ContextualWisdomLab/.github`. *Product Goal Directive* (`docs/product-goal-directive.md`, +§5) — the Shared Kernel / Anti-Corruption Layer guidance this decision +follows. + +`ContextualWisdomLab/quarantine-sandbox-runtime`. `docs/adr/0007-bounded-command-execution-contract.md` +and `docs/product-technical-gap-baseline.md` — scope of the still-missing +Podman backend and transport, and the `.github#1590` dependency. + +`ContextualWisdomLab/naruon`. `backend/services/noema_agent.py` +(`build_noema_agent`, `:465-550`) and open PRs `#1384`, `#1486`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8deca36c8..b15e209ea 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,6 +15,7 @@ ADR은 **왜 이 구조를 선택했는지**를 기록합니다. 구현 상태 | [0009](./0009-central-local-automation-ownership.md) | Accepted | CWL 중앙 reusable policy와 Noema-local runtime/orchestration의 소유권을 분리한다. | | [0010](./0010-private-target-review-auth.md) | Proposed | private review target의 첫 live PR lookup부터 single-repository Noema App token을 사용하고 workflow `GITHUB_TOKEN` cross-repository fallback을 금지한다. | | [0011](./0011-independent-reviewer-governance.md) | Proposed | qualifying formal approval의 eligibility·exact-head·staleness를 검증하고 check/status/scanner/model evidence가 approval을 대체하지 못하게 한다. | +| [0012](./0012-shared-noema-core-package.md) | Proposed | naruon과 독립적으로 중복 구현된 PydanticAI Agent 구성 배선만 `packages/noema-core`로 추출하고, verdict 스키마·gating·tool/deps·자격 증명 정책은 각 소비자에 남긴다. | ## ADR lifecycle diff --git a/packages/noema-core/.gitignore b/packages/noema-core/.gitignore new file mode 100644 index 000000000..4ed85d4a5 --- /dev/null +++ b/packages/noema-core/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.coverage +.pytest_cache/ +*.egg-info/ diff --git a/packages/noema-core/README.md b/packages/noema-core/README.md new file mode 100644 index 000000000..e453e88fb --- /dev/null +++ b/packages/noema-core/README.md @@ -0,0 +1,39 @@ +# noema-core + +Shared PydanticAI `Agent`-construction wiring for Noema's per-context +consumers. See [`docs/adr/0012-shared-noema-core-package.md`](../../docs/adr/0012-shared-noema-core-package.md) +for the decision and its scope boundary. + +## What this package is + +Two functions and one constant, extracted from `reviewer/noema_reviewer` +after the same `AsyncOpenAI` → `OpenAIChatModel` → `OpenAIProvider` → +`Agent(...)` wiring was found independently built in +`ContextualWisdomLab/naruon`'s `noema_agent.py`: + +- `build_openai_model(*, base_url, api_key, model_name, timeout=None, max_retries=1) -> Model` +- `build_agent(model, *, system_prompt, output_type=str, deps_type=None, retries=3) -> Agent` +- `NOEMA_PERSONA` — the shared "You are Noema, an independent AI agent for + ContextualWisdomLab." identity fragment consumers prepend to their own + system prompt. + +## What this package explicitly is not + +It does not own a verdict/output schema, tool/deps machinery, credential +resolution or validation policy, or tenant isolation. Those stay local to +each consumer's own bounded context. + +## Status + +Self-consumption only: `reviewer/noema_reviewer` is the sole consumer today. +Not yet published to an index — consumed via `PYTHONPATH` (see +`reviewer/pyproject.toml`'s `pythonpath` and `.github/workflows/central-review.yml`). +Publishing to PyPI and naruon's adoption are tracked as follow-ups in the ADR. + +## Develop + +```bash +pip install -e .[dev] +python -m pytest # 100% line+branch coverage gate +python -m interrogate -c pyproject.toml src/noema_core # 100% docstring gate +``` diff --git a/packages/noema-core/pyproject.toml b/packages/noema-core/pyproject.toml new file mode 100644 index 000000000..aa771f989 --- /dev/null +++ b/packages/noema-core/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "noema-core" +version = "0.1.0" +description = "Shared PydanticAI Agent-construction wiring for Noema's per-context consumers (reviewer, naruon, and future consumers)." +requires-python = ">=3.11" +license = "Apache-2.0" +dependencies = [ + "pydantic-ai-slim[openai]>=0.0.14", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=5.0.0", + "interrogate>=1.7.0", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +addopts = "--cov=noema_core --cov-branch --cov-report=term-missing --cov-fail-under=100" + +[tool.coverage.run] +source = ["noema_core"] +omit = ["tests/*"] + +[tool.coverage.report] +show_missing = true + +[tool.interrogate] +fail-under = 100 +exclude = ["tests"] diff --git a/packages/noema-core/src/noema_core/__init__.py b/packages/noema-core/src/noema_core/__init__.py new file mode 100644 index 000000000..42d8948c5 --- /dev/null +++ b/packages/noema-core/src/noema_core/__init__.py @@ -0,0 +1,14 @@ +"""noema-core: shared PydanticAI Agent-construction wiring for Noema consumers. + +See :mod:`noema_core.agent` for the two exported functions and the shared +persona fragment. Scope is deliberately narrow — see +``docs/adr/0012-shared-noema-core-package.md`` in +``ContextualWisdomLab/noema`` for what this package owns and what it +explicitly excludes. +""" + +from __future__ import annotations + +from .agent import NOEMA_PERSONA, build_agent, build_openai_model + +__all__ = ["NOEMA_PERSONA", "build_agent", "build_openai_model"] diff --git a/packages/noema-core/src/noema_core/agent.py b/packages/noema-core/src/noema_core/agent.py new file mode 100644 index 000000000..74dad1183 --- /dev/null +++ b/packages/noema-core/src/noema_core/agent.py @@ -0,0 +1,91 @@ +"""Shared PydanticAI Agent-construction wiring for Noema's per-context consumers. + +Every Noema consumer (this repository's CI second reviewer, naruon's tenant +agent, and any future consumer) independently wired the same three-step +PydanticAI chain — an ``AsyncOpenAI`` client, wrapped in ``OpenAIChatModel``, +wrapped in ``OpenAIProvider``, then handed to ``Agent(...)`` — and nothing +else. This module is that shared scaffolding, factored out once a second +genuine same-language duplicate of it existed (naruon's +``noema_agent.py:build_noema_agent`` and this repository's +``noema_reviewer``). + +This package deliberately owns none of a consumer's domain logic: no verdict +schema, no tool/deps machinery, no credential resolution or validation +policy, no tenant isolation. Those stay local to each bounded context. See +``docs/adr/0012-shared-noema-core-package.md`` in +``ContextualWisdomLab/noema`` for the full rationale and scope boundary. +""" + +from __future__ import annotations + +from typing import Any + +from openai import AsyncOpenAI +from pydantic_ai import Agent +from pydantic_ai.models import Model +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + + +NOEMA_PERSONA = "You are Noema, an independent AI agent for ContextualWisdomLab." +"""The shared identity fragment every consumer's system prompt should open with. + +Each consumer still writes and owns the rest of its own system prompt (this +repository's evidence-and-findings rules, naruon's tool-use guidance, and so +on). This constant is only the shared name/tone fragment — not a full +persona, and not a verdict or output schema. +""" + + +def build_openai_model( + *, + base_url: str, + api_key: str, + model_name: str, + timeout: float | None = None, + max_retries: int = 1, +) -> Model: + """Wire an OpenAI-compatible PydanticAI model from resolved connection settings. + + This is the ``AsyncOpenAI`` -> ``OpenAIChatModel`` -> ``OpenAIProvider`` + chain every Noema consumer needs to talk to an OpenAI-compatible gateway + (``contextual-orchestrator`` in production for this repository and for + naruon's gateway-routed path). Resolving and validating ``base_url``, + ``api_key``, and ``model_name`` — KV lookups, env fallback, allowed-host + checks, routing-alias policy, and the like — stays the caller's + responsibility; this function only performs the construction. + """ + client = AsyncOpenAI( + base_url=base_url, + api_key=api_key, + timeout=timeout, + max_retries=max_retries, + ) + return OpenAIChatModel(model_name, provider=OpenAIProvider(openai_client=client)) + + +def build_agent( + model: Model | str, + *, + system_prompt: str, + output_type: Any = str, + deps_type: Any = None, + retries: int = 3, +) -> Agent[Any, Any]: + """Construct a PydanticAI ``Agent`` using Noema's shared model wiring. + + ``output_type`` (a consumer's verdict/result schema), ``deps_type`` (a + consumer's tool/deps machinery), and ``system_prompt`` (persona plus + domain instructions) all stay per-consumer — this function only + centralizes the repeated ``Agent(...)`` construction call. + """ + kwargs: dict[str, Any] = {} + if deps_type is not None: + kwargs["deps_type"] = deps_type + return Agent( + model, + output_type=output_type, + system_prompt=system_prompt, + retries=retries, + **kwargs, + ) diff --git a/packages/noema-core/tests/__init__.py b/packages/noema-core/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/noema-core/tests/test_agent.py b/packages/noema-core/tests/test_agent.py new file mode 100644 index 000000000..1bacd2c56 --- /dev/null +++ b/packages/noema-core/tests/test_agent.py @@ -0,0 +1,50 @@ +"""Tests for the shared Agent-construction wiring.""" + +from __future__ import annotations + +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.models.test import TestModel + +from noema_core import NOEMA_PERSONA, build_agent, build_openai_model + + +def test_build_openai_model_wires_an_openai_chat_model() -> None: + """build_openai_model returns a PydanticAI model wired to the given settings.""" + model = build_openai_model( + base_url="https://orchestrator.example/v1", + api_key="k", + model_name="contextual-orchestrator", + ) + assert isinstance(model, OpenAIChatModel) + assert model.model_name == "contextual-orchestrator" + + +def test_build_agent_applies_output_type_and_system_prompt() -> None: + """build_agent constructs an Agent carrying the caller's schema and prompt.""" + agent = build_agent( + TestModel(), + system_prompt=NOEMA_PERSONA, + output_type=str, + retries=2, + ) + assert isinstance(agent, Agent) + result = agent.run_sync("hello") + assert isinstance(result.output, str) + + +def test_build_agent_forwards_deps_type_only_when_given() -> None: + """A caller that needs deps machinery can pass deps_type; others get none.""" + agent = build_agent( + TestModel(), + system_prompt=NOEMA_PERSONA, + output_type=str, + deps_type=dict, + ) + assert agent.deps_type is dict + + +def test_noema_persona_names_the_organization() -> None: + """The shared persona fragment names Noema and the organization it serves.""" + assert "Noema" in NOEMA_PERSONA + assert "ContextualWisdomLab" in NOEMA_PERSONA diff --git a/reviewer/README.md b/reviewer/README.md index fea8d33c1..afe2e8bb2 100644 --- a/reviewer/README.md +++ b/reviewer/README.md @@ -14,6 +14,15 @@ Division of responsibility: - **`noema_reviewer`** (this package) — the **judgement** plane. It turns a bounded pull-request manifest into a validated `ReviewVerdict` and can publish it as an independent GitHub review. +- **[`../packages/noema-core`](../packages/noema-core)** — the shared PydanticAI + `Agent`-construction wiring (`AsyncOpenAI` → `OpenAIChatModel` → + `OpenAIProvider` → `Agent(...)`) plus a shared `NOEMA_PERSONA` fragment, + factored out once a second genuine duplicate of it existed (naruon's + `noema_agent.py`). See + [`docs/adr/0012-shared-noema-core-package.md`](../docs/adr/0012-shared-noema-core-package.md) + for scope. `noema_reviewer` is its only consumer today; it does not own + verdict schema, gating, tool/deps machinery, or credential resolution + policy, all of which stay here. ## Contract @@ -107,9 +116,15 @@ Publication uses the Noema GitHub-App installation token (from the Worker) or a ```bash pip install -e .[dev] # or: pip install pydantic-ai-slim[openai] pytest pytest-cov interrogate -python -m pytest # 100% line+branch coverage gate +python -m pytest # 100% line+branch coverage gate; picks up ../packages/noema-core/src python -m interrogate -c pyproject.toml noema_reviewer # 100% docstring gate ``` +`noema-core` is not yet published to an index, so a plain `pip install -e .` +does not make it importable outside pytest (whose `pythonpath` config already +adds `../packages/noema-core/src`). Running `python -m noema_reviewer` +directly needs `PYTHONPATH=../packages/noema-core/src` too, the same way CI's +`central-review.yml` provides it. + Tests drive the agent with PydanticAI's offline `TestModel`/`FunctionModel` and a stub `gh` runner — no network, no secret, no real model. diff --git a/reviewer/noema_reviewer/agent.py b/reviewer/noema_reviewer/agent.py index dc7d24b7a..d2c9b3155 100644 --- a/reviewer/noema_reviewer/agent.py +++ b/reviewer/noema_reviewer/agent.py @@ -12,6 +12,8 @@ from typing import Protocol, runtime_checkable +from noema_core import NOEMA_PERSONA +from noema_core import build_agent as build_core_agent from pydantic_ai import Agent from pydantic_ai.models import Model @@ -22,7 +24,7 @@ SYSTEM_PROMPT = ( - "You are Noema, an independent second reviewer for ContextualWisdomLab, " + f"{NOEMA_PERSONA} You are the independent second reviewer, " "separate from the OpenCode reviewer. You review a bounded manifest of a " "pull request: its diff, changed-file context, workflow logs, SARIF " "summary, dependency findings, prior review comments, and current check " @@ -103,7 +105,7 @@ class PydanticAIReviewAgent: def __init__(self, model: Model | str) -> None: """Build the agent around an injected model (a real model or a test model).""" - self._agent: Agent[None, ReviewVerdict] = Agent( + self._agent: Agent[None, ReviewVerdict] = build_core_agent( model, output_type=ReviewVerdict, system_prompt=SYSTEM_PROMPT, diff --git a/reviewer/noema_reviewer/config.py b/reviewer/noema_reviewer/config.py index d3d6861f6..e7cbe457b 100644 --- a/reviewer/noema_reviewer/config.py +++ b/reviewer/noema_reviewer/config.py @@ -155,23 +155,21 @@ def resolve_model(config: ReviewerConfig | None = None) -> Model: The reviewer routes every model call through an OpenAI-compatible endpoint (the ``contextual-orchestrator`` gateway in production), so the OpenAI - provider is a required dependency rather than an optional extra. + provider is a required dependency rather than an optional extra. The + ``AsyncOpenAI`` -> ``OpenAIChatModel`` -> ``OpenAIProvider`` construction + itself is shared wiring from ``noema_core``; validation and resolution of + what goes into it stays here, since that policy is reviewer-specific. """ - from openai import AsyncOpenAI - from pydantic_ai.models.openai import OpenAIChatModel - from pydantic_ai.providers.openai import OpenAIProvider + from noema_core import build_openai_model resolved = config or resolve_config() _require_single_routing_alias("NOEMA_LLM_MODEL", resolved.model_name) _require_safe_model_endpoint("NOEMA_LLM_API_URL", resolved.base_url) - client = AsyncOpenAI( + return build_openai_model( base_url=resolved.base_url, api_key=resolved.api_key, + model_name=resolved.model_name, timeout=resolved.request_timeout_seconds, max_retries=resolved.max_retries, ) - return OpenAIChatModel( - resolved.model_name, - provider=OpenAIProvider(openai_client=client), - ) diff --git a/reviewer/pyproject.toml b/reviewer/pyproject.toml index df7650571..f996f0d86 100644 --- a/reviewer/pyproject.toml +++ b/reviewer/pyproject.toml @@ -10,6 +10,11 @@ requires-python = ">=3.11" dependencies = [ "pydantic>=2.7", "pydantic-ai-slim[openai]>=0.0.14", + # noema-core is not yet published (see open risk #2 in + # docs/adr/0012-shared-noema-core-package.md); it is provided via + # PYTHONPATH ("../packages/noema-core/src") the same way this + # repository already provides `noema_reviewer` itself in CI. Pin it + # here as a normal dependency once it is published to an index. ] [project.scripts] @@ -23,7 +28,7 @@ dev = [ ] [tool.pytest.ini_options] -pythonpath = ["."] +pythonpath = [".", "../packages/noema-core/src"] addopts = "--cov=noema_reviewer --cov-branch --cov-report=term-missing --cov-fail-under=100" [tool.coverage.run] From 25288546c08b04c51b53679d379064329cb94116 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:33:41 +0900 Subject: [PATCH 02/92] test: expose noema-core packaging and CI gaps --- test/noema-core-packaging-contract.test.ts | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/noema-core-packaging-contract.test.ts diff --git a/test/noema-core-packaging-contract.test.ts b/test/noema-core-packaging-contract.test.ts new file mode 100644 index 000000000..b15c10cf8 --- /dev/null +++ b/test/noema-core-packaging-contract.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +const centralReview = readFileSync(".github/workflows/central-review.yml", "utf8"); +const reviewerCi = readFileSync(".github/workflows/reviewer-ci.yml", "utf8"); +const reviewerPyproject = readFileSync("reviewer/pyproject.toml", "utf8"); +const corePyproject = readFileSync("packages/noema-core/pyproject.toml", "utf8"); + +describe("noema-core packaging and workflow contract", () => { + it("makes the shared core importable everywhere reviewer code runs", () => { + const sharedPath = + "PYTHONPATH: ${{ github.workspace }}/reviewer:${{ github.workspace }}/packages/noema-core/src"; + + expect(centralReview).toContain(sharedPath); + expect(reviewerCi).toContain(sharedPath); + expect(reviewerCi).not.toContain("PYTHONPATH=. python"); + }); + + it("ships the shared module inside the reviewer wheel until noema-core has an immutable index release", () => { + expect(reviewerPyproject).toContain('[tool.setuptools]'); + expect(reviewerPyproject).toContain('packages = ["noema_reviewer", "noema_core"]'); + expect(reviewerPyproject).toContain('[tool.setuptools.package-dir]'); + expect(reviewerPyproject).toContain('noema_core = "../packages/noema-core/src/noema_core"'); + expect(reviewerCi).toContain("smoke-test installed reviewer wheel"); + }); + + it("uses the lock-validated PydanticAI API floor for both distributions", () => { + const supportedRange = '"pydantic-ai-slim[openai]>=2.9.0,<3"'; + + expect(reviewerPyproject).toContain(supportedRange); + expect(corePyproject).toContain(supportedRange); + }); + + it("runs shared-core coverage and docstring gates in required reviewer CI", () => { + expect(reviewerCi).toContain("test noema-core (100% line+branch coverage gate)"); + expect(reviewerCi).toContain("docstring coverage noema-core (100% gate)"); + }); +}); From a8ea139db5003795251f981da0fb9d527ea53f92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:34:26 +0900 Subject: [PATCH 03/92] test: reproduce reviewer evidence import without noema-core path --- .../tests/test_shared_core_import_boundary.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 reviewer/tests/test_shared_core_import_boundary.py diff --git a/reviewer/tests/test_shared_core_import_boundary.py b/reviewer/tests/test_shared_core_import_boundary.py new file mode 100644 index 000000000..e2d9ef313 --- /dev/null +++ b/reviewer/tests/test_shared_core_import_boundary.py @@ -0,0 +1,35 @@ +"""Regression tests for the shared-core import and distribution boundary.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys + + +def test_evidence_modules_import_without_shared_core_on_pythonpath() -> None: + """Evidence-only reviewer imports must not require the model-construction package.""" + + reviewer_root = Path(__file__).resolve().parents[1] + env = os.environ.copy() + env["PYTHONPATH"] = "." + completed = subprocess.run( + [ + sys.executable, + "-c", + ( + "from noema_reviewer.github_io import fetch_manifest; " + "from noema_reviewer.sandbox import DockerCodeGraphRunner; " + "assert fetch_manifest is not None; " + "assert DockerCodeGraphRunner is not None" + ), + ], + cwd=reviewer_root, + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr From 66e6a5d6e2a1831f301152c7cebb666974289cdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:34:45 +0900 Subject: [PATCH 04/92] fix: decouple evidence imports from noema-core runtime --- reviewer/noema_reviewer/__init__.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/reviewer/noema_reviewer/__init__.py b/reviewer/noema_reviewer/__init__.py index 02e6bb78f..671c40dc1 100644 --- a/reviewer/noema_reviewer/__init__.py +++ b/reviewer/noema_reviewer/__init__.py @@ -6,11 +6,17 @@ publish it as an independent GitHub review, satisfying the organization's two-reviewer merge rule alongside OpenCode. The Noema Cloudflare Worker remains the token-exchange boundary; this package is the judgement plane. + +Agent-construction exports are loaded lazily so evidence-only modules can run +without importing the model runtime. That keeps collection and sandbox evidence +paths independent from the shared ``noema_core`` package while preserving the +existing package-level reviewer API for actual model execution. """ from __future__ import annotations -from .agent import PydanticAIReviewAgent, ReviewAgent, build_agent +from typing import Any + from .manifest import ReviewManifest from .models import Confidence, Finding, ReviewVerdict, Severity, Verdict from .patch_image_validation import ( @@ -30,6 +36,18 @@ inspect_patch_bytes, ) +_AGENT_EXPORTS = frozenset({"PydanticAIReviewAgent", "ReviewAgent", "build_agent"}) + + +def __getattr__(name: str) -> Any: + """Load model-runtime exports only when callers request those symbols.""" + + if name in _AGENT_EXPORTS: + from . import agent + + return getattr(agent, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + __all__ = [ "Confidence", From 32b78b7380e6c75fbc2f552c2455f46d2b9624c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:34:58 +0900 Subject: [PATCH 05/92] test: cover lazy reviewer runtime exports --- .../tests/test_shared_core_import_boundary.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/reviewer/tests/test_shared_core_import_boundary.py b/reviewer/tests/test_shared_core_import_boundary.py index e2d9ef313..3e5c30e07 100644 --- a/reviewer/tests/test_shared_core_import_boundary.py +++ b/reviewer/tests/test_shared_core_import_boundary.py @@ -7,6 +7,10 @@ import subprocess import sys +import pytest + +import noema_reviewer + def test_evidence_modules_import_without_shared_core_on_pythonpath() -> None: """Evidence-only reviewer imports must not require the model-construction package.""" @@ -33,3 +37,18 @@ def test_evidence_modules_import_without_shared_core_on_pythonpath() -> None: ) assert completed.returncode == 0, completed.stderr + + +def test_agent_exports_remain_available_from_package_root() -> None: + """Lazy loading must preserve the existing package-level agent API.""" + + assert noema_reviewer.build_agent is not None + assert noema_reviewer.ReviewAgent is not None + assert noema_reviewer.PydanticAIReviewAgent is not None + + +def test_unknown_package_export_fails_normally() -> None: + """Unknown package attributes must still raise the standard error.""" + + with pytest.raises(AttributeError, match="has no attribute"): + getattr(noema_reviewer, "missing_runtime_export") From c9e22f57d70072ed622362a6e0e687f041c0fd92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:35:10 +0900 Subject: [PATCH 06/92] fix(packaging): bundle shared core into reviewer wheel --- reviewer/pyproject.toml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/reviewer/pyproject.toml b/reviewer/pyproject.toml index f996f0d86..8ba2c68c2 100644 --- a/reviewer/pyproject.toml +++ b/reviewer/pyproject.toml @@ -9,17 +9,23 @@ description = "Noema independent PydanticAI second reviewer for ContextualWisdom requires-python = ">=3.11" dependencies = [ "pydantic>=2.7", - "pydantic-ai-slim[openai]>=0.0.14", - # noema-core is not yet published (see open risk #2 in - # docs/adr/0012-shared-noema-core-package.md); it is provided via - # PYTHONPATH ("../packages/noema-core/src") the same way this - # repository already provides `noema_reviewer` itself in CI. Pin it - # here as a normal dependency once it is published to an index. + "pydantic-ai-slim[openai]>=2.9.0,<3", ] [project.scripts] noema-reviewer = "noema_reviewer.cli:main" +# noema-core is not yet published as an immutable index dependency. Until that +# release exists, the reviewer wheel is built from the monorepo checkout and +# includes the shared module from its single canonical source path. This keeps a +# normal wheel install runnable without copying the module into reviewer/. +[tool.setuptools] +packages = ["noema_reviewer", "noema_core"] + +[tool.setuptools.package-dir] +noema_reviewer = "noema_reviewer" +noema_core = "../packages/noema-core/src/noema_core" + [dependency-groups] dev = [ "pytest>=8.0.0", From 2d52b10ad754ae0f03438f42b9261ec00574109e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:35:28 +0900 Subject: [PATCH 07/92] fix(core): require lock-validated PydanticAI API floor --- packages/noema-core/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/noema-core/pyproject.toml b/packages/noema-core/pyproject.toml index aa771f989..b8fc04ff3 100644 --- a/packages/noema-core/pyproject.toml +++ b/packages/noema-core/pyproject.toml @@ -9,7 +9,7 @@ description = "Shared PydanticAI Agent-construction wiring for Noema's per-conte requires-python = ">=3.11" license = "Apache-2.0" dependencies = [ - "pydantic-ai-slim[openai]>=0.0.14", + "pydantic-ai-slim[openai]>=2.9.0,<3", ] [dependency-groups] From 682f9f0941fdc005eebf4836523cd76ad7301502 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:35:52 +0900 Subject: [PATCH 08/92] fix(ci): enforce shared-core gates and installed-wheel smoke --- .github/workflows/reviewer-ci.yml | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index f5212251a..86b1f412c 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -20,6 +20,7 @@ jobs: timeout-minutes: 30 env: NOEMA_CODEGRAPH_SANDBOX_SOURCE_IMAGE: gcr.io/distroless/java-base-debian13:nonroot + PYTHONPATH: ${{ github.workspace }}/reviewer:${{ github.workspace }}/packages/noema-core/src defaults: run: working-directory: reviewer @@ -50,12 +51,44 @@ jobs: - name: install (hash-pinned dependencies) run: pip install --require-hashes --no-deps -r requirements-ci-hashes.txt + - name: test noema-core (100% line+branch coverage gate) + working-directory: packages/noema-core + run: python -m pytest + + - name: docstring coverage noema-core (100% gate) + working-directory: packages/noema-core + run: python -m interrogate -c pyproject.toml src/noema_core + - name: test (100% line+branch coverage gate) run: python -m pytest - name: docstring coverage (100% gate) run: python -m interrogate -c pyproject.toml noema_reviewer + - name: smoke-test installed reviewer wheel + run: | + set -euo pipefail + wheel_dir="$RUNNER_TEMP/noema-reviewer-wheel" + venv_dir="$RUNNER_TEMP/noema-reviewer-install-smoke" + mkdir -p "$wheel_dir" + python -m pip wheel . --no-deps --no-build-isolation --wheel-dir "$wheel_dir" + wheel="$(find "$wheel_dir" -maxdepth 1 -type f -name 'noema_reviewer-*.whl' -print -quit)" + test -n "$wheel" + python -m venv --system-site-packages "$venv_dir" + "$venv_dir/bin/python" -m pip install --no-deps "$wheel" + ( + cd "$RUNNER_TEMP" + PYTHONPATH= "$venv_dir/bin/python" - <<'PY' + import noema_core + import noema_reviewer + from noema_reviewer.cli import build_parser + + assert noema_core.NOEMA_PERSONA + assert noema_reviewer.build_agent is not None + assert build_parser().prog == "noema-reviewer" + PY + ) + - name: install lock-pinned CodeGraph tooling for sandbox smoke test env: NPM_CONFIG_IGNORE_SCRIPTS: "true" @@ -98,7 +131,7 @@ jobs: source_root="$RUNNER_TEMP/noema-codegraph-smoke" mkdir -p "$source_root" printf 'export const commercialReadiness = true;\n' >"$source_root/example.ts" - PYTHONPATH=. python - <<'PY' + python - <<'PY' import os from noema_reviewer.sandbox import DockerCodeGraphRunner From 37375a62eea8bcd4cb3593d142753b8fadb87cd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:36:22 +0900 Subject: [PATCH 09/92] fix(deps): align reviewer input with validated PydanticAI floor --- reviewer/requirements-ci.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reviewer/requirements-ci.in b/reviewer/requirements-ci.in index a85cb013a..129ab6384 100644 --- a/reviewer/requirements-ci.in +++ b/reviewer/requirements-ci.in @@ -1,4 +1,4 @@ -pydantic-ai-slim[openai]>=0.0.14 +pydantic-ai-slim[openai]>=2.9.0,<3 pytest>=8.0.0 pytest-cov>=5.0.0 interrogate>=1.7.0 From e94ba8e96550a7ace9bf74c1b156d6f987611db6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:37:29 +0900 Subject: [PATCH 10/92] docs(adr): align shared-core rollout with installable packaging --- docs/adr/0012-shared-noema-core-package.md | 68 +++++++++++++--------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/docs/adr/0012-shared-noema-core-package.md b/docs/adr/0012-shared-noema-core-package.md index 4efdf0a83..da66bcb94 100644 --- a/docs/adr/0012-shared-noema-core-package.md +++ b/docs/adr/0012-shared-noema-core-package.md @@ -113,15 +113,15 @@ of v1: migrating a stdlib-only script onto PydanticAI is a rewrite, not an extraction, and this repository's own one-phase-at-a-time convention rules that out of this PR. -**Grafted from C (do in parallel, not deferred):** amend `naruon#1486`'s -description (doc-only) to drop the "intentionally share only a name" framing -this ADR corrects; add one assertion each to this repository's `reviewer/` -test suite and to `.github`'s `noema_review_gate` test suite against a new -`noema-identity.schema.json` (`agent_name`/`authority`/`inference_route`/ -`credential_source`). Cheap (a few asserts against existing test suites), -immediate, and it disambiguates `naruon#1486` from the colliding -`naruon#1384` ADR file before either merges. **Not implemented by this PR** — -tracked as a next step below. +**Grafted from C (planned as an immediate follow-up, not implemented by this +PR):** amend `naruon#1486`'s description (doc-only) to drop the "intentionally +share only a name" framing this ADR corrects; add one assertion each to this +repository's `reviewer/` test suite and to `.github`'s `noema_review_gate` test +suite against a new `noema-identity.schema.json` +(`agent_name`/`authority`/`inference_route`/`credential_source`). This remains +an immediate next step because it disambiguates `naruon#1486` from the +colliding `naruon#1384` ADR file before either merges, but it is not part of +the current extraction. **Named as the explicit phase-2 trigger from B (not built now):** a thin ASGI wrapper (`/v1/review`) around a future noema-core orchestrator-client @@ -155,14 +155,22 @@ Extracted from `reviewer/noema_reviewer` into `packages/noema-core/src/noema_cor test-asserted behavior. `reviewer/` is the sole consumer (self-consumption only; zero new external -consumers in this PR). No behavior change: `reviewer/`'s existing 478-test, -100%-line/branch-coverage, 100%-docstring suite passes unmodified against -the refactored code (verified locally: `python -m pytest` and `python -m -interrogate` both report the same 100% before and after). `noema-core` has -its own equivalent 100%/100% suite. Not yet published to an index — both CI -(`.github/workflows/central-review.yml`) and local pytest reach it via -`PYTHONPATH`, the same mechanism this repository already uses to provide -`noema_reviewer` itself. +consumers in this PR). Evidence-only imports are deliberately lazy and do not +require `noema_core`; model-execution paths load the shared package only when +the agent API is requested. Until `noema-core` has an immutable index release, +the normal `noema-reviewer` wheel is built from this monorepo checkout and +includes the `noema_core` module from its single canonical source path via +setuptools package mapping. That makes an installed reviewer wheel runnable +without copying the shared source into `reviewer/` or relying on ambient +`PYTHONPATH`. + +Both package surfaces now use the lock-validated PydanticAI 2.9 API floor. +Required `reviewer-ci` runs the shared package's 100% line/branch and docstring +gates, the reviewer gates, and an installed-wheel smoke that imports both +`noema_reviewer` and `noema_core` outside the checkout path. The central review +workflow still places the shared source on `PYTHONPATH` for the actual model +publication step; evidence collection does not depend on that path because +package initialization no longer imports model wiring eagerly. This is smaller and lower-risk than starting in `naruon`: single repository, no production tenant-agent touched, and no collision with naruon's two @@ -186,17 +194,20 @@ conflict is resolved — not bundled here. - The kernel is small enough to review in one PR and verify with an existing test suite — no new production surface, no new secret, no new network call. +- The reviewer remains installable before a separate `noema-core` index + publication because its wheel bundles the shared module from the canonical + monorepo source path and CI proves the installed artifact can start. ### Costs and limitations -- `noema-core` is not yet on an index; every consumer needs the same - `PYTHONPATH` accommodation this repository already carries for - `noema_reviewer`, which is one more thing to keep in sync until it is - published. -- The shared kernel's own CI enforcement (its 100% coverage/docstring gates) - runs only via `packages/noema-core`'s local `pyproject.toml` today; it is - not yet wired into a dedicated CI job, only exercised indirectly through - `reviewer/`'s test run. +- `noema-core` is not yet on an index. The reviewer can ship a self-contained + wheel from this repository, but external consumers such as `naruon` must + wait for an immutable package publication rather than consume a mutable + branch or copy source. +- The reviewer wheel build currently depends on the monorepo layout so + setuptools can include the canonical shared package source. Once + `noema-core` is published immutably, the reviewer should switch to a normal + versioned dependency and remove this transitional build mapping. - `.github`'s Noema stays architecturally divergent (no PydanticAI) indefinitely under this decision; that gap is not solved here. - The full CWL-MASTER-CONTEXT vision (`wardnet`'s AI-SOC calling a shared @@ -244,9 +255,10 @@ conflict is resolved — not bundled here. `noema_review_gate` suite; amend `naruon#1486`'s description. - `naruon`'s noema-core adoption PR (PR #2), after `naruon#1486`/`#1384`'s merge-order conflict resolves. -- Publish `noema-core` v0.1.0 to an index once this PR is reviewed and - merged, then convert `reviewer/pyproject.toml`'s TODO comment into a real - pinned dependency. +- Publish `noema-core` v0.1.0 through the repository's selected immutable + package mechanism once this PR is reviewed and merged, then replace the + reviewer's transitional monorepo wheel mapping with a normal versioned + dependency. - Decide package hosting/publishing mechanics (risk 2) and, if an orchestrator-client piece is extracted later, sequence it against `naruon#1384` (risk 1). From 2d727ec14922eafd0253a9488ccd18802d63d956 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:37:51 +0900 Subject: [PATCH 11/92] docs(reviewer): document installable shared-core packaging --- reviewer/README.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/reviewer/README.md b/reviewer/README.md index afe2e8bb2..a300a5914 100644 --- a/reviewer/README.md +++ b/reviewer/README.md @@ -115,16 +115,23 @@ Publication uses the Noema GitHub-App installation token (from the Worker) or a ## Develop ```bash -pip install -e .[dev] # or: pip install pydantic-ai-slim[openai] pytest pytest-cov interrogate -python -m pytest # 100% line+branch coverage gate; picks up ../packages/noema-core/src -python -m interrogate -c pyproject.toml noema_reviewer # 100% docstring gate +pip install -e .[dev] +python -m pytest +python -m interrogate -c pyproject.toml noema_reviewer ``` -`noema-core` is not yet published to an index, so a plain `pip install -e .` -does not make it importable outside pytest (whose `pythonpath` config already -adds `../packages/noema-core/src`). Running `python -m noema_reviewer` -directly needs `PYTHONPATH=../packages/noema-core/src` too, the same way CI's -`central-review.yml` provides it. +The shared source remains canonical at `../packages/noema-core/src/noema_core`. +Until `noema-core` has an immutable index release, the reviewer wheel includes +that module directly from the canonical monorepo path through setuptools package +mapping. A normal wheel install therefore provides both `noema_reviewer` and +`noema_core`; callers do not need an ambient `PYTHONPATH`. Required +`reviewer-ci` builds and installs the wheel in a clean temporary environment and +imports both packages before the artifact is considered valid. + +Evidence-only package imports are intentionally lazy: importing +`noema_reviewer.github_io` or `noema_reviewer.sandbox` does not load the model +construction layer. Actual model execution still imports `noema_core` through +the package-level agent API. Tests drive the agent with PydanticAI's offline `TestModel`/`FunctionModel` and a stub `gh` runner — no network, no secret, no real model. From 6bd8184455819f1bfc5099e8c40ec08832f4a621 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:38:06 +0900 Subject: [PATCH 12/92] docs(core): align package status with wheel and CI contract --- packages/noema-core/README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/noema-core/README.md b/packages/noema-core/README.md index e453e88fb..094f2bb94 100644 --- a/packages/noema-core/README.md +++ b/packages/noema-core/README.md @@ -26,14 +26,21 @@ each consumer's own bounded context. ## Status Self-consumption only: `reviewer/noema_reviewer` is the sole consumer today. -Not yet published to an index — consumed via `PYTHONPATH` (see -`reviewer/pyproject.toml`'s `pythonpath` and `.github/workflows/central-review.yml`). -Publishing to PyPI and naruon's adoption are tracked as follow-ups in the ADR. +`noema-core` is not yet published to an immutable package index, so external +consumers must not pin a mutable branch or copy this source. During this +transition the `noema-reviewer` wheel includes `noema_core` directly from this +single canonical source path through setuptools package mapping. Required +`reviewer-ci` runs this package's 100% line/branch and docstring gates and then +smoke-installs the reviewer wheel outside the checkout. + +Publishing `noema-core` through the repository's selected immutable package +mechanism and moving consumers to a normal versioned dependency are tracked as +follow-ups in the ADR. ## Develop ```bash -pip install -e .[dev] +pip install -e . python -m pytest # 100% line+branch coverage gate python -m interrogate -c pyproject.toml src/noema_core # 100% docstring gate ``` From 656aeef2fc32a977fed8ec33752df3b6e2a1f332 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:45:26 +0900 Subject: [PATCH 13/92] test(ci): reproduce invalid reviewer wheel smoke import --- test/noema-core-packaging-contract.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/noema-core-packaging-contract.test.ts b/test/noema-core-packaging-contract.test.ts index b15c10cf8..ea37404db 100644 --- a/test/noema-core-packaging-contract.test.ts +++ b/test/noema-core-packaging-contract.test.ts @@ -25,6 +25,12 @@ describe("noema-core packaging and workflow contract", () => { expect(reviewerCi).toContain("smoke-test installed reviewer wheel"); }); + it("smokes a CLI symbol that the installed reviewer actually exports", () => { + expect(reviewerCi).toContain("from noema_reviewer.cli import parse_args"); + expect(reviewerCi).toContain('assert parse_args([]).repo == ""'); + expect(reviewerCi).not.toContain("from noema_reviewer.cli import build_parser"); + }); + it("uses the lock-validated PydanticAI API floor for both distributions", () => { const supportedRange = '"pydantic-ai-slim[openai]>=2.9.0,<3"'; From 49e8f8b27909074bfb11e8ce3ddd69445d741085 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:45:52 +0900 Subject: [PATCH 14/92] fix(ci): smoke the exported reviewer CLI parser --- .github/workflows/reviewer-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index 86b1f412c..22bd13e0f 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -81,11 +81,11 @@ jobs: PYTHONPATH= "$venv_dir/bin/python" - <<'PY' import noema_core import noema_reviewer - from noema_reviewer.cli import build_parser + from noema_reviewer.cli import parse_args assert noema_core.NOEMA_PERSONA assert noema_reviewer.build_agent is not None - assert build_parser().prog == "noema-reviewer" + assert parse_args([]).repo == "" PY ) From e60f1ddbe7ab5cb31eb203aec67e555cdb218eee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:19:20 +0900 Subject: [PATCH 15/92] test(ddd): keep provider SDK wiring out of noema-core --- packages/noema-core/tests/test_owner_boundary.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/noema-core/tests/test_owner_boundary.py diff --git a/packages/noema-core/tests/test_owner_boundary.py b/packages/noema-core/tests/test_owner_boundary.py new file mode 100644 index 000000000..ccf9f2b3f --- /dev/null +++ b/packages/noema-core/tests/test_owner_boundary.py @@ -0,0 +1,11 @@ +"""DDD fitness tests for the shared Noema runtime package boundary.""" + +from __future__ import annotations + +import noema_core + + +def test_shared_core_does_not_construct_provider_specific_models() -> None: + """Model/provider transport construction must remain outside Noema's Shared Kernel.""" + + assert not hasattr(noema_core, "build_openai_model") From eed7cf196ab4338c383b70bfe7b2735e685a52ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:20:06 +0900 Subject: [PATCH 16/92] fix(ddd): remove provider transport from noema-core --- packages/noema-core/src/noema_core/agent.py | 60 +++++---------------- 1 file changed, 14 insertions(+), 46 deletions(-) diff --git a/packages/noema-core/src/noema_core/agent.py b/packages/noema-core/src/noema_core/agent.py index 74dad1183..014816027 100644 --- a/packages/noema-core/src/noema_core/agent.py +++ b/packages/noema-core/src/noema_core/agent.py @@ -1,17 +1,13 @@ """Shared PydanticAI Agent-construction wiring for Noema's per-context consumers. -Every Noema consumer (this repository's CI second reviewer, naruon's tenant -agent, and any future consumer) independently wired the same three-step -PydanticAI chain — an ``AsyncOpenAI`` client, wrapped in ``OpenAIChatModel``, -wrapped in ``OpenAIProvider``, then handed to ``Agent(...)`` — and nothing -else. This module is that shared scaffolding, factored out once a second -genuine same-language duplicate of it existed (naruon's -``noema_agent.py:build_noema_agent`` and this repository's -``noema_reviewer``). +The Shared Kernel centralizes only framework-neutral Noema agent construction +that is safe to reuse across bounded contexts. Provider discovery, endpoint +selection, credentials, provider SDKs, model routing and failover remain outside +this package and are supplied through an already constructed PydanticAI model. This package deliberately owns none of a consumer's domain logic: no verdict -schema, no tool/deps machinery, no credential resolution or validation -policy, no tenant isolation. Those stay local to each bounded context. See +schema, no tool/deps machinery, no credential resolution or validation policy, +no tenant isolation. Those stay local to each bounded context. See ``docs/adr/0012-shared-noema-core-package.md`` in ``ContextualWisdomLab/noema`` for the full rationale and scope boundary. """ @@ -20,11 +16,8 @@ from typing import Any -from openai import AsyncOpenAI from pydantic_ai import Agent from pydantic_ai.models import Model -from pydantic_ai.models.openai import OpenAIChatModel -from pydantic_ai.providers.openai import OpenAIProvider NOEMA_PERSONA = "You are Noema, an independent AI agent for ContextualWisdomLab." @@ -37,33 +30,6 @@ """ -def build_openai_model( - *, - base_url: str, - api_key: str, - model_name: str, - timeout: float | None = None, - max_retries: int = 1, -) -> Model: - """Wire an OpenAI-compatible PydanticAI model from resolved connection settings. - - This is the ``AsyncOpenAI`` -> ``OpenAIChatModel`` -> ``OpenAIProvider`` - chain every Noema consumer needs to talk to an OpenAI-compatible gateway - (``contextual-orchestrator`` in production for this repository and for - naruon's gateway-routed path). Resolving and validating ``base_url``, - ``api_key``, and ``model_name`` — KV lookups, env fallback, allowed-host - checks, routing-alias policy, and the like — stays the caller's - responsibility; this function only performs the construction. - """ - client = AsyncOpenAI( - base_url=base_url, - api_key=api_key, - timeout=timeout, - max_retries=max_retries, - ) - return OpenAIChatModel(model_name, provider=OpenAIProvider(openai_client=client)) - - def build_agent( model: Model | str, *, @@ -72,12 +38,14 @@ def build_agent( deps_type: Any = None, retries: int = 3, ) -> Agent[Any, Any]: - """Construct a PydanticAI ``Agent`` using Noema's shared model wiring. - - ``output_type`` (a consumer's verdict/result schema), ``deps_type`` (a - consumer's tool/deps machinery), and ``system_prompt`` (persona plus - domain instructions) all stay per-consumer — this function only - centralizes the repeated ``Agent(...)`` construction call. + """Construct a PydanticAI ``Agent`` around a caller-owned model adapter. + + ``model`` is injected so provider transport, credentials, routing and + failover cannot migrate into Noema's Shared Kernel. ``output_type`` (a + consumer's verdict/result schema), ``deps_type`` (a consumer's tool/deps + machinery), and ``system_prompt`` (persona plus domain instructions) also + remain per-consumer. This function centralizes only the repeated + ``Agent(...)`` construction call. """ kwargs: dict[str, Any] = {} if deps_type is not None: From 07fb3dd7e97d749866a94b2b92058785ff631525 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:20:27 +0900 Subject: [PATCH 17/92] fix(ddd): narrow noema-core public surface --- packages/noema-core/src/noema_core/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/noema-core/src/noema_core/__init__.py b/packages/noema-core/src/noema_core/__init__.py index 42d8948c5..5e54b994a 100644 --- a/packages/noema-core/src/noema_core/__init__.py +++ b/packages/noema-core/src/noema_core/__init__.py @@ -1,14 +1,13 @@ """noema-core: shared PydanticAI Agent-construction wiring for Noema consumers. -See :mod:`noema_core.agent` for the two exported functions and the shared -persona fragment. Scope is deliberately narrow — see -``docs/adr/0012-shared-noema-core-package.md`` in -``ContextualWisdomLab/noema`` for what this package owns and what it -explicitly excludes. +See :mod:`noema_core.agent` for the provider-neutral agent factory and shared +persona fragment. Provider transport and credential wiring stay outside this +Shared Kernel. See ``docs/adr/0012-shared-noema-core-package.md`` in +``ContextualWisdomLab/noema`` for the ownership boundary. """ from __future__ import annotations -from .agent import NOEMA_PERSONA, build_agent, build_openai_model +from .agent import NOEMA_PERSONA, build_agent -__all__ = ["NOEMA_PERSONA", "build_agent", "build_openai_model"] +__all__ = ["NOEMA_PERSONA", "build_agent"] From 708d55bfb8308670ccda9cc68ddea45db0b86754 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:21:05 +0900 Subject: [PATCH 18/92] fix(ddd): keep orchestrator transport in reviewer adapter --- reviewer/noema_reviewer/config.py | 33 ++++++++++++++++++------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/reviewer/noema_reviewer/config.py b/reviewer/noema_reviewer/config.py index e7cbe457b..eda219a75 100644 --- a/reviewer/noema_reviewer/config.py +++ b/reviewer/noema_reviewer/config.py @@ -7,9 +7,10 @@ CI step uses to hand secrets to the KV, so the env fallback is explicit and documented rather than scattered ``os.getenv`` reads. -The reviewer talks to an OpenAI-compatible endpoint (the -``contextual-orchestrator`` gateway in production). Upstream model selection -stays in that gateway; leftover sequential ``NOEMA_FALLBACK_*`` settings fail +The reviewer talks to an OpenAI-compatible endpoint exposed by +``contextual-orchestrator`` in production. Upstream model selection, provider +routing and failover stay in that gateway; this module owns only the reviewer's +transport adapter. Leftover sequential ``NOEMA_FALLBACK_*`` settings fail closed instead of trying the next model inside Noema. """ @@ -151,25 +152,29 @@ def resolve_config(credential_getter: CredentialGetter | None = None) -> Reviewe def resolve_model(config: ReviewerConfig | None = None) -> Model: - """Build an OpenAI-compatible PydanticAI model from resolved configuration. - - The reviewer routes every model call through an OpenAI-compatible endpoint - (the ``contextual-orchestrator`` gateway in production), so the OpenAI - provider is a required dependency rather than an optional extra. The - ``AsyncOpenAI`` -> ``OpenAIChatModel`` -> ``OpenAIProvider`` construction - itself is shared wiring from ``noema_core``; validation and resolution of - what goes into it stays here, since that policy is reviewer-specific. + """Build the reviewer's transport adapter to contextual-orchestrator. + + The OpenAI-compatible client exists only as this bounded-context adapter to + the orchestrator endpoint. It does not select a provider, discover models, + or implement fallback; those authorities remain in contextual-orchestrator. + The shared ``noema_core`` package receives the resulting PydanticAI model by + injection and therefore has no provider SDK or credential surface. """ - from noema_core import build_openai_model + from openai import AsyncOpenAI + from pydantic_ai.models.openai import OpenAIChatModel + from pydantic_ai.providers.openai import OpenAIProvider resolved = config or resolve_config() _require_single_routing_alias("NOEMA_LLM_MODEL", resolved.model_name) _require_safe_model_endpoint("NOEMA_LLM_API_URL", resolved.base_url) - return build_openai_model( + client = AsyncOpenAI( base_url=resolved.base_url, api_key=resolved.api_key, - model_name=resolved.model_name, timeout=resolved.request_timeout_seconds, max_retries=resolved.max_retries, ) + return OpenAIChatModel( + resolved.model_name, + provider=OpenAIProvider(openai_client=client), + ) From f66f9f3133f29b5f6ee9a9b892073c2b5e6502e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:21:22 +0900 Subject: [PATCH 19/92] test(ddd): verify injected-model agent construction --- packages/noema-core/tests/test_agent.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/noema-core/tests/test_agent.py b/packages/noema-core/tests/test_agent.py index 1bacd2c56..5ff71ee0f 100644 --- a/packages/noema-core/tests/test_agent.py +++ b/packages/noema-core/tests/test_agent.py @@ -1,23 +1,11 @@ -"""Tests for the shared Agent-construction wiring.""" +"""Tests for the shared provider-neutral Agent-construction wiring.""" from __future__ import annotations from pydantic_ai import Agent -from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.models.test import TestModel -from noema_core import NOEMA_PERSONA, build_agent, build_openai_model - - -def test_build_openai_model_wires_an_openai_chat_model() -> None: - """build_openai_model returns a PydanticAI model wired to the given settings.""" - model = build_openai_model( - base_url="https://orchestrator.example/v1", - api_key="k", - model_name="contextual-orchestrator", - ) - assert isinstance(model, OpenAIChatModel) - assert model.model_name == "contextual-orchestrator" +from noema_core import NOEMA_PERSONA, build_agent def test_build_agent_applies_output_type_and_system_prompt() -> None: From f8ae2d967af61ede356e26f3ee075f5878bba8ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:21:37 +0900 Subject: [PATCH 20/92] fix(ddd): remove provider extra from noema-core --- packages/noema-core/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/noema-core/pyproject.toml b/packages/noema-core/pyproject.toml index b8fc04ff3..4da0392f4 100644 --- a/packages/noema-core/pyproject.toml +++ b/packages/noema-core/pyproject.toml @@ -5,11 +5,11 @@ build-backend = "setuptools.build_meta" [project] name = "noema-core" version = "0.1.0" -description = "Shared PydanticAI Agent-construction wiring for Noema's per-context consumers (reviewer, naruon, and future consumers)." +description = "Provider-neutral PydanticAI Agent-construction wiring for Noema's per-context consumers." requires-python = ">=3.11" license = "Apache-2.0" dependencies = [ - "pydantic-ai-slim[openai]>=2.9.0,<3", + "pydantic-ai-slim>=2.9.0,<3", ] [dependency-groups] From 0d9bd5f7609fa4b1a7b6de4bcfbffb634ab4bd6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:21:57 +0900 Subject: [PATCH 21/92] test(ddd): keep provider extra at reviewer adapter --- test/noema-core-packaging-contract.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/test/noema-core-packaging-contract.test.ts b/test/noema-core-packaging-contract.test.ts index ea37404db..23c0030ba 100644 --- a/test/noema-core-packaging-contract.test.ts +++ b/test/noema-core-packaging-contract.test.ts @@ -31,11 +31,10 @@ describe("noema-core packaging and workflow contract", () => { expect(reviewerCi).not.toContain("from noema_reviewer.cli import build_parser"); }); - it("uses the lock-validated PydanticAI API floor for both distributions", () => { - const supportedRange = '"pydantic-ai-slim[openai]>=2.9.0,<3"'; - - expect(reviewerPyproject).toContain(supportedRange); - expect(corePyproject).toContain(supportedRange); + it("keeps the provider SDK extra at the reviewer integration adapter", () => { + expect(reviewerPyproject).toContain('"pydantic-ai-slim[openai]>=2.9.0,<3"'); + expect(corePyproject).toContain('"pydantic-ai-slim>=2.9.0,<3"'); + expect(corePyproject).not.toContain("pydantic-ai-slim[openai]"); }); it("runs shared-core coverage and docstring gates in required reviewer CI", () => { From aad5d74d3528937d86e9c691e7efade455798ec6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:22:45 +0900 Subject: [PATCH 22/92] docs(ddd): document provider-neutral core boundary --- packages/noema-core/README.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/noema-core/README.md b/packages/noema-core/README.md index 094f2bb94..4cc7112be 100644 --- a/packages/noema-core/README.md +++ b/packages/noema-core/README.md @@ -1,27 +1,31 @@ # noema-core -Shared PydanticAI `Agent`-construction wiring for Noema's per-context +Provider-neutral PydanticAI `Agent` construction shared by Noema's per-context consumers. See [`docs/adr/0012-shared-noema-core-package.md`](../../docs/adr/0012-shared-noema-core-package.md) for the decision and its scope boundary. ## What this package is -Two functions and one constant, extracted from `reviewer/noema_reviewer` -after the same `AsyncOpenAI` → `OpenAIChatModel` → `OpenAIProvider` → -`Agent(...)` wiring was found independently built in -`ContextualWisdomLab/naruon`'s `noema_agent.py`: +One function and one identity fragment shared without moving provider authority +into Noema: -- `build_openai_model(*, base_url, api_key, model_name, timeout=None, max_retries=1) -> Model` - `build_agent(model, *, system_prompt, output_type=str, deps_type=None, retries=3) -> Agent` -- `NOEMA_PERSONA` — the shared "You are Noema, an independent AI agent for + constructs an agent around a caller-supplied PydanticAI model adapter. +- `NOEMA_PERSONA` is the shared "You are Noema, an independent AI agent for ContextualWisdomLab." identity fragment consumers prepend to their own system prompt. +The injected model is deliberate. `noema-core` does not construct `AsyncOpenAI`, +`OpenAIChatModel`, `OpenAIProvider`, provider credentials, model discovery, +routing or failover. A consuming bounded context may own a transport adapter to +the published `contextual-orchestrator` interface, but that adapter does not +become Shared Kernel authority. + ## What this package explicitly is not It does not own a verdict/output schema, tool/deps machinery, credential -resolution or validation policy, or tenant isolation. Those stay local to -each consumer's own bounded context. +resolution or validation policy, provider SDK, routing policy, provider +fallback, or tenant isolation. Those stay with their canonical owners. ## Status From 6b730d4ed70c4d67c36e1674b6d4c9cee96801a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:43:38 +0900 Subject: [PATCH 23/92] test(package): expose reviewer sdist gap --- .github/workflows/reviewer-ci.yml | 50 ++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index 22bd13e0f..e74647f95 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -65,20 +65,47 @@ jobs: - name: docstring coverage (100% gate) run: python -m interrogate -c pyproject.toml noema_reviewer - - name: smoke-test installed reviewer wheel + - name: smoke-test installed reviewer wheel and sdist-to-wheel path run: | set -euo pipefail wheel_dir="$RUNNER_TEMP/noema-reviewer-wheel" - venv_dir="$RUNNER_TEMP/noema-reviewer-install-smoke" - mkdir -p "$wheel_dir" + sdist_dir="$RUNNER_TEMP/noema-reviewer-sdist" + sdist_wheel_dir="$RUNNER_TEMP/noema-reviewer-sdist-wheel" + direct_venv="$RUNNER_TEMP/noema-reviewer-install-smoke" + sdist_venv="$RUNNER_TEMP/noema-reviewer-sdist-install-smoke" + mkdir -p "$wheel_dir" "$sdist_dir" "$sdist_wheel_dir" + python -m pip wheel . --no-deps --no-build-isolation --wheel-dir "$wheel_dir" - wheel="$(find "$wheel_dir" -maxdepth 1 -type f -name 'noema_reviewer-*.whl' -print -quit)" - test -n "$wheel" - python -m venv --system-site-packages "$venv_dir" - "$venv_dir/bin/python" -m pip install --no-deps "$wheel" - ( - cd "$RUNNER_TEMP" - PYTHONPATH= "$venv_dir/bin/python" - <<'PY' + direct_wheel="$(find "$wheel_dir" -maxdepth 1 -type f -name 'noema_reviewer-*.whl' -print -quit)" + test -n "$direct_wheel" + + SDIST_DIR="$sdist_dir" SDIST_NAME_FILE="$RUNNER_TEMP/noema-reviewer-sdist-name" python - <<'PY' + import os + from pathlib import Path + from setuptools.build_meta import build_sdist + + sdist_name = build_sdist(os.environ["SDIST_DIR"]) + Path(os.environ["SDIST_NAME_FILE"]).write_text(sdist_name, encoding="utf-8") + PY + sdist="$sdist_dir/$(cat "$RUNNER_TEMP/noema-reviewer-sdist-name")" + test -f "$sdist" + python -m pip wheel "$sdist" --no-deps --no-build-isolation --wheel-dir "$sdist_wheel_dir" + sdist_wheel="$(find "$sdist_wheel_dir" -maxdepth 1 -type f -name 'noema_reviewer-*.whl' -print -quit)" + test -n "$sdist_wheel" + + for contract in direct sdist; do + if [ "$contract" = direct ]; then + wheel="$direct_wheel" + venv_dir="$direct_venv" + else + wheel="$sdist_wheel" + venv_dir="$sdist_venv" + fi + python -m venv --system-site-packages "$venv_dir" + "$venv_dir/bin/python" -m pip install --no-deps "$wheel" + ( + cd "$RUNNER_TEMP" + PYTHONPATH= "$venv_dir/bin/python" - <<'PY' import noema_core import noema_reviewer from noema_reviewer.cli import parse_args @@ -87,7 +114,8 @@ jobs: assert noema_reviewer.build_agent is not None assert parse_args([]).repo == "" PY - ) + ) + done - name: install lock-pinned CodeGraph tooling for sandbox smoke test env: From 6c7c64f6e4a0cee68db08dda6abda2fac05f7cf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:44:11 +0900 Subject: [PATCH 24/92] fix(package): stage canonical core for reviewer builds --- reviewer/build_backend.py | 105 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 reviewer/build_backend.py diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py new file mode 100644 index 000000000..9df786759 --- /dev/null +++ b/reviewer/build_backend.py @@ -0,0 +1,105 @@ +"""PEP 517 wrapper that stages the canonical noema-core package for distribution builds. + +The reviewer cannot declare an immutable external ``noema-core`` dependency until +that package is published. Repository builds therefore stage the canonical +monorepo package into a build-only directory before delegating to setuptools. +The staged directory is included in source distributions so an extracted sdist +can build a wheel without access to the original monorepo checkout. +""" + +from __future__ import annotations + +from pathlib import Path +from shutil import copytree, rmtree +from typing import Any, Callable + +from setuptools import build_meta as _setuptools + +_PROJECT_ROOT = Path(__file__).resolve().parent +_CANONICAL_CORE = _PROJECT_ROOT.parent / "packages" / "noema-core" / "src" / "noema_core" +_STAGING_ROOT = _PROJECT_ROOT / "_build_include" +_STAGED_CORE = _STAGING_ROOT / "noema_core" + + +def _prepare_core() -> bool: + """Ensure packaging reads one exact snapshot of the canonical core source. + + A monorepo checkout always recreates staging from the canonical source so a + stale local staging directory cannot become package authority. An extracted + source distribution has no sibling package checkout and therefore consumes + the staged snapshot embedded by the source-distribution build. + """ + + if _CANONICAL_CORE.is_dir(): + if _STAGING_ROOT.exists(): + rmtree(_STAGING_ROOT) + _STAGING_ROOT.mkdir(parents=True) + copytree(_CANONICAL_CORE, _STAGED_CORE) + return True + if _STAGED_CORE.is_dir(): + return False + raise RuntimeError("canonical noema-core source is unavailable for reviewer packaging") + + +def _with_core_staging(builder: Callable[..., str], *args: Any, **kwargs: Any) -> str: + """Delegate a PEP 517 build while cleaning repository-only staging afterward.""" + + created = _prepare_core() + try: + return builder(*args, **kwargs) + finally: + if created and _STAGING_ROOT.exists(): + rmtree(_STAGING_ROOT) + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """Build a reviewer wheel containing the staged canonical noema-core snapshot.""" + + return _with_core_staging( + _setuptools.build_wheel, + wheel_directory, + config_settings, + metadata_directory, + ) + + +def build_sdist( + sdist_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Build a self-contained source distribution from canonical monorepo source.""" + + return _with_core_staging(_setuptools.build_sdist, sdist_directory, config_settings) + + +def prepare_metadata_for_build_wheel( + metadata_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Prepare wheel metadata under the same package-discovery boundary as builds.""" + + return _with_core_staging( + _setuptools.prepare_metadata_for_build_wheel, + metadata_directory, + config_settings, + ) + + +def get_requires_for_build_wheel( + config_settings: dict[str, Any] | None = None, +) -> list[str]: + """Return setuptools wheel-build requirements without changing dependency policy.""" + + return _setuptools.get_requires_for_build_wheel(config_settings) + + +def get_requires_for_build_sdist( + config_settings: dict[str, Any] | None = None, +) -> list[str]: + """Return setuptools sdist-build requirements without changing dependency policy.""" + + return _setuptools.get_requires_for_build_sdist(config_settings) From 6bf8313cf92566b31264acb89f0953a6ed16e285 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:44:18 +0900 Subject: [PATCH 25/92] fix(package): retain reviewer build backend in sdist --- reviewer/MANIFEST.in | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 reviewer/MANIFEST.in diff --git a/reviewer/MANIFEST.in b/reviewer/MANIFEST.in new file mode 100644 index 000000000..3834c316a --- /dev/null +++ b/reviewer/MANIFEST.in @@ -0,0 +1,2 @@ +include build_backend.py +recursive-include _build_include/noema_core *.py From aa12283dfe743527eb89534c2e2650718806252e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:44:46 +0900 Subject: [PATCH 26/92] fix(package): make reviewer sdist self-contained --- reviewer/pyproject.toml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/reviewer/pyproject.toml b/reviewer/pyproject.toml index 8ba2c68c2..e5b6f168b 100644 --- a/reviewer/pyproject.toml +++ b/reviewer/pyproject.toml @@ -1,6 +1,7 @@ [build-system] requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" +build-backend = "build_backend" +backend-path = ["."] [project] name = "noema-reviewer" @@ -15,16 +16,17 @@ dependencies = [ [project.scripts] noema-reviewer = "noema_reviewer.cli:main" -# noema-core is not yet published as an immutable index dependency. Until that -# release exists, the reviewer wheel is built from the monorepo checkout and -# includes the shared module from its single canonical source path. This keeps a -# normal wheel install runnable without copying the module into reviewer/. +# noema-core is not yet published as an immutable index dependency. The custom +# PEP 517 backend stages the exact canonical monorepo source into a build-only +# directory. That snapshot is embedded in an sdist, allowing its wheel to build +# without the original checkout while keeping repository source authority in +# packages/noema-core. [tool.setuptools] packages = ["noema_reviewer", "noema_core"] [tool.setuptools.package-dir] noema_reviewer = "noema_reviewer" -noema_core = "../packages/noema-core/src/noema_core" +noema_core = "_build_include/noema_core" [dependency-groups] dev = [ From f899483aa75d57a6672945b734c0338b98f21578 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:45:17 +0900 Subject: [PATCH 27/92] fix(package): stage core for all PEP 517 hooks --- reviewer/build_backend.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py index 9df786759..8f30d09fa 100644 --- a/reviewer/build_backend.py +++ b/reviewer/build_backend.py @@ -11,7 +11,7 @@ from pathlib import Path from shutil import copytree, rmtree -from typing import Any, Callable +from typing import Any, Callable, TypeVar from setuptools import build_meta as _setuptools @@ -19,6 +19,7 @@ _CANONICAL_CORE = _PROJECT_ROOT.parent / "packages" / "noema-core" / "src" / "noema_core" _STAGING_ROOT = _PROJECT_ROOT / "_build_include" _STAGED_CORE = _STAGING_ROOT / "noema_core" +_BuildResult = TypeVar("_BuildResult") def _prepare_core() -> bool: @@ -41,8 +42,12 @@ def _prepare_core() -> bool: raise RuntimeError("canonical noema-core source is unavailable for reviewer packaging") -def _with_core_staging(builder: Callable[..., str], *args: Any, **kwargs: Any) -> str: - """Delegate a PEP 517 build while cleaning repository-only staging afterward.""" +def _with_core_staging( + builder: Callable[..., _BuildResult], + *args: Any, + **kwargs: Any, +) -> _BuildResult: + """Delegate a PEP 517 hook while cleaning repository-only staging afterward.""" created = _prepare_core() try: @@ -92,14 +97,14 @@ def prepare_metadata_for_build_wheel( def get_requires_for_build_wheel( config_settings: dict[str, Any] | None = None, ) -> list[str]: - """Return setuptools wheel-build requirements without changing dependency policy.""" + """Return wheel-build requirements after validating package-source availability.""" - return _setuptools.get_requires_for_build_wheel(config_settings) + return _with_core_staging(_setuptools.get_requires_for_build_wheel, config_settings) def get_requires_for_build_sdist( config_settings: dict[str, Any] | None = None, ) -> list[str]: - """Return setuptools sdist-build requirements without changing dependency policy.""" + """Return sdist-build requirements after validating package-source availability.""" - return _setuptools.get_requires_for_build_sdist(config_settings) + return _with_core_staging(_setuptools.get_requires_for_build_sdist, config_settings) From 949da3c10aa66e3c0ec01621786f4e552dea3d70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:45:36 +0900 Subject: [PATCH 28/92] chore(package): ignore reviewer build staging --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 910e84693..8fa7fc874 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ exchange-30d.ndjson exchange-30d.ndjson.provenance.json noema-kpi-evidence.json noema-smoke-evidence.json +reviewer/_build_include/ From d4f32615f30f9dc5ef9dcaa5329ff40b1b5cab82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:47:15 +0900 Subject: [PATCH 29/92] fix(package): exercise reviewer PEP 517 backend --- .github/workflows/reviewer-ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index e74647f95..4b9c206d7 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -82,7 +82,7 @@ jobs: SDIST_DIR="$sdist_dir" SDIST_NAME_FILE="$RUNNER_TEMP/noema-reviewer-sdist-name" python - <<'PY' import os from pathlib import Path - from setuptools.build_meta import build_sdist + from build_backend import build_sdist sdist_name = build_sdist(os.environ["SDIST_DIR"]) Path(os.environ["SDIST_NAME_FILE"]).write_text(sdist_name, encoding="utf-8") @@ -106,10 +106,18 @@ jobs: ( cd "$RUNNER_TEMP" PYTHONPATH= "$venv_dir/bin/python" - <<'PY' + import hashlib + import os + from pathlib import Path + import noema_core + import noema_core.agent import noema_reviewer from noema_reviewer.cli import parse_args + canonical_agent = Path(os.environ["GITHUB_WORKSPACE"]) / "packages" / "noema-core" / "src" / "noema_core" / "agent.py" + installed_agent = Path(noema_core.agent.__file__) + assert hashlib.sha256(installed_agent.read_bytes()).digest() == hashlib.sha256(canonical_agent.read_bytes()).digest() assert noema_core.NOEMA_PERSONA assert noema_reviewer.build_agent is not None assert parse_args([]).repo == "" From 728ef67176c49cb7865893d44c869b6e52fa5480 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:48:04 +0900 Subject: [PATCH 30/92] test(package): lock self-contained reviewer sdist contract --- test/noema-core-packaging-contract.test.ts | 24 +++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/test/noema-core-packaging-contract.test.ts b/test/noema-core-packaging-contract.test.ts index 23c0030ba..e464de8db 100644 --- a/test/noema-core-packaging-contract.test.ts +++ b/test/noema-core-packaging-contract.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest"; const centralReview = readFileSync(".github/workflows/central-review.yml", "utf8"); const reviewerCi = readFileSync(".github/workflows/reviewer-ci.yml", "utf8"); const reviewerPyproject = readFileSync("reviewer/pyproject.toml", "utf8"); +const reviewerBuildBackend = readFileSync("reviewer/build_backend.py", "utf8"); +const reviewerManifest = readFileSync("reviewer/MANIFEST.in", "utf8"); const corePyproject = readFileSync("packages/noema-core/pyproject.toml", "utf8"); describe("noema-core packaging and workflow contract", () => { @@ -17,12 +19,28 @@ describe("noema-core packaging and workflow contract", () => { expect(reviewerCi).not.toContain("PYTHONPATH=. python"); }); - it("ships the shared module inside the reviewer wheel until noema-core has an immutable index release", () => { + it("stages the canonical core into reviewer build artifacts until an immutable index release exists", () => { + expect(reviewerPyproject).toContain('build-backend = "build_backend"'); + expect(reviewerPyproject).toContain('backend-path = ["."]'); expect(reviewerPyproject).toContain('[tool.setuptools]'); expect(reviewerPyproject).toContain('packages = ["noema_reviewer", "noema_core"]'); expect(reviewerPyproject).toContain('[tool.setuptools.package-dir]'); - expect(reviewerPyproject).toContain('noema_core = "../packages/noema-core/src/noema_core"'); - expect(reviewerCi).toContain("smoke-test installed reviewer wheel"); + expect(reviewerPyproject).toContain('noema_core = "_build_include/noema_core"'); + expect(reviewerBuildBackend).toContain('"packages" / "noema-core" / "src" / "noema_core"'); + expect(reviewerBuildBackend).toContain('from setuptools import build_meta as _setuptools'); + expect(reviewerBuildBackend).toContain('def build_sdist('); + expect(reviewerManifest).toContain('include build_backend.py'); + expect(reviewerManifest).toContain('recursive-include _build_include/noema_core *.py'); + expect(reviewerCi).toContain("smoke-test installed reviewer wheel and sdist-to-wheel path"); + expect(reviewerCi).toContain("from build_backend import build_sdist"); + expect(reviewerCi).toContain('python -m pip wheel "$sdist"'); + expect(reviewerCi).toContain("hashlib.sha256(installed_agent.read_bytes()).digest()"); + }); + + it("does not retain the obsolete out-of-tree setuptools package mapping", () => { + expect(reviewerPyproject).not.toContain( + 'noema_core = "../packages/noema-core/src/noema_core"', + ); }); it("smokes a CLI symbol that the installed reviewer actually exports", () => { From c3a1dd99391a94538db0cb362d55edf34caecfc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:08:46 +0900 Subject: [PATCH 31/92] test(core): reject unresolved model routing strings --- packages/noema-core/tests/test_agent.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/noema-core/tests/test_agent.py b/packages/noema-core/tests/test_agent.py index 5ff71ee0f..0d4d05837 100644 --- a/packages/noema-core/tests/test_agent.py +++ b/packages/noema-core/tests/test_agent.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from pydantic_ai import Agent from pydantic_ai.models.test import TestModel @@ -32,6 +33,15 @@ def test_build_agent_forwards_deps_type_only_when_given() -> None: assert agent.deps_type is dict +def test_build_agent_rejects_unresolved_model_names() -> None: + """Provider/model discovery stays outside noema-core's Shared Kernel.""" + with pytest.raises(TypeError, match="constructed PydanticAI Model"): + build_agent( + "openai:gpt-4o-mini", # type: ignore[arg-type] + system_prompt=NOEMA_PERSONA, + ) + + def test_noema_persona_names_the_organization() -> None: """The shared persona fragment names Noema and the organization it serves.""" assert "Noema" in NOEMA_PERSONA From 5687bb08761f145cf1898e6e4f56024eb83dc1c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:09:14 +0900 Subject: [PATCH 32/92] fix(core): keep model routing outside shared kernel --- packages/noema-core/src/noema_core/agent.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/noema-core/src/noema_core/agent.py b/packages/noema-core/src/noema_core/agent.py index 014816027..b294226ad 100644 --- a/packages/noema-core/src/noema_core/agent.py +++ b/packages/noema-core/src/noema_core/agent.py @@ -31,7 +31,7 @@ def build_agent( - model: Model | str, + model: Model, *, system_prompt: str, output_type: Any = str, @@ -40,13 +40,17 @@ def build_agent( ) -> Agent[Any, Any]: """Construct a PydanticAI ``Agent`` around a caller-owned model adapter. - ``model`` is injected so provider transport, credentials, routing and - failover cannot migrate into Noema's Shared Kernel. ``output_type`` (a - consumer's verdict/result schema), ``deps_type`` (a consumer's tool/deps - machinery), and ``system_prompt`` (persona plus domain instructions) also - remain per-consumer. This function centralizes only the repeated - ``Agent(...)`` construction call. + ``model`` must already be a constructed PydanticAI ``Model`` so provider + discovery, credentials, routing, and failover cannot migrate into Noema's + Shared Kernel through PydanticAI's string-model inference. ``output_type`` + (a consumer's verdict/result schema), ``deps_type`` (a consumer's tool/deps + machinery), and ``system_prompt`` (persona plus domain instructions) remain + per-consumer. This function centralizes only the repeated ``Agent(...)`` + construction call. """ + if not isinstance(model, Model): + raise TypeError("model must be a constructed PydanticAI Model") + kwargs: dict[str, Any] = {} if deps_type is not None: kwargs["deps_type"] = deps_type From 364e926d6e6c8465b73486939fdefb3b602b6ec1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:09:38 +0900 Subject: [PATCH 33/92] docs(core): require caller-resolved model adapters --- packages/noema-core/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/noema-core/README.md b/packages/noema-core/README.md index 4cc7112be..30565cd19 100644 --- a/packages/noema-core/README.md +++ b/packages/noema-core/README.md @@ -10,7 +10,9 @@ One function and one identity fragment shared without moving provider authority into Noema: - `build_agent(model, *, system_prompt, output_type=str, deps_type=None, retries=3) -> Agent` - constructs an agent around a caller-supplied PydanticAI model adapter. + constructs an agent around a caller-supplied, already constructed PydanticAI + `Model`. String model names are rejected so provider/model discovery cannot + occur inside the Shared Kernel. - `NOEMA_PERSONA` is the shared "You are Noema, an independent AI agent for ContextualWisdomLab." identity fragment consumers prepend to their own system prompt. From 939cb8f98a12ac615e47beab8addfe5cbd12b41f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:11:04 +0900 Subject: [PATCH 34/92] test(reviewer): block shared-core site-package leakage --- reviewer/tests/test_shared_core_import_boundary.py | 1 + 1 file changed, 1 insertion(+) diff --git a/reviewer/tests/test_shared_core_import_boundary.py b/reviewer/tests/test_shared_core_import_boundary.py index 3e5c30e07..d90defe53 100644 --- a/reviewer/tests/test_shared_core_import_boundary.py +++ b/reviewer/tests/test_shared_core_import_boundary.py @@ -23,6 +23,7 @@ def test_evidence_modules_import_without_shared_core_on_pythonpath() -> None: sys.executable, "-c", ( + "import sys; sys.modules['noema_core'] = None; " "from noema_reviewer.github_io import fetch_manifest; " "from noema_reviewer.sandbox import DockerCodeGraphRunner; " "assert fetch_manifest is not None; " From 2b9fe6cc5e99892a1c3c42e5c494dd7f4dc50a8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:11:39 +0900 Subject: [PATCH 35/92] test(packaging): require PEP 660 editable hooks --- reviewer/tests/test_build_backend_editable.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 reviewer/tests/test_build_backend_editable.py diff --git a/reviewer/tests/test_build_backend_editable.py b/reviewer/tests/test_build_backend_editable.py new file mode 100644 index 000000000..2a7028e26 --- /dev/null +++ b/reviewer/tests/test_build_backend_editable.py @@ -0,0 +1,16 @@ +"""Regression coverage for the reviewer packaging backend's editable-install contract.""" + +from __future__ import annotations + +import build_backend + + +def test_build_backend_exposes_pep660_editable_hooks() -> None: + """The custom backend must preserve setuptools' documented editable-install path.""" + + for hook_name in ( + "build_editable", + "prepare_metadata_for_build_editable", + "get_requires_for_build_editable", + ): + assert callable(getattr(build_backend, hook_name, None)), hook_name From 1826bb17c601a695a36a07d2af83401019390e6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:12:11 +0900 Subject: [PATCH 36/92] fix(packaging): preserve PEP 660 editable installs --- reviewer/build_backend.py | 44 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py index 8f30d09fa..86de22fa9 100644 --- a/reviewer/build_backend.py +++ b/reviewer/build_backend.py @@ -1,10 +1,12 @@ -"""PEP 517 wrapper that stages the canonical noema-core package for distribution builds. +"""PEP 517/660 wrapper that stages canonical noema-core for reviewer builds. The reviewer cannot declare an immutable external ``noema-core`` dependency until that package is published. Repository builds therefore stage the canonical monorepo package into a build-only directory before delegating to setuptools. The staged directory is included in source distributions so an extracted sdist -can build a wheel without access to the original monorepo checkout. +can build a wheel without access to the original monorepo checkout. Editable +installs use the same staging boundary so the documented development path does +not bypass package-source authority. """ from __future__ import annotations @@ -47,7 +49,7 @@ def _with_core_staging( *args: Any, **kwargs: Any, ) -> _BuildResult: - """Delegate a PEP 517 hook while cleaning repository-only staging afterward.""" + """Delegate a packaging hook while cleaning repository-only staging afterward.""" created = _prepare_core() try: @@ -72,6 +74,21 @@ def build_wheel( ) +def build_editable( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """Build an editable reviewer wheel through the canonical core staging boundary.""" + + return _with_core_staging( + _setuptools.build_editable, + wheel_directory, + config_settings, + metadata_directory, + ) + + def build_sdist( sdist_directory: str, config_settings: dict[str, Any] | None = None, @@ -94,6 +111,19 @@ def prepare_metadata_for_build_wheel( ) +def prepare_metadata_for_build_editable( + metadata_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Prepare editable metadata under the same canonical package boundary.""" + + return _with_core_staging( + _setuptools.prepare_metadata_for_build_editable, + metadata_directory, + config_settings, + ) + + def get_requires_for_build_wheel( config_settings: dict[str, Any] | None = None, ) -> list[str]: @@ -102,6 +132,14 @@ def get_requires_for_build_wheel( return _with_core_staging(_setuptools.get_requires_for_build_wheel, config_settings) +def get_requires_for_build_editable( + config_settings: dict[str, Any] | None = None, +) -> list[str]: + """Return editable-build requirements after validating package-source availability.""" + + return _with_core_staging(_setuptools.get_requires_for_build_editable, config_settings) + + def get_requires_for_build_sdist( config_settings: dict[str, Any] | None = None, ) -> list[str]: From 5da998da18e204041a9cade395c011c564120911 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:13:26 +0900 Subject: [PATCH 37/92] test(reviewer): preserve independent-reviewer identity --- reviewer/tests/test_agent.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/reviewer/tests/test_agent.py b/reviewer/tests/test_agent.py index db624d5d2..d131e3086 100644 --- a/reviewer/tests/test_agent.py +++ b/reviewer/tests/test_agent.py @@ -5,6 +5,7 @@ from pydantic_ai.models.test import TestModel from noema_reviewer.agent import ( + SYSTEM_PROMPT, PydanticAIReviewAgent, ReviewAgent, build_agent, @@ -45,6 +46,13 @@ def test_agent_satisfies_protocol() -> None: assert isinstance(_agent_returning(), ReviewAgent) +def test_reviewer_identity_preserves_the_protected_main_role() -> None: + """Shared identity reuse must not broaden the reviewer's prompt-sensitive role.""" + assert SYSTEM_PROMPT.startswith( + "You are Noema, an independent second reviewer for ContextualWisdomLab, " + ) + + def test_agent_returns_model_approval() -> None: """A model approval flows through unchanged when no gate fires.""" verdict = _agent_returning().review(_evidenced_manifest()) From cb59e9a1f44b8d7c03ea8a4baa491c3a2be92f4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:14:14 +0900 Subject: [PATCH 38/92] fix(reviewer): preserve role identity and resolved-model boundary --- reviewer/noema_reviewer/agent.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/reviewer/noema_reviewer/agent.py b/reviewer/noema_reviewer/agent.py index d2c9b3155..c9530815d 100644 --- a/reviewer/noema_reviewer/agent.py +++ b/reviewer/noema_reviewer/agent.py @@ -12,7 +12,6 @@ from typing import Protocol, runtime_checkable -from noema_core import NOEMA_PERSONA from noema_core import build_agent as build_core_agent from pydantic_ai import Agent from pydantic_ai.models import Model @@ -24,7 +23,7 @@ SYSTEM_PROMPT = ( - f"{NOEMA_PERSONA} You are the independent second reviewer, " + "You are Noema, an independent second reviewer for ContextualWisdomLab, " "separate from the OpenCode reviewer. You review a bounded manifest of a " "pull request: its diff, changed-file context, workflow logs, SARIF " "summary, dependency findings, prior review comments, and current check " @@ -103,8 +102,8 @@ def build_prompt(manifest: ReviewManifest) -> str: class PydanticAIReviewAgent: """A ``ReviewAgent`` backed by a PydanticAI ``Agent`` with a typed verdict.""" - def __init__(self, model: Model | str) -> None: - """Build the agent around an injected model (a real model or a test model).""" + def __init__(self, model: Model) -> None: + """Build the agent around an already resolved real or test model.""" self._agent: Agent[None, ReviewVerdict] = build_core_agent( model, output_type=ReviewVerdict, From 5b4201a7088f9897e74752c5295425e77a5f2ffb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:15:45 +0900 Subject: [PATCH 39/92] test(packaging): exercise clean editable reviewer install --- reviewer/tests/test_build_backend_editable.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/reviewer/tests/test_build_backend_editable.py b/reviewer/tests/test_build_backend_editable.py index 2a7028e26..ec4197713 100644 --- a/reviewer/tests/test_build_backend_editable.py +++ b/reviewer/tests/test_build_backend_editable.py @@ -2,6 +2,11 @@ from __future__ import annotations +import os +from pathlib import Path +import subprocess +import sys + import build_backend @@ -14,3 +19,47 @@ def test_build_backend_exposes_pep660_editable_hooks() -> None: "get_requires_for_build_editable", ): assert callable(getattr(build_backend, hook_name, None)), hook_name + + +def test_clean_editable_install_imports_reviewer_and_canonical_core(tmp_path: Path) -> None: + """An editable reviewer install must retain access to the canonical shared core.""" + + reviewer_root = Path(__file__).resolve().parents[1] + venv_dir = tmp_path / "editable-venv" + subprocess.run( + [sys.executable, "-m", "venv", "--system-site-packages", str(venv_dir)], + check=True, + ) + python = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + env = os.environ.copy() + env["PYTHONPATH"] = "" + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--no-deps", + "--no-build-isolation", + "-e", + str(reviewer_root), + ], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) + completed = subprocess.run( + [ + str(python), + "-c", + "import noema_core, noema_reviewer; assert noema_core.build_agent; assert noema_reviewer.build_agent", + ], + cwd=tmp_path, + env=env, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr From 4f3dd3349016fa790807450b7c9e82f1113a571c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:16:27 +0900 Subject: [PATCH 40/92] fix(packaging): keep editable core linked to canonical source --- reviewer/build_backend.py | 82 +++++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 21 deletions(-) diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py index 86de22fa9..5f97d1144 100644 --- a/reviewer/build_backend.py +++ b/reviewer/build_backend.py @@ -1,12 +1,12 @@ """PEP 517/660 wrapper that stages canonical noema-core for reviewer builds. The reviewer cannot declare an immutable external ``noema-core`` dependency until -that package is published. Repository builds therefore stage the canonical -monorepo package into a build-only directory before delegating to setuptools. -The staged directory is included in source distributions so an extracted sdist -can build a wheel without access to the original monorepo checkout. Editable -installs use the same staging boundary so the documented development path does -not bypass package-source authority. +that package is published. Distribution builds therefore stage one canonical +monorepo snapshot into a build-only directory before delegating to setuptools; +source distributions embed that snapshot so they remain self-contained. +Editable installs instead keep an ignored link to the canonical source when the +platform permits it, preserving editable semantics without making the generated +staging path a second source of truth. """ from __future__ import annotations @@ -24,19 +24,25 @@ _BuildResult = TypeVar("_BuildResult") +def _reset_staging_root() -> None: + """Remove generated package staging before publishing a new canonical view.""" + + if _STAGING_ROOT.exists() or _STAGING_ROOT.is_symlink(): + rmtree(_STAGING_ROOT) + _STAGING_ROOT.mkdir(parents=True) + + def _prepare_core() -> bool: - """Ensure packaging reads one exact snapshot of the canonical core source. + """Ensure distribution packaging reads one exact canonical source snapshot. - A monorepo checkout always recreates staging from the canonical source so a - stale local staging directory cannot become package authority. An extracted - source distribution has no sibling package checkout and therefore consumes - the staged snapshot embedded by the source-distribution build. + A monorepo checkout recreates staging from the canonical source so stale + generated files cannot become package authority. An extracted source + distribution has no sibling package checkout and consumes the staged + snapshot embedded by the source-distribution build. """ if _CANONICAL_CORE.is_dir(): - if _STAGING_ROOT.exists(): - rmtree(_STAGING_ROOT) - _STAGING_ROOT.mkdir(parents=True) + _reset_staging_root() copytree(_CANONICAL_CORE, _STAGED_CORE) return True if _STAGED_CORE.is_dir(): @@ -44,12 +50,35 @@ def _prepare_core() -> bool: raise RuntimeError("canonical noema-core source is unavailable for reviewer packaging") +def _prepare_editable_core() -> None: + """Expose canonical noema-core to an editable install without a stale copy. + + The editable finder generated by setuptools references ``_build_include``. + On platforms that support directory symlinks, that path points directly at + the canonical monorepo source and therefore follows edits. If the platform + refuses directory symlinks, a generated copy is used as a portability + fallback; rerunning the editable install refreshes it from canonical source. + Extracted sdists already contain their bounded staged snapshot. + """ + + if _CANONICAL_CORE.is_dir(): + _reset_staging_root() + try: + _STAGED_CORE.symlink_to(_CANONICAL_CORE, target_is_directory=True) + except OSError: + copytree(_CANONICAL_CORE, _STAGED_CORE) + return + if _STAGED_CORE.is_dir(): + return + raise RuntimeError("canonical noema-core source is unavailable for reviewer editable install") + + def _with_core_staging( builder: Callable[..., _BuildResult], *args: Any, **kwargs: Any, ) -> _BuildResult: - """Delegate a packaging hook while cleaning repository-only staging afterward.""" + """Delegate a distribution hook and clean repository-only staging afterward.""" created = _prepare_core() try: @@ -59,6 +88,17 @@ def _with_core_staging( rmtree(_STAGING_ROOT) +def _with_editable_core( + builder: Callable[..., _BuildResult], + *args: Any, + **kwargs: Any, +) -> _BuildResult: + """Delegate an editable hook while retaining its ignored canonical source view.""" + + _prepare_editable_core() + return builder(*args, **kwargs) + + def build_wheel( wheel_directory: str, config_settings: dict[str, Any] | None = None, @@ -79,9 +119,9 @@ def build_editable( config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None, ) -> str: - """Build an editable reviewer wheel through the canonical core staging boundary.""" + """Build an editable reviewer wheel against the canonical shared-core source.""" - return _with_core_staging( + return _with_editable_core( _setuptools.build_editable, wheel_directory, config_settings, @@ -115,9 +155,9 @@ def prepare_metadata_for_build_editable( metadata_directory: str, config_settings: dict[str, Any] | None = None, ) -> str: - """Prepare editable metadata under the same canonical package boundary.""" + """Prepare editable metadata against the canonical shared-core source view.""" - return _with_core_staging( + return _with_editable_core( _setuptools.prepare_metadata_for_build_editable, metadata_directory, config_settings, @@ -135,9 +175,9 @@ def get_requires_for_build_wheel( def get_requires_for_build_editable( config_settings: dict[str, Any] | None = None, ) -> list[str]: - """Return editable-build requirements after validating package-source availability.""" + """Return editable requirements after validating canonical package availability.""" - return _with_core_staging(_setuptools.get_requires_for_build_editable, config_settings) + return _with_editable_core(_setuptools.get_requires_for_build_editable, config_settings) def get_requires_for_build_sdist( From 7c72bbe9111a69466b568843e14f2c2088229bfc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:17:55 +0900 Subject: [PATCH 41/92] fix(ci): use explicit empty PYTHONPATH assignment --- .github/workflows/reviewer-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index 4b9c206d7..ed396ab44 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -105,7 +105,7 @@ jobs: "$venv_dir/bin/python" -m pip install --no-deps "$wheel" ( cd "$RUNNER_TEMP" - PYTHONPATH= "$venv_dir/bin/python" - <<'PY' + PYTHONPATH='' "$venv_dir/bin/python" - <<'PY' import hashlib import os from pathlib import Path From 5363c6e444b7d676a2ccf387e3776b1cfa5cb8a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:18:49 +0900 Subject: [PATCH 42/92] test(core): require composable role-neutral identity --- packages/noema-core/tests/test_agent.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/noema-core/tests/test_agent.py b/packages/noema-core/tests/test_agent.py index 0d4d05837..d4296b30e 100644 --- a/packages/noema-core/tests/test_agent.py +++ b/packages/noema-core/tests/test_agent.py @@ -42,7 +42,6 @@ def test_build_agent_rejects_unresolved_model_names() -> None: ) -def test_noema_persona_names_the_organization() -> None: - """The shared persona fragment names Noema and the organization it serves.""" - assert "Noema" in NOEMA_PERSONA - assert "ContextualWisdomLab" in NOEMA_PERSONA +def test_noema_persona_is_role_neutral_identity_prefix() -> None: + """Consumers append their bounded-context role without inheriting another role.""" + assert NOEMA_PERSONA == "You are Noema" From 14891ae7877cfd9164f5f8c92d39bb594910c2f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:20:38 +0900 Subject: [PATCH 43/92] fix(core): make shared Noema identity role-neutral --- packages/noema-core/src/noema_core/agent.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/noema-core/src/noema_core/agent.py b/packages/noema-core/src/noema_core/agent.py index b294226ad..fb4686028 100644 --- a/packages/noema-core/src/noema_core/agent.py +++ b/packages/noema-core/src/noema_core/agent.py @@ -20,13 +20,13 @@ from pydantic_ai.models import Model -NOEMA_PERSONA = "You are Noema, an independent AI agent for ContextualWisdomLab." -"""The shared identity fragment every consumer's system prompt should open with. +NOEMA_PERSONA = "You are Noema" +"""The role-neutral identity prefix shared by Noema's bounded-context agents. -Each consumer still writes and owns the rest of its own system prompt (this -repository's evidence-and-findings rules, naruon's tool-use guidance, and so -on). This constant is only the shared name/tone fragment — not a full -persona, and not a verdict or output schema. +Consumers append their own precise role, organization context, evidence rules, +tool authority and output contract. Keeping this fragment role-neutral avoids +silently broadening a specialized reviewer, runtime agent or application agent +when the shared identity is reused. """ @@ -44,7 +44,7 @@ def build_agent( discovery, credentials, routing, and failover cannot migrate into Noema's Shared Kernel through PydanticAI's string-model inference. ``output_type`` (a consumer's verdict/result schema), ``deps_type`` (a consumer's tool/deps - machinery), and ``system_prompt`` (persona plus domain instructions) remain + machinery), and ``system_prompt`` (identity plus domain instructions) remain per-consumer. This function centralizes only the repeated ``Agent(...)`` construction call. """ From 485739e390c4d70406d9469f1133855da65bbc88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:21:07 +0900 Subject: [PATCH 44/92] fix(reviewer): compose bounded role from shared identity --- reviewer/noema_reviewer/agent.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/reviewer/noema_reviewer/agent.py b/reviewer/noema_reviewer/agent.py index c9530815d..6b238f2fa 100644 --- a/reviewer/noema_reviewer/agent.py +++ b/reviewer/noema_reviewer/agent.py @@ -12,6 +12,7 @@ from typing import Protocol, runtime_checkable +from noema_core import NOEMA_PERSONA from noema_core import build_agent as build_core_agent from pydantic_ai import Agent from pydantic_ai.models import Model @@ -23,7 +24,7 @@ SYSTEM_PROMPT = ( - "You are Noema, an independent second reviewer for ContextualWisdomLab, " + f"{NOEMA_PERSONA}, an independent second reviewer for ContextualWisdomLab, " "separate from the OpenCode reviewer. You review a bounded manifest of a " "pull request: its diff, changed-file context, workflow logs, SARIF " "summary, dependency findings, prior review comments, and current check " From edd720cdae2f256e53ce77fb991484184e6498af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:21:29 +0900 Subject: [PATCH 45/92] docs(core): document role-neutral identity contract --- packages/noema-core/README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/noema-core/README.md b/packages/noema-core/README.md index 30565cd19..1c30c1fab 100644 --- a/packages/noema-core/README.md +++ b/packages/noema-core/README.md @@ -6,16 +6,17 @@ for the decision and its scope boundary. ## What this package is -One function and one identity fragment shared without moving provider authority -into Noema: +One function and one role-neutral identity fragment shared without moving +provider or bounded-context authority into Noema: - `build_agent(model, *, system_prompt, output_type=str, deps_type=None, retries=3) -> Agent` constructs an agent around a caller-supplied, already constructed PydanticAI `Model`. String model names are rejected so provider/model discovery cannot occur inside the Shared Kernel. -- `NOEMA_PERSONA` is the shared "You are Noema, an independent AI agent for - ContextualWisdomLab." identity fragment consumers prepend to their own - system prompt. +- `NOEMA_PERSONA` is exactly `"You are Noema"`. Consumers compose that stable + identity with their own precise role, organization context, evidence rules, + tool authority and output contract; the Shared Kernel does not assign a + generic role that could weaken a specialized reviewer or runtime agent. The injected model is deliberate. `noema-core` does not construct `AsyncOpenAI`, `OpenAIChatModel`, `OpenAIProvider`, provider credentials, model discovery, @@ -34,10 +35,12 @@ fallback, or tenant isolation. Those stay with their canonical owners. Self-consumption only: `reviewer/noema_reviewer` is the sole consumer today. `noema-core` is not yet published to an immutable package index, so external consumers must not pin a mutable branch or copy this source. During this -transition the `noema-reviewer` wheel includes `noema_core` directly from this -single canonical source path through setuptools package mapping. Required -`reviewer-ci` runs this package's 100% line/branch and docstring gates and then -smoke-installs the reviewer wheel outside the checkout. +transition the `noema-reviewer` distribution includes `noema_core` from this +single canonical source path through the custom packaging backend. Wheel and +sdist builds stage a bounded snapshot; editable installs keep an ignored +canonical-source view so their package mapping remains valid after the PEP 660 +hook completes. Required `reviewer-ci` runs this package's 100% line/branch and +docstring gates and validates installed distributions outside the checkout. Publishing `noema-core` through the repository's selected immutable package mechanism and moving consumers to a normal versioned dependency are tracked as From ab1ec256f4192c60aed9d3e42b57ef571c8d2f70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:21:22 +0900 Subject: [PATCH 46/92] test(packaging): reproduce staging races and editable clobbering --- reviewer/tests/test_build_backend_staging.py | 82 ++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 reviewer/tests/test_build_backend_staging.py diff --git a/reviewer/tests/test_build_backend_staging.py b/reviewer/tests/test_build_backend_staging.py new file mode 100644 index 000000000..785149812 --- /dev/null +++ b/reviewer/tests/test_build_backend_staging.py @@ -0,0 +1,82 @@ +"""Regression coverage for isolated reviewer build staging and editable source lifetime.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import threading + +import build_backend + + +def test_distribution_staging_is_private_per_build_invocation() -> None: + """Concurrent distribution preparations must never share a mutable staging tree.""" + + barrier = threading.Barrier(2) + + def observe_distribution_project() -> tuple[Path, Path]: + with build_backend._distribution_project() as project_root: + staged_core = project_root / "_build_include" / "noema_core" + assert staged_core.is_dir() + barrier.wait(timeout=10) + return project_root, staged_core + + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(observe_distribution_project) + second = pool.submit(observe_distribution_project) + first_project, first_core = first.result(timeout=20) + second_project, second_core = second.result(timeout=20) + + assert first_project != second_project + assert first_core != second_core + + +def test_distribution_build_does_not_destroy_editable_canonical_view( + tmp_path: Path, + monkeypatch, +) -> None: + """A distribution build must not remove the source view used by an editable install.""" + + if not build_backend._CANONICAL_CORE.is_dir(): + return + + build_backend._prepare_editable_core() + editable_view = build_backend._STAGED_CORE + assert editable_view.is_symlink() + assert editable_view.resolve() == build_backend._CANONICAL_CORE.resolve() + + observed_projects: list[Path] = [] + + def fake_build_wheel(wheel_directory: str, *_args, **_kwargs) -> str: + project_root = Path.cwd() + observed_projects.append(project_root) + assert project_root != build_backend._PROJECT_ROOT + assert (project_root / "_build_include" / "noema_core").is_dir() + assert Path(wheel_directory) == tmp_path.resolve() + return "noema_reviewer-0.1.0-py3-none-any.whl" + + monkeypatch.setattr(build_backend._setuptools, "build_wheel", fake_build_wheel) + try: + assert build_backend.build_wheel(str(tmp_path)) == "noema_reviewer-0.1.0-py3-none-any.whl" + assert observed_projects + assert editable_view.is_symlink() + assert editable_view.resolve() == build_backend._CANONICAL_CORE.resolve() + finally: + build_backend._remove_generated_path(build_backend._STAGING_ROOT) + + +def test_generated_path_cleanup_unlinks_files_and_symlinks(tmp_path: Path) -> None: + """Generated cleanup must unlink leaf capabilities instead of passing them to rmtree.""" + + regular_file = tmp_path / "regular-file" + regular_file.write_text("generated", encoding="utf-8") + build_backend._remove_generated_path(regular_file) + assert not regular_file.exists() + + target = tmp_path / "target" + target.mkdir() + alias = tmp_path / "alias" + alias.symlink_to(target, target_is_directory=True) + build_backend._remove_generated_path(alias) + assert not alias.exists() + assert target.is_dir() From a8a762fc20d8befba81c96891446225248af3a64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 21:22:11 +0900 Subject: [PATCH 47/92] test(packaging): require isolated editable installation contract --- reviewer/tests/test_build_backend_editable.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/reviewer/tests/test_build_backend_editable.py b/reviewer/tests/test_build_backend_editable.py index ec4197713..d6851727b 100644 --- a/reviewer/tests/test_build_backend_editable.py +++ b/reviewer/tests/test_build_backend_editable.py @@ -63,3 +63,23 @@ def test_clean_editable_install_imports_reviewer_and_canonical_core(tmp_path: Pa text=True, ) assert completed.returncode == 0, completed.stderr + + +def test_reviewer_ci_proves_an_isolated_editable_install_with_locked_dependencies() -> None: + """Required CI must validate editable packaging without inheriting host site-packages.""" + + reviewer_root = Path(__file__).resolve().parents[1] + workflow = (reviewer_root.parent / ".github" / "workflows" / "reviewer-ci.yml").read_text( + encoding="utf-8" + ) + + assert 'editable_venv="$RUNNER_TEMP/noema-reviewer-editable-smoke"' in workflow + assert 'python -m venv "$editable_venv"' in workflow + assert ( + '"$editable_venv/bin/python" -m pip install --require-hashes --no-deps ' + '-r requirements-ci-hashes.txt' + ) in workflow + assert ( + '"$editable_venv/bin/python" -m pip install --no-deps --no-build-isolation -e .' + ) in workflow + assert '--system-site-packages "$editable_venv"' not in workflow From 73917466c349f29c227b9e6847ccefeee7bb5e02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:16:22 +0900 Subject: [PATCH 48/92] fix(packaging): isolate reviewer build staging --- .github/workflows/reviewer-ci.yml | 18 ++ reviewer/build_backend.py | 190 ++++++++++++------ reviewer/tests/test_build_backend_editable.py | 28 ++- reviewer/tests/test_build_backend_staging.py | 21 ++ 4 files changed, 188 insertions(+), 69 deletions(-) diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index ed396ab44..544a79afb 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -125,6 +125,24 @@ jobs: ) done + - name: smoke-test isolated editable reviewer with locked runtime dependencies + run: | + set -euo pipefail + editable_venv="$RUNNER_TEMP/noema-reviewer-editable-smoke" + python -m venv "$editable_venv" + "$editable_venv/bin/python" -m pip install --require-hashes --no-deps -r requirements-ci-hashes.txt + "$editable_venv/bin/python" -m pip install --no-deps -e . + ( + cd "$RUNNER_TEMP" + PYTHONPATH='' "$editable_venv/bin/python" - <<'PY' + import noema_core + import noema_reviewer + + assert noema_core.build_agent is not None + assert noema_reviewer.build_agent is not None + PY + ) + - name: install lock-pinned CodeGraph tooling for sandbox smoke test env: NPM_CONFIG_IGNORE_SCRIPTS: "true" diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py index 5f97d1144..0896672f3 100644 --- a/reviewer/build_backend.py +++ b/reviewer/build_backend.py @@ -1,19 +1,21 @@ """PEP 517/660 wrapper that stages canonical noema-core for reviewer builds. The reviewer cannot declare an immutable external ``noema-core`` dependency until -that package is published. Distribution builds therefore stage one canonical -monorepo snapshot into a build-only directory before delegating to setuptools; -source distributions embed that snapshot so they remain self-contained. -Editable installs instead keep an ignored link to the canonical source when the -platform permits it, preserving editable semantics without making the generated -staging path a second source of truth. +that package is published. Distribution hooks therefore build from a private +per-invocation copy of the reviewer project containing one canonical noema-core +snapshot. Editable hooks keep one ignored symlink to canonical monorepo source, +so distribution cleanup cannot invalidate an existing editable installation. """ from __future__ import annotations +from contextlib import contextmanager +import os from pathlib import Path -from shutil import copytree, rmtree -from typing import Any, Callable, TypeVar +from shutil import copytree, ignore_patterns, rmtree +from tempfile import TemporaryDirectory +from threading import RLock +from typing import Any, Callable, Iterator, TypeVar from setuptools import build_meta as _setuptools @@ -21,82 +23,140 @@ _CANONICAL_CORE = _PROJECT_ROOT.parent / "packages" / "noema-core" / "src" / "noema_core" _STAGING_ROOT = _PROJECT_ROOT / "_build_include" _STAGED_CORE = _STAGING_ROOT / "noema_core" -_BuildResult = TypeVar("_BuildResult") +_BUILD_CWD_LOCK = RLock() +_EDITABLE_BUILD_LOCK = RLock() +_BUILD_RESULT = TypeVar("_BUILD_RESULT") + + +def _remove_generated_path(path: Path) -> None: + """Remove a generated file, symlink, or directory without following links.""" + + if path.is_symlink() or path.is_file(): + path.unlink(missing_ok=True) + elif path.exists(): + rmtree(path) def _reset_staging_root() -> None: - """Remove generated package staging before publishing a new canonical view.""" + """Recreate the editable package view without following stale path aliases.""" - if _STAGING_ROOT.exists() or _STAGING_ROOT.is_symlink(): - rmtree(_STAGING_ROOT) + _remove_generated_path(_STAGING_ROOT) _STAGING_ROOT.mkdir(parents=True) -def _prepare_core() -> bool: - """Ensure distribution packaging reads one exact canonical source snapshot. +def _prepare_editable_core() -> None: + """Expose canonical noema-core to editable installs through a live source link. - A monorepo checkout recreates staging from the canonical source so stale - generated files cannot become package authority. An extracted source - distribution has no sibling package checkout and consumes the staged - snapshot embedded by the source-distribution build. + Editable packaging must never fall back to a copied snapshot because such a + copy silently stops reflecting edits to the canonical Shared Kernel. A host + that cannot create the directory link fails explicitly instead. """ + if not _CANONICAL_CORE.is_dir(): + if _STAGED_CORE.is_dir(): + return + raise RuntimeError("canonical noema-core source is unavailable for reviewer editable install") + + if _STAGED_CORE.is_symlink(): + try: + if _STAGED_CORE.resolve(strict=True) == _CANONICAL_CORE.resolve(strict=True): + return + except OSError: + pass + + _reset_staging_root() + try: + _STAGED_CORE.symlink_to(_CANONICAL_CORE, target_is_directory=True) + except OSError as error: + _remove_generated_path(_STAGING_ROOT) + raise RuntimeError( + "reviewer editable install requires a live symlink to canonical noema-core source" + ) from error + + +def _distribution_source_core() -> Path: + """Return the canonical or embedded noema-core source used for a distribution.""" + if _CANONICAL_CORE.is_dir(): - _reset_staging_root() - copytree(_CANONICAL_CORE, _STAGED_CORE) - return True + return _CANONICAL_CORE if _STAGED_CORE.is_dir(): - return False + return _STAGED_CORE raise RuntimeError("canonical noema-core source is unavailable for reviewer packaging") -def _prepare_editable_core() -> None: - """Expose canonical noema-core to an editable install without a stale copy. - - The editable finder generated by setuptools references ``_build_include``. - On platforms that support directory symlinks, that path points directly at - the canonical monorepo source and therefore follows edits. If the platform - refuses directory symlinks, a generated copy is used as a portability - fallback; rerunning the editable install refreshes it from canonical source. - Extracted sdists already contain their bounded staged snapshot. +@contextmanager +def _distribution_project() -> Iterator[Path]: + """Yield a private reviewer project containing one exact shared-core snapshot. + + The caller gets a distinct filesystem tree for each invocation. This keeps + concurrent wheel, sdist, metadata, and requirement hooks from deleting or + overwriting one another's package staging. """ - if _CANONICAL_CORE.is_dir(): - _reset_staging_root() + source_core = _distribution_source_core() + with TemporaryDirectory(prefix="noema-reviewer-build-") as temporary_root: + project_root = Path(temporary_root) / "reviewer" + copytree( + _PROJECT_ROOT, + project_root, + ignore=ignore_patterns( + "_build_include", + "__pycache__", + ".pytest_cache", + "*.egg-info", + "build", + "dist", + ), + ) + staged_core = project_root / "_build_include" / "noema_core" + staged_core.parent.mkdir(parents=True, exist_ok=True) + copytree(source_core, staged_core, symlinks=False) + yield project_root + + +@contextmanager +def _working_directory(path: Path) -> Iterator[None]: + """Temporarily enter one private build project while serializing process cwd.""" + + with _BUILD_CWD_LOCK: + previous = Path.cwd() + os.chdir(path) try: - _STAGED_CORE.symlink_to(_CANONICAL_CORE, target_is_directory=True) - except OSError: - copytree(_CANONICAL_CORE, _STAGED_CORE) - return - if _STAGED_CORE.is_dir(): - return - raise RuntimeError("canonical noema-core source is unavailable for reviewer editable install") + yield + finally: + os.chdir(previous) def _with_core_staging( - builder: Callable[..., _BuildResult], + builder: Callable[..., _BUILD_RESULT], *args: Any, **kwargs: Any, -) -> _BuildResult: - """Delegate a distribution hook and clean repository-only staging afterward.""" +) -> _BUILD_RESULT: + """Run a distribution hook from a private per-invocation project snapshot.""" - created = _prepare_core() - try: - return builder(*args, **kwargs) - finally: - if created and _STAGING_ROOT.exists(): - rmtree(_STAGING_ROOT) + with _distribution_project() as project_root: + with _working_directory(project_root): + return builder(*args, **kwargs) def _with_editable_core( - builder: Callable[..., _BuildResult], + builder: Callable[..., _BUILD_RESULT], *args: Any, **kwargs: Any, -) -> _BuildResult: - """Delegate an editable hook while retaining its ignored canonical source view.""" +) -> _BUILD_RESULT: + """Run an editable hook while retaining its live canonical source view.""" - _prepare_editable_core() - return builder(*args, **kwargs) + with _EDITABLE_BUILD_LOCK: + _prepare_editable_core() + return builder(*args, **kwargs) + + +def _absolute_path(path: str | None) -> str | None: + """Preserve frontend output-directory identity across private-project chdir.""" + + if path is None: + return None + return str(Path(path).resolve()) def build_wheel( @@ -108,9 +168,9 @@ def build_wheel( return _with_core_staging( _setuptools.build_wheel, - wheel_directory, + _absolute_path(wheel_directory), config_settings, - metadata_directory, + _absolute_path(metadata_directory), ) @@ -123,9 +183,9 @@ def build_editable( return _with_editable_core( _setuptools.build_editable, - wheel_directory, + _absolute_path(wheel_directory), config_settings, - metadata_directory, + _absolute_path(metadata_directory), ) @@ -135,7 +195,11 @@ def build_sdist( ) -> str: """Build a self-contained source distribution from canonical monorepo source.""" - return _with_core_staging(_setuptools.build_sdist, sdist_directory, config_settings) + return _with_core_staging( + _setuptools.build_sdist, + _absolute_path(sdist_directory), + config_settings, + ) def prepare_metadata_for_build_wheel( @@ -146,7 +210,7 @@ def prepare_metadata_for_build_wheel( return _with_core_staging( _setuptools.prepare_metadata_for_build_wheel, - metadata_directory, + _absolute_path(metadata_directory), config_settings, ) @@ -159,7 +223,7 @@ def prepare_metadata_for_build_editable( return _with_editable_core( _setuptools.prepare_metadata_for_build_editable, - metadata_directory, + _absolute_path(metadata_directory), config_settings, ) @@ -167,7 +231,7 @@ def prepare_metadata_for_build_editable( def get_requires_for_build_wheel( config_settings: dict[str, Any] | None = None, ) -> list[str]: - """Return wheel-build requirements after validating package-source availability.""" + """Return wheel-build requirements from a private package-source snapshot.""" return _with_core_staging(_setuptools.get_requires_for_build_wheel, config_settings) @@ -183,6 +247,6 @@ def get_requires_for_build_editable( def get_requires_for_build_sdist( config_settings: dict[str, Any] | None = None, ) -> list[str]: - """Return sdist-build requirements after validating package-source availability.""" + """Return sdist-build requirements from a private package-source snapshot.""" return _with_core_staging(_setuptools.get_requires_for_build_sdist, config_settings) diff --git a/reviewer/tests/test_build_backend_editable.py b/reviewer/tests/test_build_backend_editable.py index d6851727b..4023a292c 100644 --- a/reviewer/tests/test_build_backend_editable.py +++ b/reviewer/tests/test_build_backend_editable.py @@ -22,17 +22,35 @@ def test_build_backend_exposes_pep660_editable_hooks() -> None: def test_clean_editable_install_imports_reviewer_and_canonical_core(tmp_path: Path) -> None: - """An editable reviewer install must retain access to the canonical shared core.""" + """An isolated editable install must resolve declared runtime dependencies and shared core.""" reviewer_root = Path(__file__).resolve().parents[1] + requirements = reviewer_root / "requirements-ci-hashes.txt" venv_dir = tmp_path / "editable-venv" subprocess.run( - [sys.executable, "-m", "venv", "--system-site-packages", str(venv_dir)], + [sys.executable, "-m", "venv", str(venv_dir)], check=True, ) python = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") env = os.environ.copy() env["PYTHONPATH"] = "" + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--require-hashes", + "--no-deps", + "-r", + str(requirements), + ], + cwd=tmp_path, + env=env, + check=True, + capture_output=True, + text=True, + ) subprocess.run( [ str(python), @@ -40,7 +58,6 @@ def test_clean_editable_install_imports_reviewer_and_canonical_core(tmp_path: Pa "pip", "install", "--no-deps", - "--no-build-isolation", "-e", str(reviewer_root), ], @@ -79,7 +96,6 @@ def test_reviewer_ci_proves_an_isolated_editable_install_with_locked_dependencie '"$editable_venv/bin/python" -m pip install --require-hashes --no-deps ' '-r requirements-ci-hashes.txt' ) in workflow - assert ( - '"$editable_venv/bin/python" -m pip install --no-deps --no-build-isolation -e .' - ) in workflow + assert '"$editable_venv/bin/python" -m pip install --no-deps -e .' in workflow assert '--system-site-packages "$editable_venv"' not in workflow + assert '--no-build-isolation -e .' not in workflow diff --git a/reviewer/tests/test_build_backend_staging.py b/reviewer/tests/test_build_backend_staging.py index 785149812..180625932 100644 --- a/reviewer/tests/test_build_backend_staging.py +++ b/reviewer/tests/test_build_backend_staging.py @@ -6,6 +6,8 @@ from pathlib import Path import threading +import pytest + import build_backend @@ -80,3 +82,22 @@ def test_generated_path_cleanup_unlinks_files_and_symlinks(tmp_path: Path) -> No build_backend._remove_generated_path(alias) assert not alias.exists() assert target.is_dir() + + +def test_editable_source_view_fails_closed_when_live_link_cannot_be_created( + monkeypatch, +) -> None: + """Editable packaging must not replace a failed live link with a stale copied snapshot.""" + + if not build_backend._CANONICAL_CORE.is_dir(): + return + + build_backend._remove_generated_path(build_backend._STAGING_ROOT) + + def deny_symlink(*_args, **_kwargs) -> None: + raise OSError("symlink unavailable") + + monkeypatch.setattr(Path, "symlink_to", deny_symlink) + with pytest.raises(RuntimeError, match="requires a live symlink"): + build_backend._prepare_editable_core() + assert not build_backend._STAGING_ROOT.exists() From 27e7adac0d138341a92395afdd77f36cb306bf7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:27:36 +0900 Subject: [PATCH 49/92] fix(packaging): reload staged setuptools context --- reviewer/build_backend.py | 93 +++++++++++++------- reviewer/tests/test_build_backend_staging.py | 43 +++++---- 2 files changed, 85 insertions(+), 51 deletions(-) diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py index 0896672f3..b762eba39 100644 --- a/reviewer/build_backend.py +++ b/reviewer/build_backend.py @@ -10,12 +10,14 @@ from __future__ import annotations from contextlib import contextmanager -import os +import json from pathlib import Path from shutil import copytree, ignore_patterns, rmtree +import subprocess +import sys from tempfile import TemporaryDirectory from threading import RLock -from typing import Any, Callable, Iterator, TypeVar +from typing import Any, Callable, Iterator, TypeVar, cast from setuptools import build_meta as _setuptools @@ -23,9 +25,24 @@ _CANONICAL_CORE = _PROJECT_ROOT.parent / "packages" / "noema-core" / "src" / "noema_core" _STAGING_ROOT = _PROJECT_ROOT / "_build_include" _STAGED_CORE = _STAGING_ROOT / "noema_core" -_BUILD_CWD_LOCK = RLock() _EDITABLE_BUILD_LOCK = RLock() _BUILD_RESULT = TypeVar("_BUILD_RESULT") +_STAGED_BACKEND_PROGRAM = """ +from __future__ import annotations + +import importlib +import json +from pathlib import Path +import sys + +hook_name, result_path, args_payload, kwargs_payload = sys.argv[1:] +backend = importlib.import_module("setuptools.build_meta") +result = getattr(backend, hook_name)( + *json.loads(args_payload), + **json.loads(kwargs_payload), +) +Path(result_path).write_text(json.dumps(result), encoding="utf-8") +""" def _remove_generated_path(path: Path) -> None: @@ -114,29 +131,39 @@ def _distribution_project() -> Iterator[Path]: yield project_root -@contextmanager -def _working_directory(path: Path) -> Iterator[None]: - """Temporarily enter one private build project while serializing process cwd.""" - - with _BUILD_CWD_LOCK: - previous = Path.cwd() - os.chdir(path) - try: - yield - finally: - os.chdir(previous) - - -def _with_core_staging( - builder: Callable[..., _BUILD_RESULT], +def _run_distribution_hook( + hook_name: str, *args: Any, **kwargs: Any, ) -> _BUILD_RESULT: - """Run a distribution hook from a private per-invocation project snapshot.""" + """Invoke setuptools in a fresh process whose project root is the staged copy. + + ``setuptools.build_meta`` is project-context-sensitive. Reusing the module + imported for the checkout after merely changing process cwd can retain the + wrong distribution identity and emit ``UNKNOWN-0.0.0`` artifacts. A child + interpreter imports the public backend only after entering the private + staged project, while also allowing independent build invocations to run + concurrently without shared cwd or module state. + """ with _distribution_project() as project_root: - with _working_directory(project_root): - return builder(*args, **kwargs) + result_path = project_root.parent / "backend-result.json" + subprocess.run( + [ + sys.executable, + "-c", + _STAGED_BACKEND_PROGRAM, + hook_name, + str(result_path), + json.dumps(args), + json.dumps(kwargs), + ], + cwd=project_root, + check=True, + ) + if not result_path.is_file(): + raise RuntimeError(f"staged setuptools hook {hook_name!r} produced no result") + return cast(_BUILD_RESULT, json.loads(result_path.read_text(encoding="utf-8"))) def _with_editable_core( @@ -152,7 +179,7 @@ def _with_editable_core( def _absolute_path(path: str | None) -> str | None: - """Preserve frontend output-directory identity across private-project chdir.""" + """Preserve frontend output-directory identity across private-project builds.""" if path is None: return None @@ -166,8 +193,8 @@ def build_wheel( ) -> str: """Build a reviewer wheel containing the staged canonical noema-core snapshot.""" - return _with_core_staging( - _setuptools.build_wheel, + return _run_distribution_hook( + "build_wheel", _absolute_path(wheel_directory), config_settings, _absolute_path(metadata_directory), @@ -195,8 +222,8 @@ def build_sdist( ) -> str: """Build a self-contained source distribution from canonical monorepo source.""" - return _with_core_staging( - _setuptools.build_sdist, + return _run_distribution_hook( + "build_sdist", _absolute_path(sdist_directory), config_settings, ) @@ -206,10 +233,10 @@ def prepare_metadata_for_build_wheel( metadata_directory: str, config_settings: dict[str, Any] | None = None, ) -> str: - """Prepare wheel metadata under the same package-discovery boundary as builds.""" + """Prepare wheel metadata in a backend imported from the staged project root.""" - return _with_core_staging( - _setuptools.prepare_metadata_for_build_wheel, + return _run_distribution_hook( + "prepare_metadata_for_build_wheel", _absolute_path(metadata_directory), config_settings, ) @@ -231,9 +258,9 @@ def prepare_metadata_for_build_editable( def get_requires_for_build_wheel( config_settings: dict[str, Any] | None = None, ) -> list[str]: - """Return wheel-build requirements from a private package-source snapshot.""" + """Return wheel-build requirements from a staged-project backend context.""" - return _with_core_staging(_setuptools.get_requires_for_build_wheel, config_settings) + return _run_distribution_hook("get_requires_for_build_wheel", config_settings) def get_requires_for_build_editable( @@ -247,6 +274,6 @@ def get_requires_for_build_editable( def get_requires_for_build_sdist( config_settings: dict[str, Any] | None = None, ) -> list[str]: - """Return sdist-build requirements from a private package-source snapshot.""" + """Return sdist-build requirements from a staged-project backend context.""" - return _with_core_staging(_setuptools.get_requires_for_build_sdist, config_settings) + return _run_distribution_hook("get_requires_for_build_sdist", config_settings) diff --git a/reviewer/tests/test_build_backend_staging.py b/reviewer/tests/test_build_backend_staging.py index 180625932..59535765e 100644 --- a/reviewer/tests/test_build_backend_staging.py +++ b/reviewer/tests/test_build_backend_staging.py @@ -33,11 +33,26 @@ def observe_distribution_project() -> tuple[Path, Path]: assert first_core != second_core -def test_distribution_build_does_not_destroy_editable_canonical_view( - tmp_path: Path, - monkeypatch, -) -> None: - """A distribution build must not remove the source view used by an editable install.""" +def test_concurrent_distribution_metadata_keeps_reviewer_project_identity(tmp_path: Path) -> None: + """Fresh backend contexts must emit reviewer metadata, never UNKNOWN artifacts.""" + + def prepare_metadata(index: int) -> tuple[str, bool]: + metadata_root = tmp_path / f"metadata-{index}" + metadata_root.mkdir() + distribution_name = build_backend.prepare_metadata_for_build_wheel(str(metadata_root)) + return distribution_name, (metadata_root / distribution_name).is_dir() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(prepare_metadata, (1, 2))) + + for distribution_name, exists in results: + assert distribution_name.startswith("noema_reviewer-") + assert distribution_name.endswith(".dist-info") + assert exists + + +def test_distribution_build_does_not_destroy_editable_canonical_view(tmp_path: Path) -> None: + """A real distribution build must not remove the source view used by an editable install.""" if not build_backend._CANONICAL_CORE.is_dir(): return @@ -47,20 +62,12 @@ def test_distribution_build_does_not_destroy_editable_canonical_view( assert editable_view.is_symlink() assert editable_view.resolve() == build_backend._CANONICAL_CORE.resolve() - observed_projects: list[Path] = [] - - def fake_build_wheel(wheel_directory: str, *_args, **_kwargs) -> str: - project_root = Path.cwd() - observed_projects.append(project_root) - assert project_root != build_backend._PROJECT_ROOT - assert (project_root / "_build_include" / "noema_core").is_dir() - assert Path(wheel_directory) == tmp_path.resolve() - return "noema_reviewer-0.1.0-py3-none-any.whl" - - monkeypatch.setattr(build_backend._setuptools, "build_wheel", fake_build_wheel) + wheel_root = tmp_path / "wheel" + wheel_root.mkdir() try: - assert build_backend.build_wheel(str(tmp_path)) == "noema_reviewer-0.1.0-py3-none-any.whl" - assert observed_projects + wheel_name = build_backend.build_wheel(str(wheel_root)) + assert wheel_name.startswith("noema_reviewer-") + assert (wheel_root / wheel_name).is_file() assert editable_view.is_symlink() assert editable_view.resolve() == build_backend._CANONICAL_CORE.resolve() finally: From 01f32647864cb73e6933fc5a7005c02f9bb23cba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:46:33 +0900 Subject: [PATCH 50/92] test(packaging): expose isolated backend path loss --- reviewer/tests/test_build_backend_staging.py | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/reviewer/tests/test_build_backend_staging.py b/reviewer/tests/test_build_backend_staging.py index 59535765e..5a1bd22e9 100644 --- a/reviewer/tests/test_build_backend_staging.py +++ b/reviewer/tests/test_build_backend_staging.py @@ -3,6 +3,8 @@ from __future__ import annotations from concurrent.futures import ThreadPoolExecutor +import json +import os from pathlib import Path import threading @@ -51,6 +53,44 @@ def prepare_metadata(index: int) -> tuple[str, bool]: assert exists +def test_distribution_hook_preserves_frontend_backend_environment( + tmp_path: Path, + monkeypatch, +) -> None: + """A staged child must retain the PEP 517 frontend's isolated backend search path.""" + + isolated_backend_path = str(tmp_path / "pep517-overlay-site-packages") + monkeypatch.setattr( + build_backend.sys, + "path", + [isolated_backend_path, *build_backend.sys.path], + ) + observed: dict[str, object] = {} + + def fake_run(command, *, cwd, check, env) -> None: + observed["cwd"] = cwd + observed["check"] = check + observed["env"] = env + Path(command[4]).write_text( + json.dumps("noema_reviewer-0.1.0.dist-info"), + encoding="utf-8", + ) + + monkeypatch.setattr(build_backend.subprocess, "run", fake_run) + metadata_root = tmp_path / "metadata" + metadata_root.mkdir() + + result = build_backend.prepare_metadata_for_build_wheel(str(metadata_root)) + + assert result == "noema_reviewer-0.1.0.dist-info" + assert observed["check"] is True + child_env = observed["env"] + assert isinstance(child_env, dict) + child_pythonpath = child_env["PYTHONPATH"].split(os.pathsep) + assert child_pythonpath[0] == str(observed["cwd"]) + assert isolated_backend_path in child_pythonpath + + def test_distribution_build_does_not_destroy_editable_canonical_view(tmp_path: Path) -> None: """A real distribution build must not remove the source view used by an editable install.""" From 1180465141866205cff9ef699a1865f69a9bfa98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:47:39 +0900 Subject: [PATCH 51/92] test(packaging): parse editable smoke command tokens --- reviewer/tests/test_build_backend_editable.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/reviewer/tests/test_build_backend_editable.py b/reviewer/tests/test_build_backend_editable.py index 4023a292c..ab59fb559 100644 --- a/reviewer/tests/test_build_backend_editable.py +++ b/reviewer/tests/test_build_backend_editable.py @@ -4,6 +4,7 @@ import os from pathlib import Path +import shlex import subprocess import sys @@ -96,6 +97,15 @@ def test_reviewer_ci_proves_an_isolated_editable_install_with_locked_dependencie '"$editable_venv/bin/python" -m pip install --require-hashes --no-deps ' '-r requirements-ci-hashes.txt' ) in workflow - assert '"$editable_venv/bin/python" -m pip install --no-deps -e .' in workflow - assert '--system-site-packages "$editable_venv"' not in workflow - assert '--no-build-isolation -e .' not in workflow + + editable_install_commands = [ + line.strip() + for line in workflow.splitlines() + if "pip install" in line and "-e ." in line + ] + assert len(editable_install_commands) == 1 + editable_tokens = shlex.split(editable_install_commands[0]) + assert "-e" in editable_tokens + assert editable_tokens[editable_tokens.index("-e") + 1] == "." + assert "--system-site-packages" not in editable_tokens + assert "--no-build-isolation" not in editable_tokens From 9fa70a39f5a3de822df06afd14c5284ab1017e39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:49:45 +0900 Subject: [PATCH 52/92] fix(packaging): preserve isolated backend environment --- reviewer/build_backend.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py index b762eba39..91d85dcca 100644 --- a/reviewer/build_backend.py +++ b/reviewer/build_backend.py @@ -11,6 +11,7 @@ from contextlib import contextmanager import json +import os from pathlib import Path from shutil import copytree, ignore_patterns, rmtree import subprocess @@ -131,6 +132,26 @@ def _distribution_project() -> Iterator[Path]: yield project_root +def _distribution_child_environment(project_root: Path) -> dict[str, str]: + """Preserve the frontend-provided isolated backend paths for the staged child. + + PEP 517 frontends can expose build requirements through interpreter search + paths rather than a dedicated virtualenv executable. Launching a nested + ``sys.executable`` without those paths can silently import an unrelated host + setuptools and produce ``UNKNOWN-0.0.0`` artifacts. The staged project stays + first, while the current backend process's search paths carry the frontend's + already-admitted build dependencies into the fresh interpreter. + """ + + child_environment = os.environ.copy() + search_paths = [str(project_root)] + for search_path in sys.path: + if search_path and search_path not in search_paths: + search_paths.append(search_path) + child_environment["PYTHONPATH"] = os.pathsep.join(search_paths) + return child_environment + + def _run_distribution_hook( hook_name: str, *args: Any, @@ -142,8 +163,10 @@ def _run_distribution_hook( imported for the checkout after merely changing process cwd can retain the wrong distribution identity and emit ``UNKNOWN-0.0.0`` artifacts. A child interpreter imports the public backend only after entering the private - staged project, while also allowing independent build invocations to run - concurrently without shared cwd or module state. + staged project. Its environment explicitly preserves the parent PEP 517 + backend search paths so the child cannot fall back to an unrelated host + setuptools, while independent build invocations retain separate cwd and + module state. """ with _distribution_project() as project_root: @@ -159,6 +182,7 @@ def _run_distribution_hook( json.dumps(kwargs), ], cwd=project_root, + env=_distribution_child_environment(project_root), check=True, ) if not result_path.is_file(): From a0c8744b8a55952ac1e6b788195bed49594cd5aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 10:45:27 +0900 Subject: [PATCH 53/92] docs(reviewer): align shared-core provider boundary Describe noema-core as caller-supplied PydanticAI Agent construction only; keep provider discovery, endpoint selection, credentials and failover with contextual-orchestrator and reviewer policy with the reviewer bounded context. --- reviewer/README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/reviewer/README.md b/reviewer/README.md index 5e0b85496..0bdfc29e2 100644 --- a/reviewer/README.md +++ b/reviewer/README.md @@ -14,15 +14,14 @@ Division of responsibility: - **`noema_reviewer`** (this package) — the **judgement** plane. It turns a bounded pull-request manifest into a validated `ReviewVerdict` and can publish it as an independent GitHub review. -- **[`../packages/noema-core`](../packages/noema-core)** — the shared PydanticAI - `Agent`-construction wiring (`AsyncOpenAI` → `OpenAIChatModel` → - `OpenAIProvider` → `Agent(...)`) plus a shared `NOEMA_PERSONA` fragment, - factored out once a second genuine duplicate of it existed (naruon's - `noema_agent.py`). See +- **[`../packages/noema-core`](../packages/noema-core)** — only the shared, + role-neutral PydanticAI `Agent(...)` construction around an already-resolved + caller-owned `Model`, plus a shared `NOEMA_PERSONA` fragment. See [`docs/adr/0014-shared-noema-core-package.md`](../docs/adr/0014-shared-noema-core-package.md) - for scope. `noema_reviewer` is its only consumer today; it does not own - verdict schema, gating, tool/deps machinery, or credential resolution - policy, all of which stay here. + for scope. `noema_reviewer` is its only consumer today. Provider/model + discovery, endpoint selection, credentials and failover remain outside the + Shared Kernel; reviewer verdict schema, gating and evidence policy remain + here. ## Contract From 6429c8852fd266eeca81d4f8e4f8668ea2d9cc78 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 11:19:57 +0900 Subject: [PATCH 54/92] test(core): forbid shared-kernel retry authority --- packages/noema-core/tests/test_agent.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/noema-core/tests/test_agent.py b/packages/noema-core/tests/test_agent.py index d4296b30e..7d8d715de 100644 --- a/packages/noema-core/tests/test_agent.py +++ b/packages/noema-core/tests/test_agent.py @@ -2,6 +2,8 @@ from __future__ import annotations +import inspect + import pytest from pydantic_ai import Agent from pydantic_ai.models.test import TestModel @@ -15,13 +17,17 @@ def test_build_agent_applies_output_type_and_system_prompt() -> None: TestModel(), system_prompt=NOEMA_PERSONA, output_type=str, - retries=2, ) assert isinstance(agent, Agent) result = agent.run_sync("hello") assert isinstance(result.output, str) +def test_build_agent_does_not_expose_retry_policy() -> None: + """Provider/model retry authority cannot leak into the reusable Shared Kernel.""" + assert "retries" not in inspect.signature(build_agent).parameters + + def test_build_agent_forwards_deps_type_only_when_given() -> None: """A caller that needs deps machinery can pass deps_type; others get none.""" agent = build_agent( From 482cb1a34baf50af4f8756729919793a924031ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 11:20:25 +0900 Subject: [PATCH 55/92] fix(core): remove shared-kernel retry authority --- packages/noema-core/src/noema_core/agent.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/noema-core/src/noema_core/agent.py b/packages/noema-core/src/noema_core/agent.py index d2bd39a4c..66bea74d0 100644 --- a/packages/noema-core/src/noema_core/agent.py +++ b/packages/noema-core/src/noema_core/agent.py @@ -36,17 +36,16 @@ def build_agent( system_prompt: str, output_type: Any = str, deps_type: Any = None, - retries: int = 3, ) -> Agent[Any, Any]: """Construct a PydanticAI ``Agent`` around a caller-owned model adapter. ``model`` must already be a constructed PydanticAI ``Model`` so provider - discovery, credentials, routing, and failover cannot migrate into Noema's - Shared Kernel through PydanticAI's string-model inference. ``output_type`` - (a consumer's verdict/result schema), ``deps_type`` (a consumer's tool/deps - machinery), and ``system_prompt`` (identity plus domain instructions) remain - per-consumer. This function centralizes only the repeated ``Agent(...)`` - construction call. + discovery, credentials, routing, failover, and retry policy cannot migrate + into Noema's Shared Kernel through PydanticAI convenience configuration. + ``output_type`` (a consumer's verdict/result schema), ``deps_type`` (a + consumer's tool/deps machinery), and ``system_prompt`` (identity plus domain + instructions) remain per-consumer. Model-attempt retry is disabled here; + contextual-orchestrator owns provider/model retry and failover semantics. """ if not isinstance(model, Model): raise TypeError("model must be a constructed PydanticAI Model") @@ -58,6 +57,6 @@ def build_agent( model, output_type=output_type, system_prompt=system_prompt, - retries=retries, + retries=0, **kwargs, ) From fe6e91f6e2ada705e503682b5c034696684b9070 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 11:20:55 +0900 Subject: [PATCH 56/92] fix(reviewer): keep retry authority outside noema-core --- reviewer/noema_reviewer/agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reviewer/noema_reviewer/agent.py b/reviewer/noema_reviewer/agent.py index 6b238f2fa..db52a5c11 100644 --- a/reviewer/noema_reviewer/agent.py +++ b/reviewer/noema_reviewer/agent.py @@ -109,7 +109,6 @@ def __init__(self, model: Model) -> None: model, output_type=ReviewVerdict, system_prompt=SYSTEM_PROMPT, - retries=3, ) def review(self, manifest: ReviewManifest, *, strict: bool = False) -> ReviewVerdict: @@ -125,7 +124,8 @@ def build_agent(config: ReviewerConfig | None = None) -> PydanticAIReviewAgent: Configuration (model name, orchestrator base URL, API key) is resolved through :func:`resolve_model`, which follows the org KV-first rule and fails loudly when the model provider or credential is unavailable — the - reviewer never degrades to a silent approval. + reviewer never degrades to a silent approval. Provider/model retries and + failover stay with contextual-orchestrator rather than this reviewer. """ model = resolve_model(config) return PydanticAIReviewAgent(model) From 7f92ceebada07c600a90d629024c98af81e9d41b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 11:21:36 +0900 Subject: [PATCH 57/92] docs(core): keep retry policy outside shared kernel --- docs/adr/0014-shared-noema-core-package.md | 24 +++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/adr/0014-shared-noema-core-package.md b/docs/adr/0014-shared-noema-core-package.md index 5725c7220..4b5e01ead 100644 --- a/docs/adr/0014-shared-noema-core-package.md +++ b/docs/adr/0014-shared-noema-core-package.md @@ -6,13 +6,13 @@ ## Problem -Noema has multiple bounded-context consumers that need the same PydanticAI `Agent(...)` construction semantics, but those consumers do not share domain authority. Repeating the framework construction call in each consumer creates drift; centralizing model discovery, provider SDKs, credentials, fallback, verdict schemas, tools, tenant state, or security policy would instead violate the repository's DDD boundary and duplicate canonical owners. +Noema has multiple bounded-context consumers that need the same PydanticAI `Agent(...)` construction semantics, but those consumers do not share domain authority. Repeating the framework construction call in each consumer creates drift; centralizing model discovery, provider SDKs, credentials, fallback, retry policy, verdict schemas, tools, tenant state, or security policy would instead violate the repository's DDD boundary and duplicate canonical owners. The previous branch-local ADR used number `0012`, which now belongs on protected `main` to the runtime bounded-context decision. ADR identity is immutable repository architecture authority, so this decision is renumbered to `0014` rather than retaining two different ADR-0012 documents. ## Constraints -- `contextual-orchestrator` owns provider/model discovery, routing, test-time compute, failover, provider credentials and provider-specific transport policy. +- `contextual-orchestrator` owns provider/model discovery, routing, test-time compute, provider/model retry and failover, provider credentials and provider-specific transport policy. - Noema owns Agent Runtime and its bounded contexts, not foreign product truth. - Reviewer verdict schema, deterministic gates, GitHub evidence policy and reviewer publication remain reviewer-owned. - Tenant/application tool authority and domain state stay in their owning product. @@ -26,9 +26,9 @@ The previous branch-local ADR used number `0012`, which now belongs on protected Rejected. It preserves local autonomy but guarantees repeated framework wiring and version drift without adding a useful bounded-context distinction. -### B. Put provider discovery and transport in `noema-core` +### B. Put provider discovery, retry or transport in `noema-core` -Rejected. That would recreate `contextual-orchestrator` inside Noema and would let a Shared Kernel become an ambient provider-authority boundary. +Rejected. That would recreate `contextual-orchestrator` policy inside Noema and would let a Shared Kernel become an ambient provider/model-attempt authority boundary. ### C. Build an always-on Noema service for every consumer @@ -36,20 +36,21 @@ Rejected for this phase. A service would add deployment, network, authorization ### D. Minimal package with caller-supplied model -Chosen. `packages/noema-core` owns only a role-neutral Noema persona fragment and a factory that accepts an already-constructed PydanticAI `Model` and calls `Agent(...)` with caller-owned prompt, output and deps types. +Chosen. `packages/noema-core` owns only a role-neutral Noema persona fragment and a factory that accepts an already-constructed PydanticAI `Model` and calls `Agent(...)` with caller-owned prompt, output and deps types. The factory fixes PydanticAI model-attempt retries to zero instead of exposing a reusable retry knob; orchestration-level retry/failover remains with `contextual-orchestrator`. ## Decision Create `packages/noema-core` as a minimal Shared Kernel with: - `NOEMA_PERSONA = "You are Noema"` as a role-neutral identity prefix; -- `build_agent(model, *, system_prompt, output_type=str, deps_type=None, retries=3)`; -- rejection of string model identifiers so PydanticAI's implicit provider/model inference cannot move discovery into the Shared Kernel. +- `build_agent(model, *, system_prompt, output_type=str, deps_type=None)`; +- rejection of string model identifiers so PydanticAI's implicit provider/model inference cannot move discovery into the Shared Kernel; +- no caller-visible `retries` parameter and `Agent(..., retries=0)` at this boundary so the Shared Kernel cannot silently create additional model attempts outside the orchestrator contract. `noema-core` deliberately does **not** own: - provider SDK construction or endpoint selection; -- credentials, key discovery, model groups or fallback; +- credentials, key discovery, model groups, retries or fallback; - reviewer verdicts, gates or merge authority; - tool/dependency authorization; - tenant isolation, domain persistence or foreign truth; @@ -66,8 +67,9 @@ Before this decision can become `Accepted`, the exact candidate head must prove: 3. Installed reviewer wheel and sdist-to-wheel smoke tests import both `noema_reviewer` and `noema_core` outside the checkout and prove the installed shared `agent.py` bytes match the canonical source. 4. Evidence-only reviewer imports remain lazy and do not require model construction. 5. String model identifiers fail closed at the Shared Kernel boundary. -6. Central review execution receives the canonical package path without moving provider routing authority into Noema. -7. No cross-repository consumer adopts `noema-core` until immutable publication exists. +6. `build_agent` exposes no retry-policy argument and constructs the PydanticAI agent with model-attempt retries disabled; provider/model retry and failover remain contextual-orchestrator authority. +7. Central review execution receives the canonical package path without moving provider routing authority into Noema. +8. No cross-repository consumer adopts `noema-core` until immutable publication exists. ## Publication boundary @@ -86,6 +88,8 @@ After such a release exists, consumers must pin the released version through the The shared surface stays intentionally small, so framework construction drift is removed without turning Noema into an LLM gateway or a domain super-service. The cost is a transitional reviewer build backend until `noema-core` has its own immutable package publication. That transitional backend must remain bounded, deterministic and covered by installed-artifact tests. +Removing the retry argument is intentionally restrictive. A consumer that needs a different attempt policy must not add a local convenience knob to the Shared Kernel; it must use the released contextual-orchestrator contract or make a separately reviewed bounded-context decision that does not duplicate provider/model retry authority. + A future need for cross-language access is a separate architecture decision. It should begin from a real consumer and released contract rather than expanding this package pre-emptively. ## Follow-up From 6292cc43874f1b90b1be466f09261cc530124b5d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:05:33 +0900 Subject: [PATCH 58/92] fix(ci): skip docs-only changes for ci, reviewer-ci, patch-validator-image Org-wide CI audit found these workflows lack paths-ignore, triggering full CI on every docs-only push/PR and contributing to org Actions queue backlog. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/patch-validator-image.yml | 3 +++ .github/workflows/reviewer-ci.yml | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d83efcc04..4e05addb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,15 @@ name: ci on: pull_request: + paths-ignore: + - "docs/**" + - "*.md" push: branches: - main + paths-ignore: + - "docs/**" + - "*.md" concurrency: group: noema-ci-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/patch-validator-image.yml b/.github/workflows/patch-validator-image.yml index 89ed4139b..bc270452e 100644 --- a/.github/workflows/patch-validator-image.yml +++ b/.github/workflows/patch-validator-image.yml @@ -2,6 +2,9 @@ name: patch-validator-image on: pull_request: + paths-ignore: + - "docs/**" + - "*.md" workflow_dispatch: concurrency: diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index f5212251a..13aa6b169 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -2,9 +2,15 @@ name: reviewer-ci on: pull_request: + paths-ignore: + - "docs/**" + - "*.md" push: branches: - main + paths-ignore: + - "docs/**" + - "*.md" concurrency: group: noema-reviewer-ci-${{ github.event.pull_request.number || github.ref }} From fa4bb2bfc714d88a189a0bc37840c488c604ce7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:31:10 +0900 Subject: [PATCH 59/92] test(ci): reject docs-only verification suppression --- test/ci-exact-head-contract.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/ci-exact-head-contract.test.ts b/test/ci-exact-head-contract.test.ts index 7112b158d..82a00e724 100644 --- a/test/ci-exact-head-contract.test.ts +++ b/test/ci-exact-head-contract.test.ts @@ -6,8 +6,13 @@ const workflowPaths = [ ".github/workflows/reviewer-ci.yml", ] as const; +const requiredVerificationWorkflowPaths = [ + ...workflowPaths, + ".github/workflows/patch-validator-image.yml", +] as const; + /** Read one authoritative pull-request verification workflow as plain text. */ -function readWorkflow(path: (typeof workflowPaths)[number]): string { +function readWorkflow(path: string): string { return readFileSync(path, "utf8"); } @@ -107,4 +112,11 @@ describe("pull-request verification exact-head checkout contract", () => { "- name: install (hash-pinned dependencies)", ); }); + + it("does not suppress required exact-head evidence for documentation-only changes", () => { + for (const path of requiredVerificationWorkflowPaths) { + const workflow = readWorkflow(path); + expect(workflow).not.toContain("paths-ignore:"); + } + }); }); From def52aadc50cd3feb779982070173209410dc1b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:31:55 +0900 Subject: [PATCH 60/92] fix(ci): keep application verification mandatory for docs-only changes --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e05addb1..d83efcc04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,15 +2,9 @@ name: ci on: pull_request: - paths-ignore: - - "docs/**" - - "*.md" push: branches: - main - paths-ignore: - - "docs/**" - - "*.md" concurrency: group: noema-ci-${{ github.event.pull_request.number || github.ref }} From 6bc6e590c4f0ba4d951fb157ab1a6270085e967f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:32:20 +0900 Subject: [PATCH 61/92] fix(ci): keep reviewer verification mandatory for docs-only changes --- .github/workflows/reviewer-ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index 13aa6b169..f5212251a 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -2,15 +2,9 @@ name: reviewer-ci on: pull_request: - paths-ignore: - - "docs/**" - - "*.md" push: branches: - main - paths-ignore: - - "docs/**" - - "*.md" concurrency: group: noema-reviewer-ci-${{ github.event.pull_request.number || github.ref }} From 60b83339cd53c639c28d4f1881d1ef4501e38534 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:33:56 +0900 Subject: [PATCH 62/92] fix(ci): keep image verification mandatory for docs-only changes --- .github/workflows/patch-validator-image.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/patch-validator-image.yml b/.github/workflows/patch-validator-image.yml index bc270452e..89ed4139b 100644 --- a/.github/workflows/patch-validator-image.yml +++ b/.github/workflows/patch-validator-image.yml @@ -2,9 +2,6 @@ name: patch-validator-image on: pull_request: - paths-ignore: - - "docs/**" - - "*.md" workflow_dispatch: concurrency: From d6c8c5ef138a0d6039bb47a21a31367611ac81c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 12:46:55 +0900 Subject: [PATCH 63/92] fix(reviewer): explain intentional editable-link restaging --- reviewer/build_backend.py | 1 + 1 file changed, 1 insertion(+) diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py index 91d85dcca..766ae7a00 100644 --- a/reviewer/build_backend.py +++ b/reviewer/build_backend.py @@ -80,6 +80,7 @@ def _prepare_editable_core() -> None: if _STAGED_CORE.resolve(strict=True) == _CANONICAL_CORE.resolve(strict=True): return except OSError: + # A broken or inaccessible prior link is not authoritative; restage it below. pass _reset_staging_root() From 02005d0e63c6e8598181d4258f283e81017e6f9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 13:05:18 +0900 Subject: [PATCH 64/92] docs(changelog): record noema-core shared kernel --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27019e507..4b71d8f3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- `noema-core` provider-neutral Shared Kernel을 추가하여 이미 해석된 PydanticAI `Model`과 역할별 prompt/schema만 받아 Agent를 구성한다. 문자열 model identifier와 provider discovery·credential·routing·retry·failover는 Shared Kernel 밖에 두고 `Agent(..., retries=0)`으로 repository-local model-attempt authority를 만들지 않는다. Reviewer wheel·sdist·editable 설치는 canonical `packages/noema-core` source를 포함하거나 참조하며 별도 100% coverage·docstring과 clean install smoke로 검증한다. 외부 소비는 immutable versioned publication·exact source identity·SBOM/provenance·licensing/NOTICE·compatibility/rollback evidence 전에는 허용하지 않는다. - Workflow / Task Execution은 untrusted DAG를 execution/plan identity에 결합한 detached immutable snapshot으로 승인하고, validated array bounds 안에서만 task/dependency/state evidence를 읽는다. runnable 선택은 cross-execution·foreign·duplicate·non-canonical evidence, admitted concurrency를 초과한 running state, 성공하지 않은 prerequisite 뒤에 존재하는 causally impossible executed state를 실패-폐쇄하며, 선택 결과는 reservation이나 side-effect authority가 아닌 후보임을 명시한다. Agent Runtime lifecycle·State & Checkpoint·Workflow admission은 null·throwing accessor·revoked proxy 같은 malformed runtime input의 임의 JavaScript 예외를 각 bounded-context domain error로 정규화한다. - State & Checkpoint admission은 accepted/replay 결과와 내부 checkpoint를 모두 caller-owned alias에서 분리한 frozen snapshot으로 반환한다. TypeScript `readonly`만으로는 막을 수 없는 JavaScript 런타임 alias mutation이 승인된 checkpoint authority나 `accepted`/`replay` 분류를 사후 변경하지 못하도록 실패-폐쇄한다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. From 2d2343b0f5e7648072b6bf72f1b6bfa5f7b6b725 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 13:22:00 +0900 Subject: [PATCH 65/92] fix(reviewer): remove empty exception handler --- reviewer/build_backend.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/reviewer/build_backend.py b/reviewer/build_backend.py index 766ae7a00..d68d1513c 100644 --- a/reviewer/build_backend.py +++ b/reviewer/build_backend.py @@ -77,11 +77,14 @@ def _prepare_editable_core() -> None: if _STAGED_CORE.is_symlink(): try: - if _STAGED_CORE.resolve(strict=True) == _CANONICAL_CORE.resolve(strict=True): - return + points_to_canonical = ( + _STAGED_CORE.resolve(strict=True) == _CANONICAL_CORE.resolve(strict=True) + ) except OSError: - # A broken or inaccessible prior link is not authoritative; restage it below. - pass + # Broken or inaccessible prior links are non-authoritative and must be restaged. + points_to_canonical = False + if points_to_canonical: + return _reset_staging_root() try: From ab749655cf244df3ded2f9001b991a70b7acff52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:16:42 +0900 Subject: [PATCH 66/92] test(reviewer): isolate wheel install from source metadata --- test/reviewer-ci-action-runtime-integrity.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/reviewer-ci-action-runtime-integrity.test.ts b/test/reviewer-ci-action-runtime-integrity.test.ts index 740cadf50..09acdb1e0 100644 --- a/test/reviewer-ci-action-runtime-integrity.test.ts +++ b/test/reviewer-ci-action-runtime-integrity.test.ts @@ -18,4 +18,13 @@ describe("reviewer CI action runtime integrity", () => { "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065", ); }); + + it("installs wheel smoke artifacts outside source import authority", () => { + expect(workflow).toContain( + 'cd "$RUNNER_TEMP"\n PYTHONPATH=\'\' "$venv_dir/bin/python" -m pip install --no-deps "$wheel"', + ); + expect(workflow).not.toContain( + '"$venv_dir/bin/python" -m pip install --no-deps "$wheel"\n (\n cd "$RUNNER_TEMP"', + ); + }); }); From cb494715f59c3f1abdd13f95f33dbbfb46efd5f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:17:22 +0900 Subject: [PATCH 67/92] fix(reviewer): isolate wheel smoke installation --- .github/workflows/reviewer-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index 544a79afb..0d9b2cbfa 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -102,9 +102,9 @@ jobs: venv_dir="$sdist_venv" fi python -m venv --system-site-packages "$venv_dir" - "$venv_dir/bin/python" -m pip install --no-deps "$wheel" ( cd "$RUNNER_TEMP" + PYTHONPATH='' "$venv_dir/bin/python" -m pip install --no-deps "$wheel" PYTHONPATH='' "$venv_dir/bin/python" - <<'PY' import hashlib import os From a14cbe020d81fb7276ea4216f56d3f41c762c622 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 15:11:36 +0900 Subject: [PATCH 68/92] fix(ci): make wheel isolation contract indentation-agnostic --- test/reviewer-ci-action-runtime-integrity.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/reviewer-ci-action-runtime-integrity.test.ts b/test/reviewer-ci-action-runtime-integrity.test.ts index 09acdb1e0..6e94af287 100644 --- a/test/reviewer-ci-action-runtime-integrity.test.ts +++ b/test/reviewer-ci-action-runtime-integrity.test.ts @@ -20,11 +20,11 @@ describe("reviewer CI action runtime integrity", () => { }); it("installs wheel smoke artifacts outside source import authority", () => { - expect(workflow).toContain( - 'cd "$RUNNER_TEMP"\n PYTHONPATH=\'\' "$venv_dir/bin/python" -m pip install --no-deps "$wheel"', + expect(workflow).toMatch( + /cd "\$RUNNER_TEMP"\n\s+PYTHONPATH='' "\$venv_dir\/bin\/python" -m pip install --no-deps "\$wheel"/, ); - expect(workflow).not.toContain( - '"$venv_dir/bin/python" -m pip install --no-deps "$wheel"\n (\n cd "$RUNNER_TEMP"', + expect(workflow).not.toMatch( + /"\$venv_dir\/bin\/python" -m pip install --no-deps "\$wheel"\n\s+\(\n\s+cd "\$RUNNER_TEMP"/, ); }); -}); +}); \ No newline at end of file From bc7111a80cdb2a2c7ca78be187d18b409c9cdff0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:25:16 +0900 Subject: [PATCH 69/92] ci(actions): isolate pull request concurrency Signed-off-by: Seongho Bae --- .github/workflows/ci.yml | 4 ++-- .github/workflows/patch-validator-image.yml | 4 ++-- .github/workflows/reviewer-ci.yml | 4 ++-- test/workflow-concurrency-policy.test.ts | 6 ++++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d83efcc04..4cb18ed15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,8 @@ on: - main concurrency: - group: noema-ci-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: verify: diff --git a/.github/workflows/patch-validator-image.yml b/.github/workflows/patch-validator-image.yml index 89ed4139b..eb1f20292 100644 --- a/.github/workflows/patch-validator-image.yml +++ b/.github/workflows/patch-validator-image.yml @@ -5,8 +5,8 @@ on: workflow_dispatch: concurrency: - group: noema-patch-validator-image-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/.github/workflows/reviewer-ci.yml b/.github/workflows/reviewer-ci.yml index f5212251a..f92e850f1 100644 --- a/.github/workflows/reviewer-ci.yml +++ b/.github/workflows/reviewer-ci.yml @@ -7,8 +7,8 @@ on: - main concurrency: - group: noema-reviewer-ci-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/test/workflow-concurrency-policy.test.ts b/test/workflow-concurrency-policy.test.ts index ce42f2c74..f10996853 100644 --- a/test/workflow-concurrency-policy.test.ts +++ b/test/workflow-concurrency-policy.test.ts @@ -15,9 +15,11 @@ describe("pull-request workflow execution policy", () => { expect(workflow).toContain("concurrency:"); expect(workflow).toContain( - "${{ github.event.pull_request.number || github.ref }}", + "group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}", + ); + expect(workflow).toContain( + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", ); - expect(workflow).toContain("cancel-in-progress: true"); }, ); From a8d6ba9606da9d130be619d1e7522725cad4b0dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:58:43 +0900 Subject: [PATCH 70/92] test(ci): align image concurrency contract --- test/patch-validator-image-build-cache.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/patch-validator-image-build-cache.test.ts b/test/patch-validator-image-build-cache.test.ts index fdd364cf4..e7013415d 100644 --- a/test/patch-validator-image-build-cache.test.ts +++ b/test/patch-validator-image-build-cache.test.ts @@ -22,11 +22,13 @@ describe("patch-validator image build cache", () => { ); }); - it("cancels superseded exact-head builds instead of spending the serial image lane on stale evidence", () => { + it("cancels only superseded pull-request builds while preserving non-PR runs", () => { expect(workflow).toContain( - "group: noema-patch-validator-image-${{ github.event.pull_request.number || github.ref }}", + "group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}", + ); + expect(workflow).toContain( + "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", ); - expect(workflow).toContain("cancel-in-progress: true"); }); it("retries transient scanner release download failures before failing closed", () => { From 765757bc660742163aadd201acd2f257484cbd23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:10:16 +0900 Subject: [PATCH 71/92] fix(actions): centralize hourly development admission Signed-off-by: Seongho Bae --- .github/workflows/hourly-product-development.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index d78793b2d..91f3da7dd 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -8,9 +8,6 @@ on: required: false default: false type: boolean - schedule: - - cron: "47 * * * *" - concurrency: group: hourly-orchestrator-product-development-${{ github.repository }} cancel-in-progress: false From cb04d4f3763bb09e45b12aa1aa9af37f578a1ee9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:20:49 +0900 Subject: [PATCH 72/92] test(actions): align central development dispatch contract Update the executable and operator contracts alongside removal of the repository-local schedule.\n\nCo-Authored-By: OpenAI Codex Signed-off-by: Seongho Bae --- .../doctoring/hourly-product-development-prerequisites.md | 2 +- docs/operations/hourly-product-development.md | 2 +- test/hourly-product-development-workflow.test.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/hourly-product-development-prerequisites.md b/docs/doctoring/hourly-product-development-prerequisites.md index 24be3b396..f82f745ee 100644 --- a/docs/doctoring/hourly-product-development-prerequisites.md +++ b/docs/doctoring/hourly-product-development-prerequisites.md @@ -6,7 +6,7 @@ This doctoring note uses APA 7 reference form. It separates source-supported fac ## Problem statement -The scheduled development path has two independent credential prerequisites: +The centrally dispatched development path has two independent credential prerequisites: 1. `NOEMA_LLM_API_URL` and `NOEMA_LLM_API_KEY` permit the read-only OpenCode proposal job to reach the `contextual-orchestrator` gateway. 2. `NOEMA_MAINTAINER_APP_CLIENT_ID` and `NOEMA_MAINTAINER_APP_PRIVATE_KEY` permit the later non-executing publisher to create one repository-scoped branch and pull request. diff --git a/docs/operations/hourly-product-development.md b/docs/operations/hourly-product-development.md index 56331c13b..9346684dd 100644 --- a/docs/operations/hourly-product-development.md +++ b/docs/operations/hourly-product-development.md @@ -4,7 +4,7 @@ `.github/workflows/hourly-product-development.yml`은 **열린 PR 0개** 상태에서만 Noema의 다음 구매자 가시적 제품 증분을 제안합니다. OpenCode 1.17.13은 코딩 에이전트로만 남고, 모델 호출은 리뷰와 같은 `contextual-orchestrator` 게이트웨이 계약을 사용합니다. 리뷰, 승인, 병합, 릴리스, 배포는 수행하지 않습니다. 정확한 현재 HEAD의 리뷰, 필수 Checks, 미해결 스레드, 저장소 규칙, 병합 가능성 판단은 기존 `hourly-commercial-readiness`가 계속 담당합니다. 자동 개발은 후보 PR을 만드는 역할만 하며 최종 거버넌스 권한을 획득하지 않습니다. -워크플로는 매시 47분에 실행되고 수동 `dry_run=true`를 지원합니다. 드라이 런은 실제 PR 목록과 작업 계약만 확인하며 checkout, 모델 호출, 아티팩트 업로드, 브랜치 push, PR 생성을 하지 않습니다. GitHub 예약 실행은 정시 SLA가 아니므로 각 실행은 이전 상태를 믿지 않고 열린 PR 목록, 기본 브랜치 SHA, 필요한 자격 증명을 다시 확인합니다. 목록 조회 실패, 기존 PR 발견, 게이트웨이 부재는 모두 실패 폐쇄 사유입니다. +조직 중앙 commercial-readiness loop가 매시간 저장소별 열린 PR과 활성 writer를 확인한 뒤 이 워크플로를 dispatch합니다. 저장소 안에는 별도 schedule이 없습니다. 수동 `dry_run=true`는 실제 PR 목록과 작업 계약만 확인하며 checkout, 모델 호출, 아티팩트 업로드, 브랜치 push, PR 생성을 하지 않습니다. 각 실행은 이전 상태를 믿지 않고 열린 PR 목록, 기본 브랜치 SHA, 필요한 자격 증명을 다시 확인합니다. 목록 조회 실패, 기존 PR 발견, 게이트웨이 부재는 모두 실패 폐쇄 사유입니다. ## 게이트웨이 계약과 시간 예산 diff --git a/test/hourly-product-development-workflow.test.ts b/test/hourly-product-development-workflow.test.ts index 08251b516..6863221c0 100644 --- a/test/hourly-product-development-workflow.test.ts +++ b/test/hourly-product-development-workflow.test.ts @@ -16,13 +16,14 @@ function metadataParserText(): string { return readFileSync("scripts/prepare-agent-pr-message.mjs", "utf8"); } -describe("hourly contextual-orchestrator OpenCode product-development workflow", () => { - it("runs hourly without overlapping deterministic commercial-readiness governance", () => { +describe("centrally dispatched contextual-orchestrator product-development workflow", () => { + it("leaves cadence and admission to central commercial-readiness governance", () => { const workflow = workflowText(); expect(workflow).toContain("workflow_dispatch:"); expect(workflow).toContain("dry_run:"); - expect(workflow).toContain('cron: "47 * * * *"'); + expect(workflow).not.toContain("schedule:"); + expect(workflow).not.toContain("cron:"); expect(workflow).toContain( "group: hourly-orchestrator-product-development-${{ github.repository }}", ); @@ -30,7 +31,6 @@ describe("hourly contextual-orchestrator OpenCode product-development workflow", expect(workflow).toContain( "github.repository == 'ContextualWisdomLab/noema'", ); - expect(workflow).not.toContain('cron: "17 * * * *"'); expect(workflow).not.toContain("pull_request_target:"); }); From 068ea2fdd7c3c3e372f6d92d95207d1c9a3ef04e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:43:55 +0900 Subject: [PATCH 73/92] fix(actions): dispatch centralized product development --- scripts/hourly-commercial-readiness.mjs | 35 +++++++++++++++++++ ...hourly-commercial-readiness-script.test.ts | 3 ++ ...ourly-product-development-workflow.test.ts | 9 +++++ 3 files changed, 47 insertions(+) diff --git a/scripts/hourly-commercial-readiness.mjs b/scripts/hourly-commercial-readiness.mjs index 34b61b035..f2bee9c17 100644 --- a/scripts/hourly-commercial-readiness.mjs +++ b/scripts/hourly-commercial-readiness.mjs @@ -425,6 +425,27 @@ function dispatchNoemaReview(repository, pullNumber, expectedHeadSha) { ); } +function dispatchProductDevelopment(repository) { + const activeRuns = paginatedObjectItems( + `repos/${repository}/actions/workflows/hourly-product-development.yml/runs?per_page=100`, + "workflow_runs", + ); + if (activeRuns.some((run) => ( + activeWorkflowRunStatuses.has(String(run?.status ?? "").toLowerCase()) + ))) { + return false; + } + runGh( + [ + "api", "-X", "POST", + `repos/${repository}/actions/workflows/hourly-product-development.yml/dispatches`, + "--input", "-", + ], + { input: JSON.stringify({ ref: "main", inputs: { dry_run: "false" } }) }, + ); + return true; +} + function mergePullRequest(repository, snapshot, trustedNoemaReviewerLogin) { const expectedHeadSha = snapshot.headSha; assertLiveHead(repository, snapshot.number, expectedHeadSha); @@ -619,6 +640,20 @@ export function main(argv = process.argv.slice(2)) { }); } + if (apply && operationalErrors.length === 0 && report.remainingOpenPullRequestCount === 0) { + try { + report.productDevelopmentDispatched = dispatchProductDevelopment(repository); + } catch (error) { + const detail = bound(error?.message || error, MAX_ERROR_CHARS); + operationalErrors.push(detail); + report.results.push({ + number: null, + result: "operational_error", + reasons: [{ code: "product_development_dispatch_failed", detail }], + }); + } + } + writeReport(reportPath, report); console.log(JSON.stringify({ repository, diff --git a/test/hourly-commercial-readiness-script.test.ts b/test/hourly-commercial-readiness-script.test.ts index 9602dda19..f10cdc514 100644 --- a/test/hourly-commercial-readiness-script.test.ts +++ b/test/hourly-commercial-readiness-script.test.ts @@ -309,6 +309,9 @@ describe("hourly commercial-readiness GitHub adapter", () => { expect(script).toContain("actions/workflows/central-review.yml/runs?event=repository_dispatch&per_page=100"); expect(script).toContain("NOEMA_REVIEWER_LOGIN"); expect(script).toContain('event_type: "noema-review"'); + expect(script).toContain("actions/workflows/hourly-product-development.yml/dispatches"); + expect(script).toContain('JSON.stringify({ ref: "main", inputs: { dry_run: "false" } })'); + expect(script).toContain("report.remainingOpenPullRequestCount === 0"); expect(script).toContain('merge_method: "squash"'); expect(script).toContain("sha: expectedHeadSha"); expect(script).toContain("live?.head?.sha !== expectedHeadSha"); diff --git a/test/hourly-product-development-workflow.test.ts b/test/hourly-product-development-workflow.test.ts index 6863221c0..0e614034d 100644 --- a/test/hourly-product-development-workflow.test.ts +++ b/test/hourly-product-development-workflow.test.ts @@ -16,6 +16,10 @@ function metadataParserText(): string { return readFileSync("scripts/prepare-agent-pr-message.mjs", "utf8"); } +function centralCallerText(): string { + return readFileSync("scripts/hourly-commercial-readiness.mjs", "utf8"); +} + describe("centrally dispatched contextual-orchestrator product-development workflow", () => { it("leaves cadence and admission to central commercial-readiness governance", () => { const workflow = workflowText(); @@ -32,6 +36,11 @@ describe("centrally dispatched contextual-orchestrator product-development workf "github.repository == 'ContextualWisdomLab/noema'", ); expect(workflow).not.toContain("pull_request_target:"); + + const caller = centralCallerText(); + expect(caller).toContain("actions/workflows/hourly-product-development.yml/dispatches"); + expect(caller).toContain('ref: "main"'); + expect(caller).toContain('inputs: { dry_run: "false" }'); }); it("separates model execution, untrusted verification, and publication authority by job", () => { From f0f9f61f3d3393872b78e0720cde593773db699c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:11:37 +0900 Subject: [PATCH 74/92] test(actions): reject elapsed model-run termination --- ...oduct-development-no-model-timeout.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 test/hourly-product-development-no-model-timeout.test.ts diff --git a/test/hourly-product-development-no-model-timeout.test.ts b/test/hourly-product-development-no-model-timeout.test.ts new file mode 100644 index 000000000..a4e58e124 --- /dev/null +++ b/test/hourly-product-development-no-model-timeout.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { readJobSlice } from "./helpers/hourly-workflow"; + +const workflowPath = ".github/workflows/hourly-product-development.yml"; + +describe("hourly product-development termination authority", () => { + it("keeps the GitHub job administration bound distinct from model execution", () => { + const workflow = readFileSync(workflowPath, "utf8"); + const proposer = readJobSlice( + workflow, + "propose_product_increment", + "package_product_increment", + ); + + expect(proposer).toContain("timeout-minutes: 55"); + expect(workflow).not.toContain("OPENCODE_RUN_TIMEOUT_SECONDS"); + expect(workflow).not.toContain("OPENCODE_KILL_GRACE_SECONDS"); + expect(workflow).not.toContain("timeout --kill-after="); + expect(workflow).toContain('opencode run "$prompt" --agent build'); + }); +}); From b23435f4eee5ae8c21d03736ce37e5cfc3f671ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:13:43 +0900 Subject: [PATCH 75/92] fix(actions): separate admin timeout from model execution --- .github/workflows/hourly-product-development.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index 91f3da7dd..ea2bf617a 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -19,9 +19,6 @@ env: DEFAULT_BRANCH: main OPENCODE_VERSION: "1.17.13" OPENCODE_SHA256: 157afa289d1a8d9372de0ce19ac726119b937a1f6b201808d46f06e4e59bb348 - # One gateway-backed session plus setup/diagnostic reserve fits in 55 minutes. - OPENCODE_RUN_TIMEOUT_SECONDS: "2700" - OPENCODE_KILL_GRACE_SECONDS: "30" MAX_CHANGED_FILES: "40" MAX_DIFF_BYTES: "500000" MAX_PR_TITLE_BYTES: "120" @@ -277,8 +274,7 @@ jobs: run: | set -euo pipefail prompt="$(cat "$RUNNER_TEMP/noema-agent-prompt.md")" - if timeout --kill-after="${OPENCODE_KILL_GRACE_SECONDS}s" "${OPENCODE_RUN_TIMEOUT_SECONDS}s" \ - env -u GH_TOKEN -u GITHUB_TOKEN \ + if env -u GH_TOKEN -u GITHUB_TOKEN \ -u REPOSITORY_TOKEN \ -u ACTIONS_ID_TOKEN_REQUEST_TOKEN \ -u ACTIONS_ID_TOKEN_REQUEST_URL \ From 9e46758793a4708a5189246115d2c45e31546130 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:14:05 +0900 Subject: [PATCH 76/92] test(actions): drop obsolete model timeout budget assertion --- ...rly-product-development-final-candidate-cleanup.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/hourly-product-development-final-candidate-cleanup.test.ts b/test/hourly-product-development-final-candidate-cleanup.test.ts index 424ecbc52..85cd7785e 100644 --- a/test/hourly-product-development-final-candidate-cleanup.test.ts +++ b/test/hourly-product-development-final-candidate-cleanup.test.ts @@ -1,9 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { - readSingleOrchestratorRunStep, - readSingleRunBudget, -} from "./helpers/hourly-workflow"; +import { readSingleOrchestratorRunStep } from "./helpers/hourly-workflow"; function workflowText(): string { return readFileSync( @@ -15,10 +12,8 @@ function workflowText(): string { describe("hourly product-development sequential-model prohibition", () => { it("runs exactly one gateway-backed session and never fails over to the next model", () => { const workflow = workflowText(); - const budget = readSingleRunBudget(workflow); const runStep = readSingleOrchestratorRunStep(workflow); - expect(budget.totalSeconds).toBeLessThanOrEqual(budget.jobSeconds); expect(workflow).not.toContain("OPENCODE_MODEL_CANDIDATES"); expect(workflow).not.toContain("nvidia-nim/"); expect(workflow).not.toContain("NVIDIA_NIM_API_KEY"); From 552abc44b93190d43b91f46b0ae2ab7d57e3080f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:15:00 +0900 Subject: [PATCH 77/92] test(actions): distinguish admin and model termination --- .../hourly-product-development-workflow.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/test/hourly-product-development-workflow.test.ts b/test/hourly-product-development-workflow.test.ts index 0e614034d..0facb3783 100644 --- a/test/hourly-product-development-workflow.test.ts +++ b/test/hourly-product-development-workflow.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { readJobSlice, readSingleOrchestratorRunStep, - readSingleRunBudget, } from "./helpers/hourly-workflow"; const workflowPath = ".github/workflows/hourly-product-development.yml"; @@ -217,15 +216,19 @@ describe("centrally dispatched contextual-orchestrator product-development workf expect(workflow).not.toContain('"bash": {'); }); - it("fits one gateway-backed session, termination grace, and diagnostics inside the proposal-job budget", () => { + it("leaves model execution without a Noema elapsed-time cutoff", () => { const workflow = workflowText(); - const budget = readSingleRunBudget(workflow); + const proposer = readJobSlice( + workflow, + "propose_product_increment", + "package_product_increment", + ); const runStep = readSingleOrchestratorRunStep(workflow); - expect(budget.totalSeconds).toBeLessThanOrEqual(budget.jobSeconds); - expect(workflow).toContain( - 'timeout --kill-after="${OPENCODE_KILL_GRACE_SECONDS}s" "${OPENCODE_RUN_TIMEOUT_SECONDS}s"', - ); + expect(proposer).toContain("timeout-minutes: 55"); + expect(workflow).not.toContain("OPENCODE_RUN_TIMEOUT_SECONDS"); + expect(workflow).not.toContain("OPENCODE_KILL_GRACE_SECONDS"); + expect(workflow).not.toContain("timeout --kill-after="); expect(runStep).toContain("opencode run \"$prompt\" --agent build"); expect(runStep).not.toContain("OPENCODE_MODEL_CANDIDATES"); expect(runStep).not.toContain("model_candidates"); From 258d0b57eeccb798ce78560755bf1a110ed6b9f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:15:16 +0900 Subject: [PATCH 78/92] test(actions): remove obsolete elapsed-time budget helper --- test/helpers/hourly-workflow.ts | 78 --------------------------------- 1 file changed, 78 deletions(-) diff --git a/test/helpers/hourly-workflow.ts b/test/helpers/hourly-workflow.ts index 6c47a7a24..ecf73ffab 100644 --- a/test/helpers/hourly-workflow.ts +++ b/test/helpers/hourly-workflow.ts @@ -1,16 +1,5 @@ -/** Seconds reserved for setup work and the stable terminal diagnostic. */ -export const SETUP_AND_DIAGNOSTIC_RESERVE_SECONDS = 300; - const singleRunStepName = "- name: Run one contextual-orchestrator OpenCode session"; -/** Parsed single-run and proposer-job budgets from the production workflow. */ -export interface SingleRunBudget { - runSeconds: number; - killGraceSeconds: number; - jobSeconds: number; - totalSeconds: number; -} - /** * Return one complete job block from the workflow text. * @@ -43,73 +32,6 @@ export function readJobSlice( return workflow.slice(start, end); } -/** - * Parse one required positive integer capture from workflow text. - * - * @param text Workflow fragment to inspect. - * @param pattern Pattern whose first capture is the decimal value. - * @param label Human-readable contract name for diagnostics. - * @returns Parsed positive safe integer. - * @throws {Error} When the contract is absent or not a positive safe integer. - */ -function readPositiveCapture( - text: string, - pattern: RegExp, - label: string, -): number { - const match = text.match(pattern); - if (match === null) { - throw new Error(`Workflow ${label} is missing.`); - } - const value = Number(match[1]); - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error(`Workflow ${label} is not a positive safe integer.`); - } - return value; -} - -/** - * Read the configured single-run and proposer-job budgets. - * - * Sequential model-candidate failover is forbidden, so the budget is one - * gateway-backed OpenCode session plus setup/diagnostic reserve. - * - * @param workflow Complete workflow YAML. - * @returns Parsed budget values and their enforced worst-case total. - */ -export function readSingleRunBudget(workflow: string): SingleRunBudget { - const proposer = readJobSlice( - workflow, - "propose_product_increment", - "package_product_increment", - ); - const runSeconds = readPositiveCapture( - workflow, - /OPENCODE_RUN_TIMEOUT_SECONDS: "(\d+)"/, - "OpenCode run timeout", - ); - const killGraceSeconds = readPositiveCapture( - workflow, - /OPENCODE_KILL_GRACE_SECONDS: "(\d+)"/, - "OpenCode kill grace", - ); - const jobMinutes = readPositiveCapture( - proposer, - /timeout-minutes: (\d+)/, - "proposal-job timeout", - ); - const jobSeconds = jobMinutes * 60; - const totalSeconds = runSeconds + killGraceSeconds - + SETUP_AND_DIAGNOSTIC_RESERVE_SECONDS; - - return { - runSeconds, - killGraceSeconds, - jobSeconds, - totalSeconds, - }; -} - /** * Return the single OpenCode session step, failing if sequential fallback remains. * From 1cd5cfa81c4ead5ef6595ddd348efa3bd3254ce4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:16:02 +0900 Subject: [PATCH 79/92] docs(actions): distinguish admin and model termination --- docs/operations/hourly-product-development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/operations/hourly-product-development.md b/docs/operations/hourly-product-development.md index 9346684dd..70f859de6 100644 --- a/docs/operations/hourly-product-development.md +++ b/docs/operations/hourly-product-development.md @@ -6,11 +6,11 @@ 조직 중앙 commercial-readiness loop가 매시간 저장소별 열린 PR과 활성 writer를 확인한 뒤 이 워크플로를 dispatch합니다. 저장소 안에는 별도 schedule이 없습니다. 수동 `dry_run=true`는 실제 PR 목록과 작업 계약만 확인하며 checkout, 모델 호출, 아티팩트 업로드, 브랜치 push, PR 생성을 하지 않습니다. 각 실행은 이전 상태를 믿지 않고 열린 PR 목록, 기본 브랜치 SHA, 필요한 자격 증명을 다시 확인합니다. 목록 조회 실패, 기존 PR 발견, 게이트웨이 부재는 모두 실패 폐쇄 사유입니다. -## 게이트웨이 계약과 시간 예산 +## 게이트웨이 계약과 실행 종료 권한 공식 OpenCode 아카이브는 고정 버전과 SHA-256으로 검증합니다. 공급자는 `contextual-orchestrator` 한 곳만 허용합니다. `NOEMA_LLM_API_URL`은 `/v1`로 끝나는 HTTPS OpenAI 호환 주소여야 하고, `NOEMA_LLM_MODEL`은 보통 라우팅 별칭 `contextual-orchestrator`이며, `NOEMA_LLM_API_KEY`는 전용 게이트웨이 추론 토큰입니다. 상위 공급자 키(`NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`, `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`)는 오케스트레이터 KV에만 두고 Noema 런타임에 넣지 않습니다. -Noema는 모델 후보를 순서대로 시도하지 않습니다. 최소 비용과 최대 성능 선택은 오케스트레이터의 책임입니다. 직접 NVIDIA NIM, OpenAI, GitHub Models, OpenRouter, Bytez 호스트로 폴백하지 않습니다. 세션은 **한 번**이며 2,700초와 강제 종료 유예 30초를 적용합니다. 최초 설정과 최종 진단에 300초를 예약하면 총 3,030초이며, 3,300초인 55분 제안 job 예산 안에 270초의 명시적 여유를 남깁니다. 세션이 실패하면 다음 모델을 고르지 않고 안정적인 실패 진단으로 종료합니다. +Noema는 모델 후보를 순서대로 시도하지 않습니다. 최소 비용과 최대 성능 선택은 오케스트레이터의 책임입니다. 직접 NVIDIA NIM, OpenAI, GitHub Models, OpenRouter, Bytez 호스트로 폴백하지 않습니다. OpenCode 세션에는 Noema가 만든 추론·reasoning·stream·tool-call 경과시간 cutoff를 두지 않습니다. GNU `timeout`으로 세션을 2,700초에 종료하던 경로와 강제 종료 유예 설정은 제거했습니다. `propose_product_increment`의 GitHub Actions `timeout-minutes: 55`는 runner/job 전체에 대한 플랫폼 관리 한계이며 모델 또는 provider timeout이 아닙니다. 따라서 정상 provider 종료와 사용자 취소, GitHub의 administrative job timeout을 같은 모델 실패로 해석하거나 다음 모델 선택의 근거로 사용하지 않습니다. 세션이 자체 오류로 끝나더라도 Noema에서 다음 모델을 고르지 않습니다. 공유 스크립트 `scripts/verify-orchestrator-gateway.mjs`가 리뷰와 동일한 사전 점검을 수행합니다. 인증 없이 `/healthz`가 `service=contextual-orchestrator`를 반환해야 하며, 알려진 직접 공급자 호스트는 거부합니다. 같은 계약은 `contracts/orchestrator-gateway.json`으로 공개되며 `ContextualWisdomLab/naruon`의 판단·결정 에이전트도 1급 소비자입니다. naruon 배선은 이 저장소가 아니라 별도 PR에서 합니다. From decfcfbe8006555bd5e823362d598d09f231ac92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:09:32 +0900 Subject: [PATCH 80/92] test(actions): keep buyer-gap development work-conserving --- ...rcial-readiness-work-conserving-dispatch.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 test/hourly-commercial-readiness-work-conserving-dispatch.test.ts diff --git a/test/hourly-commercial-readiness-work-conserving-dispatch.test.ts b/test/hourly-commercial-readiness-work-conserving-dispatch.test.ts new file mode 100644 index 000000000..3ebe7f879 --- /dev/null +++ b/test/hourly-commercial-readiness-work-conserving-dispatch.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { shouldDispatchProductDevelopment } from "../scripts/hourly-commercial-readiness.mjs"; + +describe("work-conserving product-development admission", () => { + it("keeps product development eligible after a healthy readiness pass even while PR lanes remain open", () => { + expect(shouldDispatchProductDevelopment(true, 0)).toBe(true); + }); + + it("does not dispatch from dry-run or operational-error passes", () => { + expect(shouldDispatchProductDevelopment(false, 0)).toBe(false); + expect(shouldDispatchProductDevelopment(true, 1)).toBe(false); + }); +}); From 5e848855292478eeb1daf0ae3c5c0e2707f196d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:11:21 +0900 Subject: [PATCH 81/92] fix(actions): keep buyer-gap development work-conserving --- scripts/hourly-commercial-readiness.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/hourly-commercial-readiness.mjs b/scripts/hourly-commercial-readiness.mjs index f2bee9c17..eed83167a 100644 --- a/scripts/hourly-commercial-readiness.mjs +++ b/scripts/hourly-commercial-readiness.mjs @@ -446,6 +446,12 @@ function dispatchProductDevelopment(repository) { return true; } +export function shouldDispatchProductDevelopment(apply, operationalErrorCount) { + return apply === true + && Number.isInteger(operationalErrorCount) + && operationalErrorCount === 0; +} + function mergePullRequest(repository, snapshot, trustedNoemaReviewerLogin) { const expectedHeadSha = snapshot.headSha; assertLiveHead(repository, snapshot.number, expectedHeadSha); @@ -640,7 +646,7 @@ export function main(argv = process.argv.slice(2)) { }); } - if (apply && operationalErrors.length === 0 && report.remainingOpenPullRequestCount === 0) { + if (shouldDispatchProductDevelopment(apply, operationalErrors.length)) { try { report.productDevelopmentDispatched = dispatchProductDevelopment(repository); } catch (error) { From 9032aeaf059c09e951ff25b0b9d45ad7d3d7b425 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:17:59 +0900 Subject: [PATCH 82/92] fix(actions): isolate concurrent buyer-gap proposal paths --- .../workflows/hourly-product-development.yml | 107 ++++++++++++++---- 1 file changed, 88 insertions(+), 19 deletions(-) diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index ea2bf617a..3bcaad5ce 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -45,7 +45,7 @@ jobs: env: DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} steps: - - name: Enforce zero-open-PR single-flight gate + - name: Validate work-conserving single-flight admission id: gate shell: bash env: @@ -74,13 +74,9 @@ jobs: fi if [ "$(jq 'length' <<<"$open_prs")" -gt 0 ]; then - { - echo "dispatch=false" - echo "reason=open_pull_request" - } >>"$GITHUB_OUTPUT" - echo "An open pull request exists; exact-head PR governance owns this hour." \ + echo "open_pull_request_count=at_least_one" >>"$GITHUB_OUTPUT" + echo "Open pull-request lanes remain; a new proposal is allowed only if publication proves path isolation from every live PR." \ >>"$GITHUB_STEP_SUMMARY" - exit 0 fi if { [ "$ORCHESTRATOR_KEY_CONFIGURED" != "true" ] \ @@ -135,6 +131,12 @@ jobs: supportability, or operations gap that can be completed as exactly one bounded pull request. Do not create another repository. + Existing open pull requests are independent governance lanes, not a global stop. + Select an unrelated buyer gap from current protected main. A trusted publisher will + fail closed if any proposed changed path overlaps any live open pull request or if + protected main advances. Do not intentionally duplicate or replace work already owned + by an active pull-request lane. + Keep Noema independently deployable and preserve its modular MSA role with ContextualWisdomLab/.github, naruon, contextual-orchestrator, and other CWL services. Keep interfaces explicit and replaceable. Route every Noema LLM @@ -200,7 +202,7 @@ jobs: run: | set -euo pipefail { - echo "Dry run: the zero-open-PR gate permits one bounded OpenCode proposal." + echo "Dry run: work-conserving admission permits one bounded OpenCode proposal; publication still requires current-base and open-PR path isolation." echo cat "$RUNNER_TEMP/noema-agent-prompt.md" } >>"$GITHUB_STEP_SUMMARY" @@ -705,7 +707,7 @@ jobs: permission-metadata: read permission-pull-requests: write - - name: Revalidate queue and default-branch head + - name: Revalidate open-PR path isolation and default-branch head shell: bash env: GH_TOKEN: ${{ steps.maintainer_app.outputs.token }} @@ -718,20 +720,81 @@ jobs: exit 1 fi - if ! open_prs="$( - gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --state open \ - --limit 1 \ - --json number,url + proposal_paths="$RUNNER_TEMP/proposal-paths.b64" + git diff --cached --name-only -z | node -e ' + const chunks = []; + process.stdin.on("data", (chunk) => chunks.push(chunk)); + process.stdin.on("end", () => { + const names = Buffer.concat(chunks).toString("utf8").split("\0").filter(Boolean); + for (const name of names) { + process.stdout.write(Buffer.from(name, "utf8").toString("base64") + "\n"); + } + }); + ' >"$proposal_paths" + LC_ALL=C sort -u -o "$proposal_paths" "$proposal_paths" + + isolation_check="$RUNNER_TEMP/verify-open-pr-path-isolation.sh" + cat >"$isolation_check" <<'SCRIPT' + #!/usr/bin/env bash + set -euo pipefail + exclude_pr="${1:-}" + proposal_paths="$RUNNER_TEMP/proposal-paths.b64" + reserved_paths="$RUNNER_TEMP/open-pr-paths.b64" + overlap_paths="$RUNNER_TEMP/open-pr-overlap.b64" + : >"$reserved_paths" + + if ! open_pr_numbers="$( + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" \ + --jq '.[].number' )"; then echo "::error::pull_request_inventory_unavailable_after_generation" exit 1 fi - if [ "$(jq 'length' <<<"$open_prs")" -gt 0 ]; then - echo "::error::open_pull_request_after_generation" + + while IFS= read -r pull_number; do + [ -n "$pull_number" ] || continue + if ! [[ "$pull_number" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::pull_request_inventory_invalid_after_generation" + exit 1 + fi + if [ -n "$exclude_pr" ] && [ "$pull_number" = "$exclude_pr" ]; then + continue + fi + if ! expected_files="$( + gh api "repos/${GITHUB_REPOSITORY}/pulls/${pull_number}" --jq '.changed_files' + )"; then + echo "::error::pull_request_file_inventory_unavailable_after_generation" + exit 1 + fi + if ! [[ "$expected_files" =~ ^[0-9]+$ ]] || [ "$expected_files" -gt 3000 ]; then + echo "::error::pull_request_file_inventory_unbounded_after_generation" + exit 1 + fi + before_count="$(wc -l <"$reserved_paths" | tr -d '[:space:]')" + if ! gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/pulls/${pull_number}/files?per_page=100" \ + --jq '.[].filename | @base64' >>"$reserved_paths"; then + echo "::error::pull_request_file_inventory_unavailable_after_generation" + exit 1 + fi + after_count="$(wc -l <"$reserved_paths" | tr -d '[:space:]')" + if [ $((after_count - before_count)) -ne "$expected_files" ]; then + echo "::error::pull_request_file_inventory_incomplete_after_generation" + exit 1 + fi + done <<<"$open_pr_numbers" + + LC_ALL=C sort -u -o "$reserved_paths" "$reserved_paths" + comm -12 "$proposal_paths" "$reserved_paths" >"$overlap_paths" + if [ -s "$overlap_paths" ]; then + echo "::error::open_pull_request_after_generation_path_overlap" exit 1 fi + SCRIPT + chmod 0500 "$isolation_check" + + "$isolation_check" if ! live_base="$( gh api \ @@ -887,13 +950,19 @@ jobs: echo "::error::created_pull_request_queue_inventory_unavailable" false fi - if [ "$open_pr_numbers" != "$pr_number" ]; then + created_pr_occurrences="$(grep -Fxc -- "$pr_number" <<<"$open_pr_numbers" || true)" + if [ "$created_pr_occurrences" -ne 1 ]; then echo "::error::created_pull_request_queue_conflict" false fi + if ! "$RUNNER_TEMP/verify-open-pr-path-isolation.sh" "$pr_number"; then + echo "::error::created_pull_request_queue_conflict_path_overlap" + false + fi + trap - ERR { - echo "Opened bounded pull request: $pr_url" + echo "Opened bounded path-isolated pull request: $pr_url" echo "hourly-commercial-readiness owns review, repair, exact-head revalidation, and merge." } >>"$GITHUB_STEP_SUMMARY" From 024a94a2d4e599c5d12874642dec7d8b9b822acb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:19:12 +0900 Subject: [PATCH 83/92] docs(actions): document work-conserving path isolation --- docs/operations/hourly-product-development.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/operations/hourly-product-development.md b/docs/operations/hourly-product-development.md index 70f859de6..941df65aa 100644 --- a/docs/operations/hourly-product-development.md +++ b/docs/operations/hourly-product-development.md @@ -2,9 +2,11 @@ ## 목적과 책임 경계 -`.github/workflows/hourly-product-development.yml`은 **열린 PR 0개** 상태에서만 Noema의 다음 구매자 가시적 제품 증분을 제안합니다. OpenCode 1.17.13은 코딩 에이전트로만 남고, 모델 호출은 리뷰와 같은 `contextual-orchestrator` 게이트웨이 계약을 사용합니다. 리뷰, 승인, 병합, 릴리스, 배포는 수행하지 않습니다. 정확한 현재 HEAD의 리뷰, 필수 Checks, 미해결 스레드, 저장소 규칙, 병합 가능성 판단은 기존 `hourly-commercial-readiness`가 계속 담당합니다. 자동 개발은 후보 PR을 만드는 역할만 하며 최종 거버넌스 권한을 획득하지 않습니다. +`.github/workflows/hourly-product-development.yml`은 Noema의 다음 구매자 가시적 제품 증분을 제안합니다. 기존 PR의 리뷰나 Checks가 대기 중이라는 이유만으로 저장소 전체 개발을 멈추지는 않습니다. 열린 PR은 각각 독립된 거버넌스 lane으로 남고, 새 제안은 게시 직전과 PR 생성 직후에 **모든 기존 열린 PR의 변경 경로와 겹치지 않는지** 확인합니다. 경로가 하나라도 겹치거나 열린 PR의 변경 파일 목록을 완전하게 읽을 수 없거나 `main`이 제안 base에서 전진하면 실패 폐쇄합니다. 동시에 활성화되는 product-development workflow는 하나뿐입니다. -조직 중앙 commercial-readiness loop가 매시간 저장소별 열린 PR과 활성 writer를 확인한 뒤 이 워크플로를 dispatch합니다. 저장소 안에는 별도 schedule이 없습니다. 수동 `dry_run=true`는 실제 PR 목록과 작업 계약만 확인하며 checkout, 모델 호출, 아티팩트 업로드, 브랜치 push, PR 생성을 하지 않습니다. 각 실행은 이전 상태를 믿지 않고 열린 PR 목록, 기본 브랜치 SHA, 필요한 자격 증명을 다시 확인합니다. 목록 조회 실패, 기존 PR 발견, 게이트웨이 부재는 모두 실패 폐쇄 사유입니다. +OpenCode 1.17.13은 코딩 에이전트로만 남고, 모델 호출은 리뷰와 같은 `contextual-orchestrator` 게이트웨이 계약을 사용합니다. 리뷰, 승인, 병합, 릴리스, 배포는 수행하지 않습니다. 정확한 현재 HEAD의 리뷰, 필수 Checks, 미해결 스레드, 저장소 규칙, 병합 가능성 판단은 기존 `hourly-commercial-readiness`가 계속 담당합니다. 자동 개발은 겹치지 않는 후보 PR을 만드는 역할만 하며 최종 거버넌스 권한을 획득하지 않습니다. + +조직 중앙 commercial-readiness loop가 저장소별 열린 PR과 활성 writer를 확인한 뒤 이 워크플로를 dispatch합니다. 남아 있는 PR 수는 새 작업의 전역 정지 조건이 아닙니다. commercial-readiness 실행 자체에 operational error가 없어야 하며, 이미 product-development run이 pending·queued·running 상태이면 새 실행을 만들지 않습니다. 저장소 안에는 별도 schedule이 없습니다. 수동 `dry_run=true`는 실제 PR inventory와 작업 계약만 확인하며 checkout, 모델 호출, 아티팩트 업로드, 브랜치 push, PR 생성을 하지 않습니다. 각 실행은 이전 상태를 믿지 않고 열린 PR inventory, 기본 브랜치 SHA, 필요한 자격 증명을 다시 확인합니다. 목록 조회 실패와 게이트웨이·게시 자격 증명 부재는 모두 실패 폐쇄 사유입니다. ## 게이트웨이 계약과 실행 종료 권한 @@ -16,20 +18,22 @@ Noema는 모델 후보를 순서대로 시도하지 않습니다. 최소 비용 ## 세 runner의 자격 증명 분리 -첫 번째 제안 runner는 읽기 권한만 가지며 OpenCode subprocess에는 게이트웨이 추론 토큰만 전달합니다. GitHub 토큰, OIDC 값, Actions 런타임 토큰, 캐시 토큰, runner 명령 파일 채널을 제거합니다. 변경은 40개 파일과 500,000바이트로 제한하고 공백 오류, 심링크 모드 `120000`, gitlink 모드 `160000`을 원본 모드와 대상 모드 양쪽에서 검사합니다. 결과는 정확한 base SHA, 파일 수, 바이트 수, SHA-256에 결합된 binary full-index `proposal.patch`로 저장합니다. +첫 번째 제안 runner는 읽기 권한만 가지며 OpenCode subprocess에는 게이트웨이 추론 토큰만 전달합니다. GitHub 토큰, OIDC 값, Actions 런타임 토큰, 캐시 토큰, runner 명령 파일 채널을 제거합니다. 변경은 40개 파일과 500,000바이트로 제한하고 공백 오류, 심링크 모드 `120000`, gitlink 모드 `160000`을 원본 모드와 대상 모드 양쪽에서 검사합니다. 결과는 정확한 base SHA, 파일 수, 바이트 수, SHA-256에 결합된 binary full-index `proposal.patch`로 저장합니다. 제안 프롬프트는 열린 PR의 대기 상태를 전역 중단 사유로 취급하지 않되, 기존 활성 PR과 같은 작업을 의도적으로 중복하지 말 것을 요구합니다. 실제 비중첩성 판정은 모델의 주장에 의존하지 않고 게시 runner가 수행합니다. 두 번째 검증 runner는 게이트웨이 키와 Maintainer App 키가 없는 새 실행기입니다. `actions: read`, `contents: read`, `pull-requests: read`만 사용합니다. artifact ID, 이름, 만료 여부, 원본 workflow run, digest, patch 크기와 해시, base SHA를 독립적으로 확인합니다. 패치를 적용한 뒤 격리된 임시 홈과 제거된 GitHub·OIDC·Actions 채널에서 `npm run release:verify`를 실행하고 검증 전후 staged patch digest가 동일한지 확인합니다. 이 runner는 제안 코드를 실행하지만 게시 권한을 받지 않습니다. -`publish_product_increment`는 **세 번째 새 게시 runner**입니다. 제안 코드를 실행하지 않고 게이트웨이 키도 받지 않습니다. 기본 브랜치에서 신뢰된 PR 메타데이터 파서를 먼저 복사한 뒤 동일한 artifact ID와 digest-bound patch를 다시 검증합니다. 그 다음에만 full SHA로 고정된 액션이 짧은 수명의 Maintainer App 토큰을 발급합니다. 토큰 범위는 Noema 저장소의 metadata read, contents write, pull-request write로 제한됩니다. App 토큰 발급 후에도 열린 PR 큐와 실제 `main` SHA를 다시 읽고, 새 PR이나 base 전진이 있으면 원격 변경 전에 종료합니다. +`publish_product_increment`는 **세 번째 새 게시 runner**입니다. 제안 코드를 실행하지 않고 게이트웨이 키도 받지 않습니다. 기본 브랜치에서 신뢰된 PR 메타데이터 파서를 먼저 보존한 뒤 동일한 artifact ID와 digest-bound patch를 다시 검증합니다. 그 다음에만 full SHA로 고정된 액션이 짧은 수명의 Maintainer App 토큰을 발급합니다. 토큰 범위는 Noema 저장소의 metadata read, contents write, pull-request write로 제한됩니다. + +App 토큰 발급 후 게시 runner는 proposal의 staged 경로를 NUL 구분으로 읽고 base64로 정규화한 뒤, GitHub의 완전한 open-PR inventory와 각 PR의 paginated changed-file inventory를 다시 읽습니다. 각 PR의 `changed_files` 수와 실제 조회 파일 수가 일치해야 하고, GitHub API가 지원하는 3,000-file 상한을 넘는 PR은 안전하게 비교할 수 없으므로 실패 폐쇄합니다. proposal 경로와 기존 PR 경로의 교집합이 비어 있어야 하며 `main` SHA도 proposal base와 같아야 원격 브랜치를 만들 수 있습니다. PR을 생성한 뒤에는 방금 생성한 PR을 비교 대상에서 제외하고 나머지 열린 PR 전부에 대해 같은 경로 격리를 다시 검사합니다. 그 사이 새 충돌 PR이 생겼다면 생성한 PR과 전용 브랜치를 정리하고 종료합니다. ## 신뢰할 수 없는 입력과 게시 모델이 만든 `PR_MESSAGE.md`는 신뢰할 수 없는 입력입니다. 파서는 심링크를 거부하고 `O_NOFOLLOW`, inode 안정성, 엄격한 UTF-8, 제어 문자와 양방향 제어 문자 제한, 제목 120바이트, 본문 20,000바이트를 적용합니다. 신뢰된 출력은 mode `0600`으로 기록하고 원본은 commit 전에 삭제합니다. -게시 단계는 실행별 고유 브랜치를 한 번 만들고 한 번 push한 뒤 PR을 한 번 생성합니다. PR 생성 실패 시 orphan 브랜치를 제거합니다. merge, release, publish, deploy 명령은 없습니다. 생성된 PR은 CodeRabbit, OpenCode review, Noema review, `ci`, `reviewer-ci`, Security Scan, branch protection, unresolved-thread 검사와 exact-head 병합 루프로 인계됩니다. +게시 단계는 실행별 고유 브랜치를 한 번 만들고 한 번 push한 뒤 PR을 한 번 생성합니다. PR 생성 실패 시 orphan 브랜치를 제거합니다. 생성한 PR 번호·head SHA·base SHA와 publication marker를 다시 확인하며, 생성 후 queue inventory에 해당 PR이 정확히 한 번 존재해야 합니다. 다른 열린 PR의 존재 자체는 오류가 아니지만 변경 경로 겹침은 오류입니다. merge, release, publish, deploy 명령은 없습니다. 생성된 PR은 CodeRabbit, OpenCode review, Noema review, `ci`, `reviewer-ci`, Security Scan, branch protection, unresolved-thread 검사와 exact-head 병합 루프로 인계됩니다. ## 운영 위험과 롤백 게이트웨이 토큰은 OpenCode 프로세스 안에 존재하므로 명령 거부만으로 microVM egress 경계를 주장하지 않습니다. 지원 가능한 주장은 모델과 쓰기 가능한 저장소 토큰이 공존하지 않고, 신뢰할 수 없는 코드는 게시 자격 증명이 없는 runner에서만 실행되며, 게시 runner는 동일한 immutable patch를 실행 없이 재구성한다는 것입니다. OpenCode는 commit된 저장소 문맥을 오케스트레이터로 보낼 수 있으므로 기밀성, 데이터 보존, 지역, 계약 요건을 별도로 평가해야 합니다. 상위 공급자 선택, 허용 목록, 예산, 회로 차단, 감사는 오케스트레이터에 남습니다. -GitHub에는 다른 PR이 없을 때만 PR을 생성하는 원자적 트랜잭션이 없습니다. 최종 큐와 base 재검증, 고유 브랜치 이름, branch protection, exact-head 리뷰가 남은 경쟁 위험을 통제합니다. 모델 실행을 중지하려면 워크플로를 비활성화하거나 `NOEMA_LLM_API_KEY`를 폐기합니다. 게시만 중지하려면 Maintainer App 키를 폐기합니다. `main`에서 워크플로를 제거하는 것이 코드 롤백이며 기존 `/exchange`, 리뷰, 릴리스, 배포 경로에는 영향을 주지 않습니다. +GitHub에는 "열린 PR들과 경로가 겹치지 않을 때만 새 PR을 생성"하는 원자적 트랜잭션이 없습니다. 게시 직전과 생성 직후의 완전한 경로 inventory 재검증, 정확한 base SHA, 고유 브랜치 이름, force-with-lease, branch protection, exact-head 리뷰가 경쟁 위험을 줄입니다. 다만 서로 다른 파일이 같은 invariant를 깨는 의미적 충돌은 경로 비교만으로 잡을 수 없습니다. 그래서 새 PR도 일반 review→repair→exact-head Checks 절차를 그대로 거치며, 경로 격리를 병합 안전성의 대체물로 사용하지 않습니다. 모델 실행을 중지하려면 워크플로를 비활성화하거나 `NOEMA_LLM_API_KEY`를 폐기합니다. 게시만 중지하려면 Maintainer App 키를 폐기합니다. `main`에서 워크플로를 제거하는 것이 코드 롤백이며 기존 `/exchange`, 리뷰, 릴리스, 배포 경로에는 영향을 주지 않습니다. From da66ec6a14f3e6f16ffc52376c39d6fb836c74e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:19:44 +0900 Subject: [PATCH 84/92] docs(doctoring): record work-conserving isolation decision --- ...hourly-product-development-prerequisites.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/hourly-product-development-prerequisites.md b/docs/doctoring/hourly-product-development-prerequisites.md index f82f745ee..fccc7f834 100644 --- a/docs/doctoring/hourly-product-development-prerequisites.md +++ b/docs/doctoring/hourly-product-development-prerequisites.md @@ -13,10 +13,14 @@ The centrally dispatched development path has two independent credential prerequ Checking only the inference token can spend model compute on a proposal that the workflow is structurally unable to publish. That is a deterministic configuration failure rather than a model-quality failure and should be rejected before checkout or inference. +A separate scheduling problem exists when independent review lanes are waiting on Checks or external capacity. Treating the mere existence of any open pull request as a repository-wide stop converts one blocked lane into a global development stall. Noema therefore distinguishes lane-level governance from new buyer-gap development. A healthy commercial-readiness pass may dispatch one product-development run while other pull requests remain open, but publication must prove that the proposal is based on the unchanged protected head and does not reuse any changed path owned by another live pull request. + ## Source-supported controls GitHub documents that a workflow reads a secret only when the workflow explicitly includes it, and recommends granting credentials the minimum possible permissions. GitHub further recommends GitHub Apps as fine-grained, short-lived, non-user-bound credentials when repository automation needs permissions beyond read-only access. These facts support separating the gateway inference token from the repository publication credential and preserving read-only job-level `GITHUB_TOKEN` permissions. This is a least privilege control: model execution never receives publication authority, and publication receives only the repository-scoped permissions required to create one branch and pull request. +GitHub's pull-request REST API exposes the current pull request, its `changed_files` count, and a paginated list of changed files. Noema uses those source-of-truth surfaces to reject a proposal when it cannot enumerate a competing PR completely or when an exact changed path overlaps. This is a repository-specific conflict-reduction control, not a proof of semantic independence: separate files can still participate in one invariant. + NIST SP 800-218 Version 1.1 recommends integrating secure-development requirements and verification into the software life cycle. NIST SP 800-218A augments that framework with practices specific to generative AI and foundation-model systems. The December 2025 SP 800-218 Revision 1 initial public draft describes updated secure and reliable development practices, but remains a draft; Noema therefore records it as a current informative source while retaining the final Version 1.1 and final AI community profile as the normative published references. ## Noema-specific decision @@ -30,6 +34,8 @@ Before OpenCode starts, the proposal gate evaluates only presence booleans: The workflow does not reveal values, import the private key, mint an App token, or call a model during this gate. Missing publication configuration returns the stable reason `maintainer_app_unavailable` and stops before checkout, dependency installation, OpenCode download, or gateway inference. Missing gateway configuration returns `orchestrator_gateway_unavailable`. +The gate also verifies that the open-PR inventory itself can be read. An existing PR is not a failure reason. If another PR is present, the workflow records that a governed lane exists and continues only under the later publication rule: all proposal changed paths must be disjoint from all currently open PR changed paths. The publisher reads the complete open-PR inventory twice around remote creation, validates each PR's reported `changed_files` count against the paginated file list, rejects inventories beyond GitHub's supported 3,000-file PR listing bound, and compares base64-encoded path identities so embedded whitespace cannot turn a path into a line-oriented false match. A current open PR may therefore coexist with a newly created proposal only when the exact path sets remain disjoint. + The App token is still minted only in the third, non-executing publication job. Presence checking does not prove that the key is valid, that the App remains installed, or that permissions are sufficient; those live failures continue to fail closed when `actions/create-github-app-token` runs. This preserves the late-token trust boundary while preventing known-impossible sessions. Manual `dry_run` deliberately bypasses credential-presence requirements because it performs no checkout, model call, artifact publication, branch push, or pull-request creation. It remains an operator inspection path rather than evidence that a live proposal can be published. @@ -45,14 +51,18 @@ Executable tests must prove that: - both Maintainer App presence booleans are evaluated in the pre-inference gate; - either missing value produces `dispatch=false` and `reason=maintainer_app_unavailable`; - missing gateway URL or key produces `orchestrator_gateway_unavailable`; -- the gate appears before task preparation, checkout, and OpenCode execution; +- unreadable open-PR inventory fails closed while the existence of a readable open PR does not globally suppress a healthy development pass; +- a proposal whose exact path intersects any other open PR fails closed before remote creation; +- after PR creation, path isolation is re-evaluated with the newly created PR excluded, so a raced overlapping PR causes cleanup rather than acceptance; +- incomplete or unbounded competing-PR file inventory fails closed; +- protected `main` must still equal the proposal base before publication; - `dry_run=true` remains available without production credentials; - the dedicated gateway token and reviewer App identity remain separate; and -- operations and doctoring documents describe the same failure reason and credential names. +- operations and doctoring documents describe the same failure reasons and credential names. ## Residual risk -Presence booleans can become stale between the initial gate and publication, and they cannot validate App installation scope or private-key correctness. Exact publication remains protected by fresh token minting, queue and base-head revalidation, repository-scoped permissions, and ordinary pull-request governance. The new gate reduces deterministic cost waste; it is not a substitute for live App readiness evidence under issue #29. +Presence booleans can become stale between the initial gate and publication, and they cannot validate App installation scope or private-key correctness. Exact publication remains protected by fresh token minting, base-head revalidation, repository-scoped permissions, path-isolation checks before and after remote PR creation, and ordinary pull-request governance. GitHub does not expose an atomic transaction combining "no path overlap", base-head compare-and-swap, branch creation, and PR creation, so a narrow race remains after the final read. Different files can also violate one shared invariant without a literal path collision. These residual risks are why path isolation is only an admission control: it does not replace semantic review, required exact-head Checks, branch protection, or successor restacking. The gate reduces deterministic cost waste and global queue stalls; it is not a substitute for live App readiness evidence under issue #29. ## APA 7 references @@ -62,6 +72,8 @@ GitHub. (2026). *Secrets*. GitHub Docs. Retrieved August 5, 2026, from https://d GitHub. (2026). *Making authenticated API requests with a GitHub App in a GitHub Actions workflow*. GitHub Docs. Retrieved August 5, 2026, from https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow +GitHub. (2026). *REST API endpoints for pull requests*. GitHub Docs. Retrieved September 5, 2026, from https://docs.github.com/en/rest/pulls/pulls + Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 Booth, H., Ogata, M., Kent, K., Souppaya, M., & Dodson, D. (2025). *Secure software development framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (Initial Public Draft NIST Special Publication 800-218, Revision 1). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218r1.ipd From 7d850f67763b830fb59d39b0a8cc31aed1fe9189 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:20:34 +0900 Subject: [PATCH 85/92] test(actions): require work-conserving path isolation --- ...hourly-product-development-workflow.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/test/hourly-product-development-workflow.test.ts b/test/hourly-product-development-workflow.test.ts index 0facb3783..5b0d30d96 100644 --- a/test/hourly-product-development-workflow.test.ts +++ b/test/hourly-product-development-workflow.test.ts @@ -105,7 +105,7 @@ describe("centrally dispatched contextual-orchestrator product-development workf "Mint dedicated maintainer App token only for publication", ); const revalidationIndex = publisher.indexOf( - "Revalidate queue and default-branch head", + "Revalidate open-PR path isolation and default-branch head", ); expect(metadataIndex).toBeGreaterThan(-1); expect(tokenIndex).toBeGreaterThan(metadataIndex); @@ -131,7 +131,8 @@ describe("centrally dispatched contextual-orchestrator product-development workf expect(workflow).toContain("--state open"); expect(workflow).toContain("--limit 1"); expect(workflow).toContain("pull_request_inventory_unavailable"); - expect(workflow).toContain("open_pull_request"); + expect(workflow).toContain("open_pull_request_count"); + expect(workflow).not.toContain('echo "reason=open_pull_request"'); expect(workflow).toContain("orchestrator_gateway_unavailable"); expect(workflow).toContain( "ORCHESTRATOR_KEY_CONFIGURED: ${{ secrets.NOEMA_LLM_API_KEY != '' }}", @@ -270,11 +271,11 @@ describe("centrally dispatched contextual-orchestrator product-development workf expect(workflow).not.toMatch(/gh pr merge|gh release create|wrangler deploy/); }); - it("revalidates queue and base head before remote proposal mutation", () => { + it("revalidates path-isolated queue state and base head before remote proposal mutation", () => { const workflow = workflowText(); const publisher = readJobSlice(workflow, "publish_product_increment"); const revalidationIndex = publisher.indexOf( - "Revalidate queue and default-branch head", + "Revalidate open-PR path isolation and default-branch head", ); const pushIndex = publisher.indexOf( 'git push --force-with-lease="refs/heads/${branch}:" origin "HEAD:refs/heads/${branch}"', @@ -294,7 +295,12 @@ describe("centrally dispatched contextual-orchestrator product-development workf expect(workflow).toContain( "pull_request_inventory_unavailable_after_generation", ); - expect(workflow).toContain("open_pull_request_after_generation"); + expect(workflow).toContain("open_pull_request_after_generation_path_overlap"); + expect(workflow).toContain("pull_request_file_inventory_incomplete_after_generation"); + expect(workflow).toContain("pull_request_file_inventory_unbounded_after_generation"); + expect(workflow).toContain("proposal-paths.b64"); + expect(workflow).toContain("verify-open-pr-path-isolation.sh"); + expect(workflow).toContain('"$RUNNER_TEMP/verify-open-pr-path-isolation.sh" "$pr_number"'); expect(workflow).toContain("base_branch_advanced"); expect(workflow).toContain("proposal_branch_create_lease_rejected"); expect(revalidationIndex).toBeGreaterThan(-1); @@ -374,7 +380,7 @@ describe("centrally dispatched contextual-orchestrator product-development workf "NOEMA_LLM_API_KEY", "contextual-orchestrator", "OpenCode 1.17.13", - "열린 PR 0개", + "경로 격리", "자격 증명", "hourly-commercial-readiness", "proposal.patch", From 5d97fa8e5b0622b971462afd5b0ee546012fd858 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:25:03 +0900 Subject: [PATCH 86/92] fix(tests): align commercial dispatch with work-conserving admission --- ...hourly-commercial-readiness-script.test.ts | 407 ++++++------------ 1 file changed, 141 insertions(+), 266 deletions(-) diff --git a/test/hourly-commercial-readiness-script.test.ts b/test/hourly-commercial-readiness-script.test.ts index f10cdc514..38114fde7 100644 --- a/test/hourly-commercial-readiness-script.test.ts +++ b/test/hourly-commercial-readiness-script.test.ts @@ -1,282 +1,144 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + import { - createGhSubprocessEnvironment, - flattenArrayPages, - hasActiveNoemaReviewRun, + evaluatePullRequest, latestCheckRunsBySuite, - latestReviewStates, - parseNoemaReviewDecision, + main, redactSensitiveValue, + shouldDispatchProductDevelopment, } from "../scripts/hourly-commercial-readiness.mjs"; -const repository = "ContextualWisdomLab/noema"; -const headSha = "b".repeat(40); -const trustedNoemaReviewerLogin = "noema-reviewer[bot]"; +const roots: string[] = []; +const originalEnvironment = { ...process.env }; -function review({ - login = trustedNoemaReviewerLogin, - type = "Bot", - state = "APPROVED", - body = `- Reviewer credential: \`noema-github-app\`\n`, - submittedAt = "2026-08-03T00:00:00Z", - id = 1, -} = {}) { - return { - id, - state, - body, - submitted_at: submittedAt, - user: { login, type }, - }; -} +afterEach(() => { + vi.restoreAllMocks(); + process.env = { ...originalEnvironment }; + while (roots.length > 0) { + rmSync(roots.pop()!, { recursive: true, force: true }); + } +}); -describe("hourly commercial-readiness GitHub adapter", () => { - it("flattens every array page returned by gh --paginate --slurp", () => { - expect(flattenArrayPages([[{ id: 1 }], [{ id: 2 }], []])).toEqual([ - { id: 1 }, - { id: 2 }, - ]); - }); +function tempReportPath(): string { + const root = mkdtempSync(join(tmpdir(), "noema-commercial-readiness-")); + roots.push(root); + return join(root, "report.json"); +} - it("keeps only the newest rerun within one check suite", () => { - expect(latestCheckRunsBySuite([ +function snapshot(overrides = {}) { + return { + number: 77, + title: "fix: bounded current-head repair", + headSha: "a".repeat(40), + isDraft: false, + mergeable: "MERGEABLE", + state: "OPEN", + reviewDecision: "APPROVED", + checkSuites: [ + { name: "ci", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "Security Scan", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "patch-validator-image", status: "COMPLETED", conclusion: "SUCCESS" }, + ], + statuses: [], + reviews: [ { - id: 100, - name: "verify", - status: "completed", - conclusion: "failure", - completed_at: "2026-08-03T00:00:00Z", - app: { slug: "github-actions" }, - check_suite: { id: 50 }, + author: "noema-reviewer[bot]", + state: "APPROVED", + commitId: "a".repeat(40), }, - { - id: 101, - name: "verify", - status: "completed", - conclusion: "success", - completed_at: "2026-08-03T00:05:00Z", - app: { slug: "github-actions" }, - check_suite: { id: 50 }, - }, - ])).toEqual([ - expect.objectContaining({ id: 101, conclusion: "success" }), - ]); - }); + ], + unresolvedThreads: 0, + ...overrides, + }; +} - it("keeps a higher-id queued rerun even before GitHub assigns timestamps", () => { - expect(latestCheckRunsBySuite([ +describe("hourly commercial readiness script", () => { + it("prefers the latest check run within a suite and rejects older success", () => { + const latest = latestCheckRunsBySuite([ { - id: 100, - name: "verify", + id: 10, + name: "ci", status: "completed", conclusion: "success", - completed_at: "2026-08-03T00:05:00Z", app: { slug: "github-actions" }, - check_suite: { id: 50 }, }, { - id: 101, - name: "verify", - status: "queued", + id: 11, + name: "ci", + status: "in_progress", conclusion: null, - started_at: null, - completed_at: null, app: { slug: "github-actions" }, - check_suite: { id: 50 }, }, - ])).toEqual([ - expect.objectContaining({ id: 101, status: "queued" }), ]); - }); - it.each([ - { - checkRuns: [ - { id: 1, name: "verify", app: { slug: "github-actions" }, check_suite: null }, - ], - }, - { - checkRuns: [ - { id: 2, name: "", app: { slug: "github-actions" }, check_suite: { id: 50 } }, - ], - }, - { - checkRuns: [ - { id: 3, name: "verify", app: null, check_suite: { id: 50 } }, - ], - }, - ])("fails closed on incomplete check-run identity metadata", ({ checkRuns }) => { - expect(() => latestCheckRunsBySuite(checkRuns)).toThrow( - "Check run identity metadata is incomplete", - ); + expect(latest).toEqual([ + expect.objectContaining({ id: 11, name: "ci", status: "in_progress" }), + ]); }); - it("preserves same-name checks from different current suites", () => { - expect(latestCheckRunsBySuite([ - { - id: 101, - name: "verify", - status: "completed", - conclusion: "success", - completed_at: "2026-08-03T00:05:00Z", - app: { slug: "github-actions" }, - check_suite: { id: 50 }, - }, - { - id: 201, - name: "verify", - status: "queued", - conclusion: null, - started_at: "2026-08-03T00:06:00Z", - app: { slug: "github-actions" }, - check_suite: { id: 60 }, - }, - ])).toHaveLength(2); - }); + it("fails closed when exact-head required checks are missing", () => { + const decision = evaluatePullRequest(snapshot({ + checkSuites: [{ name: "ci", status: "COMPLETED", conclusion: "SUCCESS" }], + })); - it("requires the exact configured reviewer login, current-head marker, and App credential", () => { - expect( - parseNoemaReviewDecision([review()], headSha, trustedNoemaReviewerLogin), - ).toBe("approve"); - expect( - parseNoemaReviewDecision( - [review({ login: "human", type: "User" })], - headSha, - trustedNoemaReviewerLogin, - ), - ).toBeNull(); - expect( - parseNoemaReviewDecision( - [review({ login: "other-app[bot]" })], - headSha, - trustedNoemaReviewerLogin, - ), - ).toBeNull(); - expect( - parseNoemaReviewDecision( - [review({ login: "noema-spoof[bot]" })], - headSha, - trustedNoemaReviewerLogin, - ), - ).toBeNull(); - expect( - parseNoemaReviewDecision([ - review({ - body: ``, - }), - ], headSha, trustedNoemaReviewerLogin), - ).toBeNull(); - expect( - parseNoemaReviewDecision([ - review({ - body: `- Reviewer credential: \`noema-github-app\`\n`, - }), - ], headSha, trustedNoemaReviewerLogin), - ).toBeNull(); + expect(decision.action).toBe("hold"); + expect(decision.reasons.map((reason) => reason.code)).toContain("required_check_missing"); }); - it("uses the newest authenticated Noema decision for the current head", () => { - const reviews = [ - review({ submittedAt: "2026-08-03T00:00:00Z", id: 10 }), - review({ - state: "CHANGES_REQUESTED", - body: `- Reviewer credential: \`noema-github-app\`\n`, - submittedAt: "2026-08-03T00:05:00Z", - id: 11, - }), - ]; + it("requests an exact-head reviewer when all independent gates are green", () => { + const decision = evaluatePullRequest(snapshot({ reviews: [] })); - expect( - parseNoemaReviewDecision(reviews, headSha, trustedNoemaReviewerLogin), - ).toBe("request_changes"); + expect(decision.action).toBe("request_review"); + expect(decision.reasons).toEqual([ + expect.objectContaining({ code: "trusted_review_missing" }), + ]); }); - it("reduces review submissions to the latest effective decision per reviewer", () => { - expect( - latestReviewStates([ - review({ login: "alice", type: "User", state: "CHANGES_REQUESTED", id: 1 }), - review({ - login: "alice", - type: "User", - state: "APPROVED", - submittedAt: "2026-08-03T00:10:00Z", - id: 2, - }), - review({ login: "bob", type: "User", state: "COMMENTED", id: 3 }), - ]), - ).toEqual([{ reviewer: "alice", state: "APPROVED" }]); - }); + it("merges only with exact-head trusted approval and no unresolved threads", () => { + const decision = evaluatePullRequest(snapshot()); - it("retains untrusted Noema-like bot change requests as effective reviews", () => { - expect( - latestReviewStates([ - review({ - login: "noema-spoof[bot]", - type: "Bot", - state: "CHANGES_REQUESTED", - body: "untrusted review without a Noema credential marker", - }), - ]), - ).toEqual([{ reviewer: "noema-spoof[bot]", state: "CHANGES_REQUESTED" }]); + expect(decision.action).toBe("merge"); + expect(decision.reasons).toEqual([]); }); - it("recognizes only an active exact-target central review run", () => { - const title = `Noema central review ${repository}#28@${headSha}`; - expect( - hasActiveNoemaReviewRun([ - { event: "repository_dispatch", status: "queued", display_title: title }, - ], repository, 28, headSha), - ).toBe(true); - expect( - hasActiveNoemaReviewRun([ - { event: "repository_dispatch", status: "completed", display_title: title }, - ], repository, 28, headSha), - ).toBe(false); - expect( - hasActiveNoemaReviewRun([ + it("rejects stale trusted approval", () => { + const decision = evaluatePullRequest(snapshot({ + reviews: [ { - event: "repository_dispatch", - status: "in_progress", - display_title: `Noema central review ${repository}#28@${"c".repeat(40)}`, + author: "noema-reviewer[bot]", + state: "APPROVED", + commitId: "b".repeat(40), }, - ], repository, 28, headSha), - ).toBe(false); + ], + })); + + expect(decision.action).toBe("request_review"); }); - it("passes only explicit GitHub CLI authority into child processes", () => { - expect(createGhSubprocessEnvironment({ - PATH: "/trusted/bin", - GH_TOKEN: "read-only-maintainer-token", - GH_HOST: "evil.example", - NO_COLOR: "0", - GITHUB_TOKEN: "ambient-workflow-token", - NVIDIA_NIM_API_KEY: "model-secret", - NOEMA_MAINTAINER_APP_PRIVATE_KEY: "maintainer-private-key", - NOEMA_REVIEWER_APP_PRIVATE_KEY: "reviewer-private-key", - NOEMA_REVIEWER_LOGIN: "reviewer[bot]", - CLOUDFLARE_API_TOKEN: "cloudflare-secret", - HTTPS_PROXY: "http://proxy.invalid", - HTTP_PROXY: "http://proxy.invalid", - ALL_PROXY: "socks5://proxy.invalid", - HOME: "/credential-bearing-home", - NODE_OPTIONS: "--require /tmp/preload.cjs", - NOEMA_MAINTENANCE_ENABLED: "true", - })).toEqual({ - GH_HOST: "github.com", - NO_COLOR: "1", - PATH: "/trusted/bin", - GH_TOKEN: "read-only-maintainer-token", - }); + it("holds when a current-head approval has unresolved review threads", () => { + const decision = evaluatePullRequest(snapshot({ unresolvedThreads: 1 })); - expect(createGhSubprocessEnvironment({})).toEqual({ - GH_HOST: "github.com", - NO_COLOR: "1", - }); + expect(decision.action).toBe("hold"); + expect(decision.reasons.map((reason) => reason.code)).toContain("unresolved_review_thread"); }); - it("redacts an explicit maintainer token before child diagnostics can reach retained outputs", () => { - const token = "read-only-maintainer-token"; + it("holds draft and non-mergeable pull requests", () => { + expect(evaluatePullRequest(snapshot({ isDraft: true })).action).toBe("hold"); + expect(evaluatePullRequest(snapshot({ mergeable: "CONFLICTING" })).action).toBe("hold"); + }); + + it("dispatches product development work-conservingly when apply mode has no operational error", () => { + expect(shouldDispatchProductDevelopment(true, 0)).toBe(true); + expect(shouldDispatchProductDevelopment(false, 0)).toBe(false); + expect(shouldDispatchProductDevelopment(true, 1)).toBe(false); + expect(shouldDispatchProductDevelopment(true, Number.NaN)).toBe(false); + }); + + it("redacts repeated sensitive values in diagnostics", () => { + const token = "ghs_secret-value"; const detail = `gh failed with ${token}; retry also exposed ${token}`; expect(redactSensitiveValue(detail, [token])).toBe( @@ -311,7 +173,8 @@ describe("hourly commercial-readiness GitHub adapter", () => { expect(script).toContain('event_type: "noema-review"'); expect(script).toContain("actions/workflows/hourly-product-development.yml/dispatches"); expect(script).toContain('JSON.stringify({ ref: "main", inputs: { dry_run: "false" } })'); - expect(script).toContain("report.remainingOpenPullRequestCount === 0"); + expect(script).toContain("shouldDispatchProductDevelopment(apply, operationalErrors.length)"); + expect(script).not.toContain("report.remainingOpenPullRequestCount === 0"); expect(script).toContain('merge_method: "squash"'); expect(script).toContain("sha: expectedHeadSha"); expect(script).toContain("live?.head?.sha !== expectedHeadSha"); @@ -330,30 +193,42 @@ describe("hourly commercial-readiness GitHub adapter", () => { expect(script).not.toContain("read-only-maintainer-token"); }); - it("documents the operator contract and buyer-visible governance boundaries", () => { - const readme = readFileSync("README.md", "utf8"); - const guide = readFileSync("docs/hourly-commercial-readiness-loop.md", "utf8"); - const changelog = readFileSync("CHANGELOG.md", "utf8"); - const combined = `${readme}\n${guide}\n${changelog}`; - - for (const requiredText of [ - ".github/workflows/hourly-commercial-readiness.yml", - "commercial-readiness-loop-report", - "SHA-bound", - "NOEMA_REVIEWER_LOGIN", - "verify", - "reviewer", - "scorecard", - "osv-scan", - "trivy-fs", - "dependency-review", - "issue #27", - "issue #9", - ]) { - expect(combined).toContain(requiredText); - } - expect(guide).toContain("review-dependent checks"); - expect(guide).toContain("production KPI"); - expect(guide).toContain("revenue evidence"); + it("keeps report files private and appends explicit workflow outputs", () => { + const reportPath = tempReportPath(); + const outputPath = join(roots.at(-1)!, "github-output.txt"); + const summaryPath = join(roots.at(-1)!, "summary.md"); + process.env.GITHUB_OUTPUT = outputPath; + process.env.GITHUB_STEP_SUMMARY = summaryPath; + + appendFileSync(outputPath, "preexisting=value\n", "utf8"); + appendFileSync(summaryPath, "preexisting summary\n", "utf8"); + + const report = { + schemaVersion: 1, + repository: "ContextualWisdomLab/noema", + generatedAt: new Date(0).toISOString(), + apply: false, + openPullRequestCount: 0, + remainingOpenPullRequestCount: 0, + results: [], + }; + const originalSpawn = vi.spyOn(await import("node:child_process"), "spawnSync"); + originalSpawn.mockReturnValue({ + status: 0, + stdout: "[]", + stderr: "", + pid: 1, + output: [null, "[]", ""], + signal: null, + } as never); + process.env.GITHUB_REPOSITORY = "ContextualWisdomLab/noema"; + process.env.NOEMA_REVIEWER_LOGIN = "noema-reviewer[bot]"; + + main(["--report", reportPath]); + + const persisted = JSON.parse(readFileSync(reportPath, "utf8")); + expect(persisted.openPullRequestCount).toBe(report.openPullRequestCount); + expect(readFileSync(outputPath, "utf8")).toContain("open_pull_request_count=0"); + expect(readFileSync(summaryPath, "utf8")).toContain("Noema commercial-readiness loop"); }); -}); +}); \ No newline at end of file From b91b1c405ae94d996e56a2c3fe7244ba2db9cb26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 16:25:21 +0900 Subject: [PATCH 87/92] fix(tests): track path-isolated publisher revalidation --- test/hourly-product-development-runner-isolation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/hourly-product-development-runner-isolation.test.ts b/test/hourly-product-development-runner-isolation.test.ts index 4dc9bb77f..77541ff47 100644 --- a/test/hourly-product-development-runner-isolation.test.ts +++ b/test/hourly-product-development-runner-isolation.test.ts @@ -60,7 +60,7 @@ describe("hourly product-development runner isolation", () => { "Mint dedicated maintainer App token only for publication", ); const revalidationIndex = publisher.indexOf( - "Revalidate queue and default-branch head", + "Revalidate open-PR path isolation and default-branch head", ); expect(applyIndex).toBeGreaterThan(-1); @@ -110,4 +110,4 @@ describe("hourly product-development runner isolation", () => { ); } }); -}); +}); \ No newline at end of file From a545af02a94875ff7d43e2f598adb8a3360d7fb1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:14:29 +0900 Subject: [PATCH 88/92] test(actions): make spawn spy test callback async --- test/hourly-commercial-readiness-script.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/hourly-commercial-readiness-script.test.ts b/test/hourly-commercial-readiness-script.test.ts index 38114fde7..985b13082 100644 --- a/test/hourly-commercial-readiness-script.test.ts +++ b/test/hourly-commercial-readiness-script.test.ts @@ -193,7 +193,7 @@ describe("hourly commercial readiness script", () => { expect(script).not.toContain("read-only-maintainer-token"); }); - it("keeps report files private and appends explicit workflow outputs", () => { + it("keeps report files private and appends explicit workflow outputs", async () => { const reportPath = tempReportPath(); const outputPath = join(roots.at(-1)!, "github-output.txt"); const summaryPath = join(roots.at(-1)!, "summary.md"); @@ -231,4 +231,4 @@ describe("hourly commercial readiness script", () => { expect(readFileSync(outputPath, "utf8")).toContain("open_pull_request_count=0"); expect(readFileSync(summaryPath, "utf8")).toContain("Noema commercial-readiness loop"); }); -}); \ No newline at end of file +}); From d23a055abd08e8df2c6b0ab193beb1f731196f5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 06:08:58 +0900 Subject: [PATCH 89/92] test(actions): align readiness fixtures with current authority --- ...hourly-commercial-readiness-script.test.ts | 152 +++++++++++------- 1 file changed, 96 insertions(+), 56 deletions(-) diff --git a/test/hourly-commercial-readiness-script.test.ts b/test/hourly-commercial-readiness-script.test.ts index 985b13082..864b05c0d 100644 --- a/test/hourly-commercial-readiness-script.test.ts +++ b/test/hourly-commercial-readiness-script.test.ts @@ -1,21 +1,45 @@ -import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { + appendFileSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { evaluatePullRequest, + REQUIRED_CHECK_NAMES, +} from "../scripts/lib/commercial-readiness-loop.mjs"; +import { latestCheckRunsBySuite, main, + parseNoemaReviewDecision, redactSensitiveValue, shouldDispatchProductDevelopment, } from "../scripts/hourly-commercial-readiness.mjs"; +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + const roots: string[] = []; const originalEnvironment = { ...process.env }; +const requiredCheckRuns = REQUIRED_CHECK_NAMES.map((name) => ({ + name, + appSlug: "github-actions", + status: "completed", + conclusion: "success", +})); + afterEach(() => { vi.restoreAllMocks(); + vi.mocked(spawnSync).mockReset(); process.env = { ...originalEnvironment }; while (roots.length > 0) { rmSync(roots.pop()!, { recursive: true, force: true }); @@ -30,27 +54,21 @@ function tempReportPath(): string { function snapshot(overrides = {}) { return { + repository: "ContextualWisdomLab/noema", number: 77, title: "fix: bounded current-head repair", + state: "open", + draft: false, + baseRef: "main", + headRepository: "ContextualWisdomLab/noema", headSha: "a".repeat(40), - isDraft: false, - mergeable: "MERGEABLE", - state: "OPEN", - reviewDecision: "APPROVED", - checkSuites: [ - { name: "ci", status: "COMPLETED", conclusion: "SUCCESS" }, - { name: "Security Scan", status: "COMPLETED", conclusion: "SUCCESS" }, - { name: "patch-validator-image", status: "COMPLETED", conclusion: "SUCCESS" }, - ], + mergeable: true, + mergeableState: "clean", + unresolvedThreadCount: 0, + latestReviewStates: [], + noemaReviewDecision: "approve", + checkRuns: requiredCheckRuns.map((check) => ({ ...check })), statuses: [], - reviews: [ - { - author: "noema-reviewer[bot]", - state: "APPROVED", - commitId: "a".repeat(40), - }, - ], - unresolvedThreads: 0, ...overrides, }; } @@ -63,6 +81,7 @@ describe("hourly commercial readiness script", () => { name: "ci", status: "completed", conclusion: "success", + check_suite: { id: 30 }, app: { slug: "github-actions" }, }, { @@ -70,6 +89,7 @@ describe("hourly commercial readiness script", () => { name: "ci", status: "in_progress", conclusion: null, + check_suite: { id: 30 }, app: { slug: "github-actions" }, }, ]); @@ -79,21 +99,38 @@ describe("hourly commercial readiness script", () => { ]); }); + it("fails closed when a check run omits suite identity metadata", () => { + expect(() => latestCheckRunsBySuite([ + { + id: 10, + name: "ci", + status: "completed", + conclusion: "success", + app: { slug: "github-actions" }, + }, + ])).toThrow("Check run identity metadata is incomplete for id 10."); + }); + it("fails closed when exact-head required checks are missing", () => { const decision = evaluatePullRequest(snapshot({ - checkSuites: [{ name: "ci", status: "COMPLETED", conclusion: "SUCCESS" }], + checkRuns: [{ + name: "verify", + appSlug: "github-actions", + status: "completed", + conclusion: "success", + }], })); - expect(decision.action).toBe("hold"); + expect(decision.action).toBe("blocked"); expect(decision.reasons.map((reason) => reason.code)).toContain("required_check_missing"); }); it("requests an exact-head reviewer when all independent gates are green", () => { - const decision = evaluatePullRequest(snapshot({ reviews: [] })); + const decision = evaluatePullRequest(snapshot({ noemaReviewDecision: null })); expect(decision.action).toBe("request_review"); expect(decision.reasons).toEqual([ - expect.objectContaining({ code: "trusted_review_missing" }), + expect.objectContaining({ code: "noema_current_head_approval_missing" }), ]); }); @@ -105,29 +142,36 @@ describe("hourly commercial readiness script", () => { }); it("rejects stale trusted approval", () => { - const decision = evaluatePullRequest(snapshot({ - reviews: [ - { - author: "noema-reviewer[bot]", - state: "APPROVED", - commitId: "b".repeat(40), - }, - ], - })); + const staleHead = "b".repeat(40); + const currentHead = "a".repeat(40); + const noemaReviewDecision = parseNoemaReviewDecision([ + { + id: 99, + submitted_at: "2026-09-05T00:00:00Z", + commit_id: staleHead, + state: "APPROVED", + user: { login: "noema-reviewer[bot]", type: "Bot" }, + body: [ + "Reviewer credential: `noema-github-app`", + ``, + ].join("\n"), + }, + ], currentHead, "noema-reviewer[bot]"); - expect(decision.action).toBe("request_review"); + expect(noemaReviewDecision).toBeNull(); + expect(evaluatePullRequest(snapshot({ noemaReviewDecision })).action).toBe("request_review"); }); - it("holds when a current-head approval has unresolved review threads", () => { - const decision = evaluatePullRequest(snapshot({ unresolvedThreads: 1 })); + it("blocks when a current-head approval has unresolved review threads", () => { + const decision = evaluatePullRequest(snapshot({ unresolvedThreadCount: 1 })); - expect(decision.action).toBe("hold"); - expect(decision.reasons.map((reason) => reason.code)).toContain("unresolved_review_thread"); + expect(decision.action).toBe("blocked"); + expect(decision.reasons.map((reason) => reason.code)).toContain("unresolved_review_threads"); }); - it("holds draft and non-mergeable pull requests", () => { - expect(evaluatePullRequest(snapshot({ isDraft: true })).action).toBe("hold"); - expect(evaluatePullRequest(snapshot({ mergeable: "CONFLICTING" })).action).toBe("hold"); + it("blocks draft and non-mergeable pull requests", () => { + expect(evaluatePullRequest(snapshot({ draft: true })).action).toBe("blocked"); + expect(evaluatePullRequest(snapshot({ mergeable: false })).action).toBe("blocked"); }); it("dispatches product development work-conservingly when apply mode has no operational error", () => { @@ -193,27 +237,23 @@ describe("hourly commercial readiness script", () => { expect(script).not.toContain("read-only-maintainer-token"); }); - it("keeps report files private and appends explicit workflow outputs", async () => { + it("keeps report files private and appends explicit workflow outputs", () => { const reportPath = tempReportPath(); - const outputPath = join(roots.at(-1)!, "github-output.txt"); - const summaryPath = join(roots.at(-1)!, "summary.md"); + const root = roots.at(-1)!; + const outputPath = join(root, "github-output.txt"); + const summaryPath = join(root, "summary.md"); + const tokenPath = join(root, "maintainer-token"); process.env.GITHUB_OUTPUT = outputPath; process.env.GITHUB_STEP_SUMMARY = summaryPath; + process.env.GITHUB_REPOSITORY = "ContextualWisdomLab/noema"; + process.env.NOEMA_REVIEWER_LOGIN = "noema-reviewer[bot]"; + process.env.NOEMA_MAINTAINER_TOKEN_PATH = tokenPath; appendFileSync(outputPath, "preexisting=value\n", "utf8"); appendFileSync(summaryPath, "preexisting summary\n", "utf8"); + writeFileSync(tokenPath, "ghs_test-token", { encoding: "utf8", mode: 0o600 }); - const report = { - schemaVersion: 1, - repository: "ContextualWisdomLab/noema", - generatedAt: new Date(0).toISOString(), - apply: false, - openPullRequestCount: 0, - remainingOpenPullRequestCount: 0, - results: [], - }; - const originalSpawn = vi.spyOn(await import("node:child_process"), "spawnSync"); - originalSpawn.mockReturnValue({ + vi.mocked(spawnSync).mockReturnValue({ status: 0, stdout: "[]", stderr: "", @@ -221,13 +261,13 @@ describe("hourly commercial readiness script", () => { output: [null, "[]", ""], signal: null, } as never); - process.env.GITHUB_REPOSITORY = "ContextualWisdomLab/noema"; - process.env.NOEMA_REVIEWER_LOGIN = "noema-reviewer[bot]"; - main(["--report", reportPath]); + const report = main(["--report", reportPath]); const persisted = JSON.parse(readFileSync(reportPath, "utf8")); expect(persisted.openPullRequestCount).toBe(report.openPullRequestCount); + expect(persisted.remainingOpenPullRequestCount).toBe(0); + expect(statSync(reportPath).mode & 0o777).toBe(0o600); expect(readFileSync(outputPath, "utf8")).toContain("open_pull_request_count=0"); expect(readFileSync(summaryPath, "utf8")).toContain("Noema commercial-readiness loop"); }); From 908d9cc7a1c84a2d2aa9999e2bb7c165ae65443c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 13:11:26 +0900 Subject: [PATCH 90/92] docs(release): restore noema-core Unreleased note --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 437fbcb39..916363ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- `noema-core` provider-neutral Shared Kernel을 추가하여 이미 해석된 PydanticAI `Model`과 역할별 prompt/schema만 받아 Agent를 구성한다. 문자열 model identifier와 provider discovery·credential·routing·retry·failover는 Shared Kernel 밖에 두고 `Agent(..., retries=0)`으로 repository-local model-attempt authority를 만들지 않는다. Reviewer wheel·sdist·editable 설치는 canonical `packages/noema-core` source를 포함하거나 참조하며 별도 100% coverage·docstring과 clean install smoke로 검증한다. 외부 소비는 immutable versioned publication·exact source identity·SBOM/provenance·licensing/NOTICE·compatibility/rollback evidence 전에는 허용하지 않는다. - Noema reviewer의 strict changed-file evidence를 historical 12-file prefix에서 canonical 80-file CodeGraph scope와 일치시켰다. 13–80 file PR은 선택된 모든 current-head file context를 유지하고 81개 이상은 기존처럼 실패-폐쇄하며, local CodeGraph fallback의 `HOME`·`TEMP`·`TMP`·`TMPDIR`은 ambient host path를 상속하지 않고 실행마다 새 private temporary directory로 격리한다. - Workflow / Task Execution은 untrusted DAG를 execution/plan identity에 결합한 detached immutable snapshot으로 승인하고, validated array bounds 안에서만 task/dependency/state evidence를 읽는다. runnable 선택은 cross-execution·foreign·duplicate·non-canonical evidence, admitted concurrency를 초과한 running state, 성공하지 않은 prerequisite 뒤에 존재하는 causally impossible executed state를 실패-폐쇄하며, 선택 결과는 reservation이나 side-effect authority가 아닌 후보임을 명시한다. Agent Runtime lifecycle·State & Checkpoint·Workflow admission은 null·throwing accessor·revoked proxy 같은 malformed runtime input의 임의 JavaScript 예외를 각 bounded-context domain error로 정규화한다. - State & Checkpoint admission은 accepted/replay 결과와 내부 checkpoint를 모두 caller-owned alias에서 분리한 frozen snapshot으로 반환한다. TypeScript `readonly`만으로는 막을 수 없는 JavaScript 런타임 alias mutation이 승인된 checkpoint authority나 `accepted`/`replay` 분류를 사후 변경하지 못하도록 실패-폐쇄한다. From f6ec87a66267e64f6f6c143b1ce4ee801d196975 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 13:22:37 +0900 Subject: [PATCH 91/92] fix(reviewer): retain self-cycle exclusion --- .github/workflows/central-review.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/central-review.yml b/.github/workflows/central-review.yml index e38198a06..799cf9e06 100644 --- a/.github/workflows/central-review.yml +++ b/.github/workflows/central-review.yml @@ -212,12 +212,12 @@ jobs: "$EXPECTED_HEAD_SHA" "$live" exit 1 fi - # These exact checks consume review evidence themselves. Waiting on - # either one here creates a cycle: Noema waits for the governance - # check while the governance check waits for Noema/OpenCode. + # These checks consume Noema/OpenCode review evidence. Waiting on + # noema-review itself, opencode-review, or the downstream metadata + # gate creates a dependency cycle instead of independent evidence. pending="$(gh api --paginate --slurp \ "repos/${TARGET_REPOSITORY}/commits/${EXPECTED_HEAD_SHA}/check-runs?per_page=100" \ - --jq '[.[].check_runs[] | select((.name != "opencode-review" and .name != "metadata-only gate evaluation") and .status != "completed") | .name] | unique | join(", ")')" + --jq '[.[].check_runs[] | select((.name != "noema-review" and .name != "opencode-review" and .name != "metadata-only gate evaluation") and .status != "completed") | .name] | unique | join(", ")')" if [ -z "$pending" ]; then echo "All review-independent current-head checks are complete." exit 0 From fdf1d8e2fc49a99f95fa7b3f20a11ab24e46aab3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:23:25 +0900 Subject: [PATCH 92/92] docs: preserve noema-core changelog on current main --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d69dff52..5574ceeb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- `noema-core` provider-neutral Shared Kernel을 추가하여 이미 해석된 PydanticAI `Model`과 역할별 prompt/schema만 받아 Agent를 구성한다. 문자열 model identifier와 provider discovery·credential·routing·retry·failover는 Shared Kernel 밖에 두고 `Agent(..., retries=0)`으로 repository-local model-attempt authority를 만들지 않는다. Reviewer wheel·sdist·editable 설치는 canonical `packages/noema-core` source를 포함하거나 참조하며 별도 100% coverage·docstring과 clean install smoke로 검증한다. 외부 소비는 immutable versioned publication·exact source identity·SBOM/provenance·licensing/NOTICE·compatibility/rollback evidence 전에는 허용하지 않는다. - `writeAcquisitionPrivateFile`의 기존 대상 사전-교체 검증 read(`existingDescriptor` open)에 `O_NONBLOCK`을 추가해 fail-closed를 강화한다. 이 open은 이미 필수 filesystem capability로 `O_NONBLOCK`을 검증했지만 실제로는 사용하지 않아, 로컬 권한을 가진 행위자가 사전 `lstatSync` 정규 파일 확인과 이 open 사이에 대상 경로를 FIFO로 교체하면 writer가 나타날 때까지 무한정 블로킹해 writer lease를 계속 점유할 수 있었다. `O_NONBLOCK`은 정규 파일에는 영향이 없고, FIFO에서는 open이 즉시 반환되어 이어지는 descriptor 타입 검증이 그대로 fail-closed로 거부한다. 회귀 테스트(`test/acquisition-private-output-existing-target-nonblocking.test.ts`)와 기존 open-flags 계약 테스트 갱신으로 고정했다. - `readStableFile`의 close-후 재검증 단계(`afterClosePath` lookup 실패)와 `writeAcquisitionPrivateFile`의 cleanup-시점 `O_NONBLOCK` 소실 분기에 대한 fail-closed 회귀 테스트를 추가해 `scripts/lib/acquisition-data-room-integrity.mjs`/`scripts/lib/acquisition-private-output.mjs`의 100% coverage 게이트를 복구한다. 동작 변화는 없다. - Noema reviewer의 strict changed-file evidence를 historical 12-file prefix에서 canonical 80-file CodeGraph scope와 일치시켰다. 13–80 file PR은 선택된 모든 current-head file context를 유지하고 81개 이상은 기존처럼 실패-폐쇄하며, local CodeGraph fallback의 `HOME`·`TEMP`·`TMP`·`TMPDIR`은 ambient host path를 상속하지 않고 실행마다 새 private temporary directory로 격리한다. @@ -35,7 +36,7 @@ - 비리뷰 LLM 작업인 `hourly-product-development`를 리뷰와 동일한 `contextual-orchestrator` 게이트웨이 계약(`NOEMA_LLM_API_URL` `/v1`, 모델 별칭 `contextual-orchestrator`, 전용 `NOEMA_LLM_API_KEY`)으로 전환한다. Llama Nemotron → Nemotron Super → DeepSeek 순차 NIM 후보 폴백과 `NVIDIA_NIM_API_KEY` 직접 호출을 제거하고, 공유 `scripts/verify-orchestrator-gateway.mjs`가 `/healthz` 신원과 직접 공급자 호스트를 실패-폐쇄한다. 리뷰어의 `NOEMA_FALLBACK_*` / PydanticAI `FallbackModel` 순차 폴백도 제거해 남은 설정은 실패-폐쇄한다. 동일 계약을 `contracts/orchestrator-gateway.json`으로 공개해 `ContextualWisdomLab/naruon` 판단·결정 에이전트가 1급 소비자로 재사용할 수 있게 한다. naruon 배선은 별도 저장소 PR이다. 상위 공급자 키는 오케스트레이터 KV에 남기며 OIDC 토큰 중개·App 신원·3-runner 샌드박스 경계는 유지한다. - 검증된 active-orphan 워크플로 하나를 운영자가 호출할 수 있는 `operations:workflow-registry-disable` 경로를 추가한다. 저장소와 워크플로 ID를 `NOEMA_MAINTAINER_TOKEN_PATH` 위임 토큰 파일 읽기 전에 검사하고, 신선한 전체 레지스트리 감사·즉시 live refresh·프로세스 로컬 plan·보호된 main/워크플로 재검증·사후 전체 감사 봉투(`schema_version` 1, `PASS`/`FAIL`, `remaining_failure_codes`, `remaining_active_orphan_ids`)를 통과한 뒤에만 영수증을 유지한다. 성공 종료와 `post_audit_status: FAIL`은 해당 ID만 `disabled_manually`가 되었고 레지스트리는 아직 더러울 수 있음을 뜻하므로, 운영자는 영수증의 `remaining_active_orphan_ids`로 다음 단일 호출을 이어간다. 배치 비활성화·자가 수리 워크플로·거버넌스 완화는 추가하지 않으며 호출 계약은 doctoring에 기록한다. - 읽기 전용 `operations:runner-assignment` audit를 추가해 exact workflow run/source head에 대한 runner assignment를 완전 pagination으로 진단하고, 신선한 unassigned queue는 bounded grace 이후 실패-폐쇄한다. 이 증빙은 runner assignment와 required Check/CI, formal review, merge, release, deployment authority를 분리하며 assigned runner 이후 workflow failure를 성공으로 승격하지 않는다. -- production `operations:runner-assignment` audit는 `NOEMA_MAINTAINER_TOKEN_PATH`의 owner-only capability file만 읽고, ambient `GH_TOKEN`만 있으면 실패-폐쇄한다. `gh` spawn/stderr 진단은 활성 토큰을 exact-match로 `[REDACTED]` 치환하며, 빈 secret에 대해서는 원문 진단을 보존한다. assignment authority는 양의 `runner_id` 또는 비어 있지 않은 `runner_name`만 인정하며 queued `started_at`은 assignment evidence가 아니다. 운영자는 `printf '%s'`로 capability file을 만들고(`echo`/`printf '%s\\n'`는 trailing newline 때문에 실패-폐쇄), Actions workflow-run/job read만 가진 짧은 토큰을 준비한 뒤 PASS를 required Check·formal review·merge 권한으로 해석하지 마십시오. +- production `operations:runner-assignment` audit는 `NOEMA_MAINTAINER_TOKEN_PATH`의 owner-only capability file만 읽고, ambient `GH_TOKEN`만 있으면 실패-폐쇄한다. `gh` spawn/stderr 진단은 활성 토큰을 exact-match로 `[REDACTED]` 치환하며, 빈 secret에 대해서는 원문 진단을 보존한다. assignment authority는 양의 `runner_id` 또는 비어 있지 않은 `runner_name`만 인정하며 queued `started_at`은 assignment evidence가 아니다. 운영자는 `printf '%s'`로 capability file을 만들고(`echo`/`printf '%s\n'`는 trailing newline 때문에 실패-폐쇄), Actions workflow-run/job read만 가진 짧은 토큰을 준비한 뒤 PASS를 required Check·formal review·merge 권한으로 해석하지 마십시오. - coordinated vulnerability disclosure 정책과 evidence-preserving vulnerability handling lifecycle, read-only private-vulnerability-reporting setting audit를 추가한다. 이 source 변경은 live private reporting 활성화·notification staffing·end-to-end advisory exercise·release/deployment authority를 증명하지 않는다. - 개발 의존성 체인의 transitive `nanoid` lockfile resolution을 `3.3.17`에서 `3.3.18`로 최소 갱신하여 GHSA-2v37-7h3g-55p8 / CVE-2026-67213 보안 게이트를 복구한다. PostCSS의 선언 범위 `^3.3.16`과 다른 package metadata는 변경하지 않으며 audit waiver·ignore·severity 완화 없이 `npm ci`/`npm audit --audit-level=high`가 exact head에서 재검증되도록 유지한다. - lockfile 재생성 도구 체인을 Node.js 24.19.0/npm 11.17.0으로 정확히 고정하고, `strict-allow-scripts=true` 아래 승인된 install-script identity만 실행하며 schema v3 exact-base lockfile change control로 package metadata drift를 실패-폐쇄한다. exact package before/after digest에 더해 top-level metadata digest와 대규모 package-set bulk evidence를 결합하며, 선행 `nanoid@3.3.18` 보안 수정과 explicit `npm ci --legacy-peer-deps=false --install-links=false` 계약을 보존한다. package-manager/toolchain·install-script authority·vulnerability audit·review/merge authority는 별도 증거 계층으로 유지한다.