Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"feature_directory": "specs/026-darnit-harness"}
{"feature_directory": "specs/027-interactive-resolvers"}
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ else:
- Filesystem only. Composition is resolved in-memory at framework-config load time; no new persistent state. (013-plugin-composition)

## Recent Changes
- 027-interactive-resolvers: adds `--interactive` flag to `darnit harness` and a new `QuestionResolver` Protocol (async, `@runtime_checkable`) that sits downstream of feature 026's `AnswerSource` chain. `InteractiveTerminalResolver` reference implementation prompts on `/dev/tty` (isolated from stdout report / stderr progress streams). Third-party resolvers register via Python entry points under group `darnit.question_resolvers` (mirrors `darnit.frameworks` discovery). Every `Answer` carries `authority: "asserted"` enforced at the model layer via `Literal["asserted"]` with a fixed default. Per-question `resolution_trail` in the report captures which resolvers were offered a question and how each responded (`answered`/`skipped`/`errored`). Fail-fast (<2s) when stdin is not a TTY OR /dev/tty is not openable under `--interactive`. Feature 026's "no re-audit after collect" MVP policy preserved.
- 026-darnit-harness: adds `darnit harness` subcommand -- end-to-end audit driver with in-band LLM dispatch (fleet-operator + CI-integrated persona). Consumes `ANTHROPIC_API_KEY` from env; dispatches PENDING_LLM results via `PydanticAILLMStep`. Non-interactive by default; batch answers via pluggable `AnswerSource` Protocol with auto-discovery of `.project/project.yaml` + `--answers` override. Markdown + JSON reports. Four documented exit codes (0/1/2/3) plus grep-able stderr summary. New `darnit.harness` subpackage (`driver`, `answer_sources`, `report`, `exit_codes`).
- 025-rfc0001-stage1: RFC-0001 Stage 1. Adds `authority` (`dispositive`|`suggestive`|`asserted`) to every step + result; per-phase Check execution rule ensures only dispositive/asserted results conclude a control (LLM output alone cannot manufacture a PASS). New `darnit.core.action_plan` module exposes `next_action`/`submit_result` as a public typed protocol; `agent.graph.route()` becomes a thin adapter. MCP surface adds `run_next_action`/`submit_action_result` tools (client-owned state). Baseline attestation predicate gains a per-result `authority` field additively within v1. `pydantic-ai-slim[anthropic]` becomes a required runtime dep.
- 024-cmd-run-e2e-tests: E2E baseline for `darnit run` pinning header/footer/count/exit-code contract; used as the mechanical regression guarantee for Stage 1's `cmd_run` code path.
Expand All @@ -378,5 +379,5 @@ else:
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan:
[`specs/026-darnit-harness/plan.md`](specs/026-darnit-harness/plan.md)
[`specs/027-interactive-resolvers/plan.md`](specs/027-interactive-resolvers/plan.md)
<!-- SPECKIT END -->
7 changes: 7 additions & 0 deletions packages/darnit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ darnit = "darnit.cli:main"
# Framework plugins register here
# Example: openssf-baseline = "darnit_baseline:get_framework_path"

[project.entry-points."darnit.question_resolvers"]
# Feature 027: QuestionResolver plugins register here. Entry-point declarations
# are LAZY -- Python only loads the target module when a caller invokes
# `importlib.metadata.entry_points(group=...).load()`. Missing / broken entry
# points are logged and skipped at discovery time, never crash the harness.
interactive_terminal = "darnit.harness.interactive_resolver:build"

[project.optional-dependencies]
attestation = [
"sigstore>=3.0.0",
Expand Down
91 changes: 89 additions & 2 deletions packages/darnit/src/darnit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,30 @@ def cmd_harness(args: argparse.Namespace) -> int:
output_format = getattr(args, "format", "markdown")
output_path = getattr(args, "output", None)
answers_path = getattr(args, "answers", None)
interactive = getattr(args, "interactive", False)
per_resolver_timeout_s = getattr(args, "per_resolver_timeout", None)

# Feature 027: --interactive fail-fast guard (IR-7..IR-9 / SC-005).
# Must run BEFORE any control iteration so a CI misfire never silently
# skips every question.
if interactive:
if not sys.stdin.isatty():
_emit_exit_summary(
"setup_error, interactive channel unavailable "
"(stdin is not a TTY)",
HarnessExitCode.SETUP_ERROR,
)
return int(HarnessExitCode.SETUP_ERROR)
try:
_tty_probe = open("/dev/tty", "r+", buffering=1) # noqa: SIM115
_tty_probe.close()
except OSError as exc:
_emit_exit_summary(
"setup_error, interactive channel unavailable "
f"(/dev/tty not openable: {exc.strerror or type(exc).__name__})",
HarnessExitCode.SETUP_ERROR,
)
return int(HarnessExitCode.SETUP_ERROR)

# Build the resolver via the explicit factory. Any AnswerSourceLoadError
# from a bad --answers file surfaces as a SETUP_ERROR.
Expand All @@ -765,6 +789,29 @@ def cmd_harness(args: argparse.Namespace) -> int:
_emit_exit_summary(f"setup_error, {exc}", HarnessExitCode.SETUP_ERROR)
return int(HarnessExitCode.SETUP_ERROR)

# Feature 027: build the QuestionResolver chain via entry-point discovery.
# PR #367 review Constitution IV fix: external resolvers stay out of the
# chain unless --allow-external-resolvers is set. An answer they produce
# is recorded with authority="asserted", so silent invocation would let
# any installed third-party package produce dispositive-strength values
# without operator opt-in.
allow_external_resolvers = getattr(args, "allow_external_resolvers", False)
try:
question_resolvers = HarnessRun.build_default_resolver_chain(
interactive=interactive,
allow_external_resolvers=allow_external_resolvers,
)
except HarnessSetupError as exc:
_emit_exit_summary(f"setup_error, {exc}", HarnessExitCode.SETUP_ERROR)
return int(HarnessExitCode.SETUP_ERROR)

if question_resolvers:
harness_logger = get_logger("harness")
harness_logger.info(
"harness: resolvers configured: %s",
[getattr(r, "name", "unknown") for r in question_resolvers],
)

run = HarnessRun(
local_path=repo_path,
framework_name=getattr(args, "framework", None),
Expand All @@ -773,6 +820,8 @@ def cmd_harness(args: argparse.Namespace) -> int:
llm_step=PydanticAILLMStep(),
per_call_timeout_s=getattr(args, "per_call_timeout", 60),
total_run_timeout_s=getattr(args, "total_run_timeout", 900),
question_resolvers=question_resolvers,
per_resolver_timeout_s=per_resolver_timeout_s,
)

try:
Expand Down Expand Up @@ -803,11 +852,18 @@ def cmd_harness(args: argparse.Namespace) -> int:
if not text.endswith("\n"):
sys.stdout.write("\n")

# Exit summary
# Exit summary. Order matches CLI-13 exactly:
# `complete, <P> PASS, <F> FAIL, <W> WARN, <PEND> pending, exit <N>`
# so CI parsers written against the contract keep working. Answered
# count is appended AFTER `pending`; the CLI-13 grep pattern is a
# prefix match on positional fields, so the extra trailing field is
# additive rather than positional-shifting. PR #367 review fix.
s = report.summary
answered_count = len(report.answered_feedback)
pending_count = len(report.pending_feedback)
_emit_exit_summary(
f"complete, {s.pass_} PASS, {s.fail} FAIL, {s.warn} WARN, "
f"{len(report.pending_feedback)} pending",
f"{pending_count} pending, {answered_count} answered",
HarnessExitCode(report.exit_class),
)
return report.exit_class
Expand Down Expand Up @@ -1185,6 +1241,37 @@ def create_parser() -> argparse.ArgumentParser:
default=900,
help="Total audit-run timeout in seconds (default: 900 = 15 min)",
)
harness_parser.add_argument(
"--interactive",
action="store_true",
help=(
"Prompt the operator at the terminal for any pending feedback "
"question not covered by --answers or .project/project.yaml. "
"Requires stdin to be a TTY and /dev/tty to be openable; fails "
"fast with exit 2 otherwise."
),
)
harness_parser.add_argument(
"--per-resolver-timeout",
type=float,
default=None,
help=(
"Per-resolver timeout in seconds. Default: no timeout. "
"Setting a global bound is usually inappropriate (an operator at "
"a terminal cannot be on the same clock as a webhook resolver)."
),
)
harness_parser.add_argument(
"--allow-external-resolvers",
action="store_true",
help=(
"Include third-party question resolvers registered under the "
"`darnit.question_resolvers` entry-point group. Off by default: "
"external resolvers produce authority='asserted' values, so "
"silently including them would violate Constitution Principle IV "
"(only human confirmation may assert)."
),
)
harness_parser.set_defaults(func=cmd_harness)

# install command
Expand Down
60 changes: 60 additions & 0 deletions packages/darnit/src/darnit/harness/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""darnit.harness: end-to-end audit driver (feature 026) + interactive resolvers (feature 027).

Public API surface -- re-exports from submodules so downstream consumers can
import from `darnit.harness` directly without knowing about internal layout.
"""

from darnit.harness.answer_sources import (
AnswerResolver,
AnswerSource,
FileAnswerSource,
ProjectYamlAnswerSource,
)
from darnit.harness.driver import (
HarnessRun,
HarnessRunTimeout,
HarnessSetupError,
)
from darnit.harness.exit_codes import HarnessExitCode
from darnit.harness.interactive_resolver import InteractiveTerminalResolver
from darnit.harness.question_resolvers import (
Answer,
InteractiveAborted,
QuestionResolver,
ResolutionTrailEntry,
)
from darnit.harness.report import (
AnsweredFeedbackEntry,
HarnessReport,
HarnessSummary,
PendingFeedbackEntry,
)
from darnit.harness.resolver_discovery import (
build_default_resolver_chain,
discover_registered_resolvers,
)

__all__ = (
# Feature 026: driver + report
"HarnessRun",
"HarnessRunTimeout",
"HarnessSetupError",
"HarnessExitCode",
"HarnessReport",
"HarnessSummary",
"PendingFeedbackEntry",
# Feature 026: answer sources
"AnswerResolver",
"AnswerSource",
"FileAnswerSource",
"ProjectYamlAnswerSource",
# Feature 027: question resolvers
"Answer",
"AnsweredFeedbackEntry",
"InteractiveAborted",
"InteractiveTerminalResolver",
"QuestionResolver",
"ResolutionTrailEntry",
"build_default_resolver_chain",
"discover_registered_resolvers",
)
Loading
Loading