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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "spectre-marketplace",
"metadata": {
"version": "1.2.0",
"version": "1.2.1",
"description": "Spectre — deterministic spec-driven Claude Code plugin (vision → spec → evaluate → lock → implement → verify)."
},
"owner": {
Expand All @@ -11,7 +11,7 @@
{
"name": "spectre",
"description": "Three-tier pre-lock spec evaluator + persistence-tier classifier. /vision, /implement, and /implement auto skills with action/verification gates and auto-routed Spectre-finding capture.",
"version": "1.2.0",
"version": "1.2.1",
"source": "./"
}
]
Expand Down
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,61 @@

All notable changes to the Spectre plugin.

## v1.2.1 — 2026-05-15

Defect-pack hotfix. Seven fixes addressing Tier-1 regex false-positives, walker stop-signal inconsistency, post-ship-iteration false-positive on zero-exemplar views, missing substitution evidence in the eval sidecar, and missing operator-mode flag on lock state. No spec contract changes — v1.0/v1.1/v1.1.1/v1.2.0 locked specs remain valid.

**Test count:** 1923 (v1.2.0) → 1967 (v1.2.1). +44 new tests, 0 regressions.

### Fixed — Tier-1 `_SQL_RE` + `_SHELL_EVAL_RE` lexical-context filtering (defects #1, #2)

- `_SQL_RE` no longer fires on Python/TS identifier collisions: `hashlib.update()`, `os.replace()`, `errors="replace"`, `.replace()`. Opener boundary tightened to require non-`.` predecessor; bare `UPDATE`/`REPLACE` now require SQL-shape continuation (`UPDATE <table> SET`, `REPLACE INTO|VALUES`). `INSERT INTO` and `DELETE FROM` are specific enough to stand alone.
- `_SHELL_EVAL_RE`'s `$(...)` branch no longer fires inside inert string literals (JSON bodies, log strings, argv elements). A new `_classify_and_strip_literals` pre-pass distinguishes **executable string payloads** (the argument of `bash -c`, `python3 -c`, `psql -c`, etc.) from **inert data**. Inert literals are masked before sink scanning; executable payloads remain verbatim so live `bash -c "DELETE FROM x"` still fires.
- Allowlisted executable-payload interpreters: `bash`, `sh`, `zsh`, `dash`, `python`, `python3`, `node`, `nodejs`, `perl`, `ruby`, `psql`, `mysql`, `sqlite3`. Conservative fallback on unbalanced quotes (returns original action; today's behavior preserved).
- 18 new tests in `tests/test_substrate_ast_lexical_filtering.py` covering 6 false-positives, 8 true-positives, and 4 boundary cases.

### Fixed — `_action_authored_path` accepts relative paths + `touch` (defect #3)

- Authoring-verb regexes (`tee`, `>`, `>>`, `cat >`, `cp`, `install`, plus new `touch`) now match relative paths when a project root is threaded through. The v1.2 Fix H `--project` flag is the carrier; relative authoring was previously rejected, forcing operators into `: > path` workarounds and false `self-cycle-produces` findings on natural idioms like `tee schemas/x.json`.
- **Workspace-boundary guard:** every authored path is normalized via `pathlib.Path.resolve()` and rejected if it escapes the project root. `../etc/passwd`, out-of-root absolute paths, and symlink escapes are not cleared — `self-cycle-produces` still fires for them, preventing false-clears that would mask real authoring problems.
- Backward-compatible: when called without a project root, only absolute paths are recognized (today's behavior).
- 10 new tests in `tests/test_spec_ast_relative_authoring.py` including an end-to-end integration test through `classify()`.

### Fixed — walker stop predicate unified (defect #4)

- New `_recommend_stop_predicate(state, draft_text)` function is the single source of truth for the walker stop signal. Previously the explicit `walker coverage` subcommand computed coverage without first calling `_refresh_pending`, producing `recommended-stop=no` despite `pending=0 deferred=0` when the draft had been edited externally after the last answer.
- Both the post-answer emission path and the explicit-coverage entrypoint now route through the predicate. The `walker coverage` subcommand also persists the refreshed pending set so subsequent reads see the same view.
- 3 new tests in `tests/test_walker_stop_predicate_consistency.py`.

### Fixed — `excessive-post-ship-iteration` zero-exemplar exception (defect #5)

- The aggregate check no longer penalizes operators for picking `post-ship-iteration` when a view has zero compatible exemplars in the catalog — the deferral was forced, not chosen.
- `post-ship-iteration-deferral` findings now carry a structured `reason` field (added to `findings.Finding` as a non-fingerprinted attribute): `"operator-deferral"` when compatible exemplars existed but the operator chose to defer, `"no-compatible-exemplar"` when the catalog was empty for the view's fingerprint.
- The aggregate `excessive-post-ship-iteration` warn counts only the operator-deferral subset. Empty-catalog deferrals get a different recovery hint pointing to catalog contribution (`docs/exemplars/<view>/<slug>.md`).
- 6 new tests in `tests/test_cross_view_gate_no_compatible_exception.py` including an end-to-end integration test that exercises both reasons.

### Added — substitution log in eval sidecar (defect #6)

- `eval_metadata.write_sidecar()` now accepts an optional `substitutions: list[dict]` kwarg. Each entry shape: `{"from": <old_text>, "to": <new_text>, "reason": <short>, "tier1_check_name": <kind>, "step_id": <step-N>}`.
- Logged when an agent rewrites action content or verification commands to satisfy a Tier-1 check — contemporaneous evidence, not a finding. Empty array when no rewrites; absent key when the caller doesn't supply the kwarg (back-compat).
- Forwarded by the `write-sidecar` CLI subcommand so Python and shell callers stay in sync.
- 4 new tests in `tests/test_eval_sidecar_substitutions.py`.

### Added — `operator_mode` flag on lock state (defect #7)

- Each lock entry in `state/.locks.json` now records `operator_mode: "interactive" | "auto"` so downstream audit/evidence can distinguish operator-driven locks from `/implement auto` runs.
- `supervisor.LockState.acquire()` accepts an `operator_mode` kwarg (default `"interactive"`); the supervisor's `acquire` request op accepts an `operator_mode` field (default `"interactive"`).
- Backward-compatible: pre-1.2.1 lock files that lack the field default to `"interactive"` on reconcile.
- 3 new tests in `tests/test_supervisor_operator_mode.py`.

### Surface bumps

- `.claude-plugin/marketplace.json` 1.2.0 → 1.2.1 (both `metadata.version` and `plugins[0].version`)
- `README.md` test-count badge: 1923 → 1967
- `README.md` version badge: 1.2.0 → 1.2.1
- `findings.Finding` gains a non-fingerprinted `reason: str | None` field
- `eval_metadata.write_sidecar` gains an optional `substitutions: list[dict] | None` kwarg

## v1.2.0 — 2026-05-15

Coverage + diagnostics minor release. 13 fixes across five classes surfaced by Vidence's v1.1 dogfooding. No spec contract changes — locked specs from v1.0/v1.1/v1.1.1 remain valid. v1.2 evaluator may surface additional `warn`/`info` findings on existing specs where new checks now apply.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

> Spectre — a deterministic spec-driven Claude Code plugin. Vision → Spec → Evaluate → Lock → Implement → Verify, with three-tier pre-lock review and per-project resource locking.

[![tests](https://img.shields.io/badge/tests-1923%20passing-brightgreen)](#tests) [![python](https://img.shields.io/badge/python-3.11%2B-blue)](#install) [![stdlib only](https://img.shields.io/badge/deps-stdlib%20only-blue)](#install) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![version](https://img.shields.io/badge/version-1.2.0-blue)](CHANGELOG.md)
[![tests](https://img.shields.io/badge/tests-1967%20passing-brightgreen)](#tests) [![python](https://img.shields.io/badge/python-3.11%2B-blue)](#install) [![stdlib only](https://img.shields.io/badge/deps-stdlib%20only-blue)](#install) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![version](https://img.shields.io/badge/version-1.2.1-blue)](CHANGELOG.md)

## Table of Contents

Expand Down Expand Up @@ -116,7 +116,7 @@ Full vocabulary registry: [`docs/glossary.md`](docs/glossary.md) (75+ status cod

Full reference — hooks, skills, spec step schema, sidecar format, layout, finding-kind taxonomy: [`docs/API.md`](docs/API.md).

**v1.2 components** — plugin `1.2.0` ([`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json)), `EVALUATOR_VERSION = "1.0.0"` ([`bin/spec_evaluator.py`](bin/spec_evaluator.py)), `WALKER_VERSION = "1.0.0"` ([`bin/walker.py`](bin/walker.py)). Walker state files persisted under v0.9 are rejected on load; remove `state/.walk.json` and re-run `/vision` to migrate (hard cutover from v0.9; no migration tool).
**v1.2 components** — plugin `1.2.1` ([`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json)), `EVALUATOR_VERSION = "1.0.0"` ([`bin/spec_evaluator.py`](bin/spec_evaluator.py)), `WALKER_VERSION = "1.0.0"` ([`bin/walker.py`](bin/walker.py)). Walker state files persisted under v0.9 are rejected on load; remove `state/.walk.json` and re-run `/vision` to migrate (hard cutover from v0.9; no migration tool).

**`spectre` CLI surface** — top-level wrapper resolves `${CLAUDE_PLUGIN_ROOT}`, exports `PYTHONPATH`, dispatches to `python3 -m bin.<subcommand>`:

Expand All @@ -133,7 +133,7 @@ Full reference — hooks, skills, spec step schema, sidecar format, layout, find
## Tests

```bash
pytest tests/ # 1923 tests, stdlib + pytest
pytest tests/ # 1967 tests, stdlib + pytest
pytest tests/ -v # verbose
pytest tests/test_spec_evaluator.py -v # single module
```
Expand Down
86 changes: 76 additions & 10 deletions bin/cross_view_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,31 @@ def _check_cross_view_references(
}


def _emit_deferral_finding(section: str) -> "_findings.Finding":
"""Return a post-ship-iteration-deferral info finding for the given view section."""
def _emit_deferral_finding(
section: str,
reason: str = "operator-deferral",
) -> "_findings.Finding":
"""Return a post-ship-iteration-deferral info finding for the given view section.

``reason`` distinguishes:
- ``operator-deferral`` (default) — the operator chose post-ship-iteration
despite compatible exemplars being available. Excessive deferrals across
views signal poor process; the aggregate check counts these.
- ``no-compatible-exemplar`` — the view's catalog has zero exemplars
matching the receiver-fingerprint. The deferral was forced, not chosen;
the aggregate check skips it. Recovery hint redirects to catalog
contribution rather than process correction.
"""
if reason == "no-compatible-exemplar":
suggested = (
f"§{section}'s catalog is empty for this fingerprint — consider "
f"contributing an exemplar via docs/exemplars/<view>/<slug>.md."
)
else:
suggested = (
f"Add a compatible exemplar to ~/.spectre/exemplars/ or the plugin "
f"catalog, then re-run the walker to bind §{section}."
)
return _findings.Finding(
tier=2,
kind="post-ship-iteration-deferral",
Expand All @@ -195,21 +218,57 @@ def _emit_deferral_finding(section: str) -> "_findings.Finding":
f"§{section} deferred exemplar selection to post-ship iteration — "
f"no catalog exemplar matched the view's receiver-fingerprint."
),
suggested_fix=(
f"Add a compatible exemplar to ~/.spectre/exemplars/ or the plugin catalog, "
f"then re-run the walker to bind §{section}."
),
suggested_fix=suggested,
reason=reason,
)


def _no_compatible_exemplar(
section: str,
view_fp: str | None,
catalog: "_catalog.Catalog",
) -> bool:
"""True iff the catalog has zero exemplars compatible with this view's fingerprint.

"Compatible" means the exemplar's view-type covers the section AND its
`calibrated_for` either is empty (any-match) or includes the view's
receiver-fingerprint. When the view has no declared fingerprint, fall back
to "view-type alone must match" — same coverage check.
"""
view_types = _VIEW_TO_CATALOG_TYPES.get(section, set())
if not view_types:
return False
for ex in catalog.exemplars.values():
if not (set(ex.view_types) & view_types):
continue
if not ex.calibrated_for or view_fp is None:
return False # any-match exemplar exists
if view_fp in ex.calibrated_for:
return False
return True


def _check_exemplar_bindings(
view_blocks: dict[str, str],
substrate_blocks: dict[str, str] | None = None,
) -> list[_findings.Finding]:
results: list[_findings.Finding] = []
catalog = _catalog.load_catalog()
fingerprints = (
_extract_receiver_fingerprints(substrate_blocks)
if substrate_blocks is not None
else {}
)
for section, block in view_blocks.items():
if _is_not_applicable(block):
continue
substrate_key = _VIEW_SECTION_TO_SUBSTRATE_KEY.get(section)
view_fp = fingerprints.get(substrate_key) if substrate_key else None
deferral_reason = (
"no-compatible-exemplar"
if _no_compatible_exemplar(section, view_fp, catalog)
else "operator-deferral"
)
# Parse taxonomy-version declarations (`taxonomy-version: help-text:1, error-text:1`)
spec_taxonomies: dict[str, int] = {}
for tv_match in _TAXONOMY_VERSION_RE.finditer(block):
Expand All @@ -228,7 +287,7 @@ def _check_exemplar_bindings(
# _EXEMPLAR_REF_RE — detect it with its own regex. Emit at most one deferral
# finding per section even if multiple style-keys are deferred.
if _POST_SHIP_RE.search(block):
results.append(_emit_deferral_finding(section))
results.append(_emit_deferral_finding(section, reason=deferral_reason))
_section_deferred = True
# Find exemplar bindings
for m in _EXEMPLAR_REF_RE.finditer(block):
Expand All @@ -244,7 +303,7 @@ def _check_exemplar_bindings(
# missing-catalog error. Skip if the section already emitted a deferral.
if raw_ref == "post-ship-iteration":
if not _section_deferred:
results.append(_emit_deferral_finding(section))
results.append(_emit_deferral_finding(section, reason=deferral_reason))
_section_deferred = True
continue
status, matches = _catalog.lookup_status(raw_ref)
Expand Down Expand Up @@ -460,7 +519,14 @@ def _check_excessive_post_ship_iteration(
fingerprint. More than one suggests a broader catalog structural gap —
the operator should file a catalog issue rather than deferring silently.
"""
count = sum(1 for f in findings if f.kind == "post-ship-iteration-deferral")
# Only count operator-chosen deferrals. Views with zero compatible
# exemplars (`reason == "no-compatible-exemplar"`) force the choice;
# penalizing the operator for the only valid option is the v1.2 defect.
count = sum(
1 for f in findings
if f.kind == "post-ship-iteration-deferral"
and (f.reason or "operator-deferral") == "operator-deferral"
)
if count > 1:
return [_findings.Finding(
tier=2,
Expand Down Expand Up @@ -498,7 +564,7 @@ def classify(spec_path: pathlib.Path) -> list[_findings.Finding]:
view_blocks = _extract_view_blocks(body)
results: list[_findings.Finding] = []
results.extend(_check_cross_view_references(view_blocks, substrate_blocks))
results.extend(_check_exemplar_bindings(view_blocks))
results.extend(_check_exemplar_bindings(view_blocks, substrate_blocks))
results.extend(_check_fingerprint_vs_hard_contract(body, substrate_blocks))
results.extend(_check_fingerprint_vs_exemplar(view_blocks, substrate_blocks))
# Aggregation pass — must run after all per-view checks have emitted.
Expand Down
16 changes: 16 additions & 0 deletions bin/eval_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ def write_sidecar(
contract_resolution: dict | None = None,
substrate_resolution: dict | None = None,
findings_inline: list[dict] | None = None,
substitutions: list[dict] | None = None,
) -> pathlib.Path:
"""Atomic write of <spec>.eval.json next to the spec file.

Expand Down Expand Up @@ -268,6 +269,12 @@ def write_sidecar(
through; if the payload already contains a ``contract_resolution`` key it
is forwarded automatically (see the CLI handler below). Python API callers
should pass the value from ``result.sidecar_payload.get("contract_resolution")``.

*substitutions* — v1.2.1 #6 evidence trail. When non-None, each entry is
a dict ``{"from": <old_text>, "to": <new_text>, "reason": <short>,
"tier1_check_name": <kind>, "step_id": <step-N>}`` recording an agent's
rewrite of action content or verification commands to satisfy a Tier-1
check. Not a finding — contemporaneous evidence the operator can audit.
"""
spec_path = pathlib.Path(spec_path)
sidecar_path = sidecar_path_for(spec_path)
Expand Down Expand Up @@ -308,6 +315,14 @@ def write_sidecar(
if findings_inline is not None:
payload["findings"] = findings_inline

# v1.2.1 #6: substitution evidence. Each entry shape:
# {"from": str, "to": str, "reason": str,
# "tier1_check_name": str, "step_id": str}
# Logged when an agent rewrites action content or verification commands
# to satisfy a Tier-1 check. Evidence, not a finding.
if substitutions is not None:
payload["substitutions"] = substitutions

# Atomic write: mkstemp + os.replace
fd, tmp = tempfile.mkstemp(
dir=sidecar_path.parent, prefix=sidecar_path.name, suffix=".tmp"
Expand Down Expand Up @@ -490,6 +505,7 @@ def write_envelope_alongside_sidecar(
contract_resolution=payload.get("contract_resolution"),
substrate_resolution=payload.get("substrate_resolution"),
findings_inline=payload.get("findings_inline"),
substitutions=payload.get("substitutions"),
)
except KeyError as exc:
_status.emit("error", "eval_metadata.sidecar_missing_field", dest="stderr",
Expand Down
6 changes: 6 additions & 0 deletions bin/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ class Finding:
# works even when the model omits the "missing: X;" message prefix.
# NOT included in fingerprint() — message text already excluded there.
target_artifact: str | None = None
# v1.2.1 — structured reason for findings that share a kind but have
# distinct semantic causes (e.g. `post-ship-iteration-deferral` may stem
# from an operator-chosen deferral or from a view with zero compatible
# exemplars). Lets aggregators count only the operator-deferral subset.
# NOT included in fingerprint() — same rationale as target_artifact.
reason: str | None = None

def __post_init__(self) -> None:
if self.kind not in KNOWN_KINDS:
Expand Down
Loading
Loading