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
56 changes: 56 additions & 0 deletions .github/workflows/python-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,53 @@ jobs:
- uses: astral-sh/setup-uv@v6
- run: uv run --quiet --frozen --no-dev --project plugins/capability/darrow-tickets-github/backend python plugins/capability/darrow-tickets-github/backend/tests/fresh_install.py

decisions-windows:
name: Decisions Python ${{ matrix.python-version }} on windows-latest
needs: changes
if: contains(needs.changes.outputs.packages, 'plugins/foundation/darrow-decisions/backend')
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
runs-on: windows-latest
defaults:
run:
shell: bash
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: scripts/check-python --package plugins/foundation/darrow-decisions/backend

decisions-fresh-install:
name: Fresh install decisions on ${{ matrix.os }}
needs: changes
if: contains(needs.changes.outputs.packages, 'plugins/foundation/darrow-decisions/backend')
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: astral-sh/setup-uv@v6
- run: uv run --quiet --frozen --no-dev --project plugins/foundation/darrow-decisions/backend python plugins/foundation/darrow-decisions/backend/tests/fresh_install.py
- name: Verify decision CLI regression scenarios
if: runner.os != 'Windows'
shell: bash
run: |
/bin/bash --version
/bin/bash plugins/foundation/darrow-decisions/backend/tests/decision.test.sh

inventory:
name: Python inventory guard
runs-on: ubuntu-latest
Expand Down Expand Up @@ -376,6 +423,8 @@ jobs:
- git-fresh-install
- tickets-windows
- tickets-fresh-install
- decisions-windows
- decisions-fresh-install
- inventory
- skill-authoring-fresh-install
- observability-fresh-install
Expand Down Expand Up @@ -409,6 +458,9 @@ jobs:
TICKETS_SELECTED: ${{ contains(needs.changes.outputs.packages, 'plugins/capability/darrow-tickets-github/backend') }}
TICKETS_WINDOWS_RESULT: ${{ needs.tickets-windows.result }}
TICKETS_INSTALL_RESULT: ${{ needs.tickets-fresh-install.result }}
DECISIONS_SELECTED: ${{ contains(needs.changes.outputs.packages, 'plugins/foundation/darrow-decisions/backend') }}
DECISIONS_WINDOWS_RESULT: ${{ needs.decisions-windows.result }}
DECISIONS_INSTALL_RESULT: ${{ needs.decisions-fresh-install.result }}
run: |
scripts/verify-python-quality-results \
"$CHANGES_RESULT" "$INVENTORY_RESULT" \
Expand All @@ -428,3 +480,7 @@ jobs:
if [ "$TICKETS_SELECTED" = true ]; then expected=success; fi
test "$TICKETS_WINDOWS_RESULT" = "$expected"
test "$TICKETS_INSTALL_RESULT" = "$expected"
expected=skipped
if [ "$DECISIONS_SELECTED" = true ]; then expected=success; fi
test "$DECISIONS_WINDOWS_RESULT" = "$expected"
test "$DECISIONS_INSTALL_RESULT" = "$expected"
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ bun run check:python

`check:decisions` rejects missing or stale checked-in ADR catalog data. When
ADRs change, refresh it first with
`bash plugins/foundation/darrow-decisions/bin/decision catalog rebuild --repo .`.
`uv run --quiet --frozen --no-dev --project plugins/foundation/darrow-decisions/backend darrow-decision catalog rebuild --repo .`.
`check:python` is required when Python source, tests, package metadata, or locks
change; its standards and package inventory are defined in
[`docs/specs/python-quality.md`](docs/specs/python-quality.md).
Expand Down
38 changes: 24 additions & 14 deletions docs/specs/decision-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,9 @@ canonical `Summary`, `Context`, `Decision`, and `Consequences`. `Supersedes`,

## Deterministic decision facade

The plugin ships a Bash `decision` facade at `bin/decision`. It inventories ADRs,
allocates the next identifier, validates structure and relationships, checks
The plugin ships a contained Python + UV `darrow-decision` facade, invoked with
`uv run --quiet --frozen --no-dev --project <plugin-root>/backend darrow-decision`.
It inventories ADRs, allocates the next identifier, validates structure and relationships, checks
transitions, and emits bounded model-facing output. It does not decide whether a
choice is authoritative, material, or ADR-worthy.

Expand All @@ -128,17 +129,18 @@ choice is authoritative, material, or ADR-worthy.
characters are refused rather than followed or silently omitted.
- **DM-14 — Collision-safe numbering.** The next identifier follows the highest
numeric `ADR-NNNN` identifier found in filenames or headings and preserves at
least four digits. Numeric identifiers are limited to 18 digits so Bash can
compare them without overflow; wider identifiers and exhaustion of that range
are explicit errors. Duplicate identifiers, filename/heading disagreement,
least four digits. Numeric identifiers retain the public 18-digit limit;
wider identifiers and exhaustion of that range are explicit errors.
Duplicate identifiers, filename/heading disagreement,
and an already occupied proposed path are errors.
- **DM-15 — Structural validation.** Every ADR file must be readable and have a
supported identifier, non-empty title, supported status, ISO date, exactly
one non-empty canonical `Summary` metadata field, and non-empty `Context`,
`Decision`, and `Consequences` sections. Required input
that exists but is unreadable causes a clear refusal rather than being
skipped. Under the facade's `LC_ALL=C` byte semantics, ADR titles are capped
at 240 bytes, status and date fields at 32 bytes each, `Summary` at 500 bytes,
skipped. Metadata limits count UTF-8 bytes, preserving the facade's byte
semantics. ADR titles are capped at 240 bytes, status and date fields at
32 bytes each, `Summary` at 500 bytes,
`Supersedes` and
`Superseded by` at 1,000 bytes each and 50 targets per field, and `Revisit
when` at 500 bytes.
Expand All @@ -152,10 +154,13 @@ when` at 500 bytes.
repository with no symlink component and emits its verified absolute path for
model-facing reports. The skills make deliberate repository edits with the
harness's ordinary file tools, then use the facade to verify them.
- **DM-18 — Portable shell behavior.** The facade and tests support Bash 5 and
macOS `/bin/bash` 3.2, remain bounded on large input, and follow the repository
shell-portability rules. Inventory and relationship validation are linear in
records plus relationships subject to documented metadata and output caps.
- **DM-18 — Native portable behavior.** The contained Python package supports
Python 3.10–3.13 on native Windows, Linux, and macOS, without runtime Bash
shims or sibling-plugin dependencies. Commands, records, diagnostics, and exit
codes remain stable. Filesystem operations use native paths; catalog writes
close and flush a sibling temporary file before atomic replacement. Inventory
and relationship validation are linear in records plus relationships subject
to documented metadata and output caps.
CLI title input is capped at 200 bytes, search input at 200 bytes, list output
at 200 records, and validation output at 40 diagnostics plus one omitted-count
line. `canonical-path --path` input is capped at 1,000 bytes. Paths and
Expand Down Expand Up @@ -188,9 +193,9 @@ when` at 500 bytes.
complete Markdown catalog atomically in byte-stable order, and `catalog check`
verifies its format, directory membership, fingerprints, visible metadata,
and source ADR validity.
Rebuilding an unchanged ADR set produces identical bytes under Bash 3.2 and
Bash 5. Repository validation includes the same freshness check, so CI and
manual validation reject drift. The catalog remains local to its ADR owner and
Rebuilding an unchanged ADR set produces identical UTF-8 bytes on every
supported platform. Repository validation includes the same freshness check,
so CI and manual validation reject drift. The catalog remains local to its ADR owner and
never catalogs specifications, policies, work items, review state, or another
owner surface.

Expand All @@ -205,6 +210,11 @@ that is unavailable, report the owner and stop without creating a substitute.

### Invariants

- **DM-C0 — Route requested captures before judging authority.** A request to
capture or accept a decision selects this capability even when the choice is
unresolved or its authority is missing. Those facts determine whether to ask
or refuse within the capability; they do not make the request a different
intent.
- **DM-C1 — Search before mutation.** Inspect the repository and search related
ADRs, specifications, policies, and work-item context before creating or
changing a record. Reported root surfaces are discovery candidates, not an
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"scripts": {
"check:docs": "bun scripts/check-docs.ts",
"check:docs:external": "bun scripts/check-docs.ts --external",
"check:decisions": "bash plugins/foundation/darrow-decisions/bin/decision validate --repo .",
"check:decisions": "uv run --quiet --frozen --no-dev --project plugins/foundation/darrow-decisions/backend darrow-decision validate --repo .",
"check:python": "scripts/check-python",
"check:python:inventory": "scripts/test-python-inventory",
"check:python:performance": "plugins/capability/darrow-observability-langfuse/tests/performance.test.sh",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "darrow-decisions",
"description": "Capture and query durable repository decisions at their canonical scope",
"version": "0.1.6",
"version": "0.2.0",
"license": "BUSL-1.1"
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "darrow-decisions",
"version": "0.1.6",
"version": "0.2.0",
"description": "Capture and query durable repository decisions at their canonical scope",
"author": {
"name": "Björn Rochel",
Expand Down
15 changes: 9 additions & 6 deletions plugins/foundation/darrow-decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ queries while reporting each authoritative effect only once.

Example: _“What have we decided about authentication?”_

### `bin/decision`
### `darrow-decision`

A portable Bash facade shared by both skills. It discovers ADRs, validates
A contained Python package shared by both skills. It discovers ADRs, validates
their structure and relationships, allocates collision-safe identifiers, and
supports deterministic inspection and filtering. A checked-in Markdown
`README.md` beside the ADRs catalogs every record's canonical Summary, status,
Expand All @@ -42,9 +42,9 @@ something is a decision and where it belongs.
Build and verify the derived ADR-only catalog with:

```sh
bash bin/decision catalog rebuild --repo /absolute/path/to/repository
bash bin/decision catalog check --repo /absolute/path/to/repository
bash bin/decision validate --repo /absolute/path/to/repository
uv run --quiet --frozen --no-dev --project backend darrow-decision catalog rebuild --repo /absolute/path/to/repository
uv run --quiet --frozen --no-dev --project backend darrow-decision catalog check --repo /absolute/path/to/repository
uv run --quiet --frozen --no-dev --project backend darrow-decision validate --repo /absolute/path/to/repository
```

Rebuilds are byte-deterministic and atomic. `validate` rejects a stale checked-in
Expand Down Expand Up @@ -76,7 +76,10 @@ Capture one explicit decision or find existing decisions. Do not infer accepted

## Hosts and prerequisites

Codex and Claude Code; Bash, baseline Unix tools, and access to authoritative decision records.
Codex and Claude Code; UV, Python 3.10–3.13, Git, and access to authoritative
decision records. The CLI runs natively on Windows, Linux, and macOS. Runtime
dependencies are empty; quality tools are locked in the development group.
The plugin has no Bash runtime entrypoint or sibling-plugin dependency.

## Installation

Expand Down
57 changes: 57 additions & 0 deletions plugins/foundation/darrow-decisions/backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
[project]
name = "darrow-decisions"
version = "0.2.0"
description = "Deterministic ADR mechanics for Darrow decision skills"
requires-python = ">=3.10,<3.14"
dependencies = []

[project.scripts]
darrow-decision = "darrow_decisions.cli:entrypoint"

[dependency-groups]
dev = [
"coverage[toml]>=7.10,<8",
"hypothesis>=6.138,<7",
"mypy>=1.18,<2",
"pytest>=8.4,<9",
"ruff>=0.12,<1",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv]
default-groups = []

[tool.hatch.build.targets.wheel]
packages = ["src/darrow_decisions"]

[tool.ruff]
target-version = "py310"
line-length = 88
src = ["src", "tests"]

[tool.ruff.lint]
select = ["B", "C4", "C90", "E4", "E7", "E9", "F", "I", "N", "RUF", "SIM", "UP"]
mccabe.max-complexity = 5

[tool.ruff.format]
docstring-code-format = true

[tool.mypy]
python_version = "3.10"
strict = true
files = ["src", "tests"]

[tool.pytest.ini_options]
addopts = ["--strict-config", "--strict-markers"]
testpaths = ["tests"]

[tool.coverage.run]
branch = true
source = ["darrow_decisions"]

[tool.coverage.report]
show_missing = true
skip_covered = false
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Contained deterministic ADR mechanics."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Strict command options with the existing usage and diagnostic interface."""

from dataclasses import dataclass, field

from .errors import DecisionError
from .model import CONTROL, STATUSES, byte_length

USAGE = """usage: decision inspect [--dir <path>] [--repo <path>]
decision list [--dir <path>] [--repo <path>]
[--status Proposed|Accepted|Rejected|Deprecated|Superseded]
[--search <text>] [--related-to <ADR-NNNN>] [--limit <n>]
decision next-id [--dir <path>] [--repo <path>] [--title <title>]
decision validate [--dir <path>] [--repo <path>]
decision catalog rebuild|check [--dir <path>] [--repo <path>]
decision canonical-path --path <path> [--repo <path>]
decision check-transition --from <status> --to <status>"""

OPTIONS = {
"inspect": ("repo", "dir"),
"validate": ("repo", "dir"),
"catalog": ("repo", "dir"),
"list": ("repo", "dir", "status", "search", "related-to", "limit"),
"next-id": ("repo", "dir", "title"),
"canonical-path": ("repo", "path"),
"check-transition": ("from", "to"),
}


@dataclass(frozen=True)
class Arguments:
command: str
action: str = ""
values: dict[str, str] = field(default_factory=dict)

def get(self, name: str, default: str = "") -> str:
return self.values.get(name, default)


def parse_arguments(argv: list[str]) -> Arguments:
if not argv or argv[0] not in OPTIONS:
raise DecisionError(USAGE)
command, *rest = argv
action = ""
if command == "catalog":
if not rest or rest[0] not in ("rebuild", "check"):
raise DecisionError(USAGE)
action, *rest = rest
values = parse_options(command, rest)
required = {"canonical-path": ("path",), "check-transition": ("from", "to")}.get(
command, ()
)
if any(key not in values for key in required):
raise DecisionError(USAGE)
validate_values(values)
return Arguments(command, action, values)


def parse_options(command: str, rest: list[str]) -> dict[str, str]:
if len(rest) % 2:
raise DecisionError(USAGE)
values = {}
allowed = {"--" + name for name in OPTIONS[command]}
for flag, value in zip(rest[::2], rest[1::2], strict=True):
if flag not in allowed:
raise DecisionError(USAGE)
values[flag[2:]] = value
return values


def validate_values(values: dict[str, str]) -> None:
for name in (
"repo",
"dir",
"status",
"search",
"related-to",
"title",
"path",
"from",
"to",
):
if CONTROL.search(values.get(name, "")):
raise DecisionError(f"--{name} contains unsupported control bytes")
if byte_length(values.get("path", "")) > 1000:
raise DecisionError("--path must be at most 1000 bytes")


def check_transition(arguments: Arguments) -> None:
old, new = arguments.get("from"), arguments.get("to")
if not old or not new:
raise DecisionError(USAGE)
for value in (old, new):
if value not in STATUSES:
raise DecisionError(f"unsupported decision status: {value}")
allowed = {
("Proposed", "Accepted"),
("Proposed", "Rejected"),
("Accepted", "Deprecated"),
("Accepted", "Superseded"),
}
if (old, new) not in allowed:
raise DecisionError(f"unsupported decision transition: {old} -> {new}", 3)
print(f"{old} -> {new}: allowed")
Loading
Loading