Skip to content

feat(harness): add QuestionResolver Protocol + --interactive CLI flag (stacked on #365) - #367

Merged
mlieberman85 merged 2 commits into
darnitdevorg:mainfrom
mlieberman85:027-interactive-resolvers
Aug 13, 2026
Merged

feat(harness): add QuestionResolver Protocol + --interactive CLI flag (stacked on #365)#367
mlieberman85 merged 2 commits into
darnitdevorg:mainfrom
mlieberman85:027-interactive-resolvers

Conversation

@mlieberman85

Copy link
Copy Markdown
Contributor

STACKED ON #365. Do not merge before #365. Until #365 lands on main, this PR's diff includes all of #365's changes plus feature 027. After #365 merges, this branch will be rebased onto main and the diff will collapse to feature 027 only.

Summary

Adds --interactive to darnit harness and a new QuestionResolver Protocol that sits downstream of feature 026's AnswerSource chain. Where AnswerSource is passive preloaded-value lookup, QuestionResolver is 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

  • New CLI flag: darnit harness --interactive prompts the operator at /dev/tty for any feedback question not covered by --answers or .project/project.yaml. Fail-fast (<2s, exit 2) when stdin is not a TTY OR /dev/tty is not openable.
  • New Protocol: darnit.harness.question_resolvers.QuestionResolver -- third-party resolver authors can register via Python entry points under darnit.question_resolvers (matches the existing darnit.frameworks discovery pattern).
  • Extended report: per-question resolution_trail captures which resolvers were offered a question and how each responded (answered / skipped / errored). New answered_feedback list surfaces per-answer origin + authority.

Constitution notes

  • IV (Never Guess User Values): reinforced. Answer.authority is a Literal[\"asserted\"] with a fixed default -- resolver authors physically cannot construct an Answer with a different authority (Pydantic ValidationError at construction). Every answer flowing through this feature carries authority: \"asserted\".
  • Feature 026's "no re-audit after collect" MVP policy preserved. A resolver-supplied answer is captured but does NOT silently promote a FAIL to PASS.

What's not in scope

  • No re-audit on interactive input (deferred; feature 026's MVP policy applies unchanged).
  • Alternative prompt output channels beyond /dev/tty (FR-004a designs the seam; specific event/log sinks are future features).
  • Windows terminal support (FR-004a's pluggable channel accommodates it later without a Protocol change).

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.
  • Manual smoke: uv run darnit harness /path/to/repo --interactive --level 3 on a repo with at least three pending questions. Verify:
    • Ordinary [N/M] progress lines on stderr during audit.
    • Exactly ONE harness: starting interactive collection (N pending questions) line before the first prompt.
    • Prompts appear on the terminal (via /dev/tty), NOT interleaved with stderr.
    • Each prompt has [N of M] position, control_id, question text, optional Help line, and > chevron.
    • Ctrl+C preserves already-collected answers; report is still assembled.
    • Exactly ONE harness: finished interactive collection: X answered, Y skipped, Z aborted line after the last prompt.
  • Manual smoke: echo | uv run darnit harness /path/to/repo --interactive -- expect exit 2 in <2s with stderr summary containing interactive channel unavailable (stdin is not a TTY).
  • Manual smoke: install the fixture package (uv pip install -e tests/darnit/harness/fixtures/mock_resolver_pkg), run the harness with --interactive on a repo with pending questions -- verify the resolvers-configured log line lists both interactive_terminal and mock_answer / mock_error.

Load-bearing safety properties (test-enforced)

  • SC-002 extensibility: tests/darnit/harness/test_extensibility_sc002.py imports a resolver from a fixture package OUTSIDE packages/darnit/src/darnit/harness/, injects it, asserts it's invoked. Fails if the Protocol seam breaks.
  • SC-003 asserted authority: 4 tests across test_question_resolvers.py, test_driver.py, test_resolution_trail.py, test_report.py. Includes a Pydantic-level test that constructing Answer(authority=\"dispositive\") raises ValidationError.
  • SC-005 fail-fast: two CLI variants (non-TTY stdin, /dev/tty unavailable) both exit 2 in <2s.
  • FR-013 no-values-in-logs: driver test uses a distinctive-string mock resolver; asserts the value never appears in any darnit.harness log record.
  • RT-10 redaction: _LeakingErrorResolver raises with an embedded sk-ant-* string; the trail's error_summary contains [REDACTED_ANTHROPIC_KEY], not the literal.

Follow-ups (not in this PR)

  • Alternative prompt output channels (FR-004a seam only, no implementation).
  • Re-audit on interactive input.
  • Windows / non-POSIX terminal path.
  • Persistence + Constitution IV confirmation timestamp / by-whom (would live in a future "persist interactive answers" feature).

Rebase plan

mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 12, 2026
…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 pxp928 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

driver.py:460

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

driver.py:781

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

driver.py:578

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_streams requires both injected streams, but contract IR-23 says /dev/tty must not be opened when either is provided. Single-stream injection silently talks to the real terminal, or raises on CI.
  • report.py:39PendingFeedbackEntry never gains the answered / answer / answer_authority fields required by this PR's own RT-2a, RT-7, RT-9a and tasks.md T019. Consumers filtering on answer_authority == "asserted" find nothing.
  • cli.py:850 — the new answered field changes the exit-summary field order locked by the 026 CLI-13 contract, and that contract file isn't updated. Breaks WARN, (\d+) pending-style CI parsers.
  • driver.py:594except TimeoutError sits before except Exception, so a resolver raising a built-in TimeoutError is mislabelled as a harness timeout. The trail reads resolver timed out after Nones and the real error is dropped.
  • driver.py:588 — unguarded resolver.name inside the exception handlers. Direct injection bypasses the Protocol isinstance check, so a handled resolver error can become an unhandled AttributeError → exit 3, which also skips the close() 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".

@mlieberman85
mlieberman85 force-pushed the 027-interactive-resolvers branch from c10e83b to 559aef4 Compare August 13, 2026 14:48
mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 13, 2026
…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.
mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 13, 2026
…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.
mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 13, 2026
…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.
mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 13, 2026
…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.
@mlieberman85
mlieberman85 force-pushed the 027-interactive-resolvers branch from 3d2831b to db7ec67 Compare August 13, 2026 16:37
mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 13, 2026
…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.
mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 13, 2026
…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.
mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 13, 2026
…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.
mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 13, 2026
…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.
@mlieberman85
mlieberman85 force-pushed the 027-interactive-resolvers branch from db7ec67 to 9be475a Compare August 13, 2026 20:08
@mlieberman85
mlieberman85 merged commit cbc2ab7 into darnitdevorg:main Aug 13, 2026
8 checks passed
mlieberman85 added a commit to mlieberman85/darnit that referenced this pull request Aug 14, 2026
…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.
mlieberman85 added a commit that referenced this pull request Aug 14, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants