diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 149326a..b179a5a 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -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": {
@@ -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": "./"
}
]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2496fa2..8d9940c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
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//.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": , "to": , "reason": , "tier1_check_name": , "step_id": }`.
+- 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.
diff --git a/README.md b/README.md
index 5db90e1..7cea738 100644
--- a/README.md
+++ b/README.md
@@ -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) [](#install) [](#install) [](LICENSE) [](CHANGELOG.md)
+[](#tests) [](#install) [](#install) [](LICENSE) [](CHANGELOG.md)
## Table of Contents
@@ -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.`:
@@ -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
```
diff --git a/bin/cross_view_gate.py b/bin/cross_view_gate.py
index c73cf72..3f0440a 100644
--- a/bin/cross_view_gate.py
+++ b/bin/cross_view_gate.py
@@ -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//.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",
@@ -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):
@@ -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):
@@ -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)
@@ -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,
@@ -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.
diff --git a/bin/eval_metadata.py b/bin/eval_metadata.py
index 61b196c..d1d8824 100644
--- a/bin/eval_metadata.py
+++ b/bin/eval_metadata.py
@@ -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 .eval.json next to the spec file.
@@ -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": , "to": , "reason": ,
+ "tier1_check_name": , "step_id": }`` 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)
@@ -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"
@@ -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",
diff --git a/bin/findings.py b/bin/findings.py
index c344e8b..c23d071 100644
--- a/bin/findings.py
+++ b/bin/findings.py
@@ -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:
diff --git a/bin/spec_ast.py b/bin/spec_ast.py
index d4528c5..41b524f 100644
--- a/bin/spec_ast.py
+++ b/bin/spec_ast.py
@@ -898,13 +898,46 @@ def _parse_mutates_paths(body: str) -> list[str]:
return paths
-def _action_authored_path(action: str) -> list[str]:
+# Accept both absolute and relative authoring paths. Relative paths require
+# a project root and must resolve inside it (workspace-boundary guard).
+_AUTHORED_PATH_CHARSET = r"[a-zA-Z0-9_./-]"
+
+
+def _path_inside_root(raw: str, project_root: pathlib.Path | None) -> str | None:
+ """Return the path if it resolves inside project_root, else None.
+
+ Absolute paths must be under project_root after resolve(). Relative paths
+ are resolved against project_root. If project_root is None (today's
+ behavior), only absolute paths are accepted unchanged and no containment
+ check runs — preserves API compatibility for callers that have not yet
+ threaded the project root through. `..` escapes and symlink-escapes are
+ rejected once a project_root is supplied.
+ """
+ if project_root is None:
+ return raw if raw.startswith("/") else None
+ try:
+ candidate = pathlib.Path(raw)
+ if not candidate.is_absolute():
+ candidate = project_root / candidate
+ resolved = candidate.resolve()
+ except (OSError, ValueError):
+ return None
+ root = project_root.resolve()
+ if resolved == root or root in resolved.parents:
+ return raw
+ return None
+
+
+def _action_authored_path(
+ action: str, project_root: pathlib.Path | None = None
+) -> list[str]:
"""Heuristic: return file paths that the action plausibly *creates/writes*.
Looks for:
- - heredoc targets: `cat > /path < path < /path or >> /path redirects (file writes)
+ - Any > path or >> path redirects (file writes)
+ - touch path (single-arg authoring verb; relative or absolute)
- CLI output flags: a token in _SELF_CYCLE_OUTPUT_OPTS means the next
non-flag token is an authored output destination. Also handles the
equals-form (--out=path).
@@ -913,24 +946,47 @@ def _action_authored_path(action: str) -> list[str]:
within them. Keeping mkdir paths out prevents false "authored" matches
when a subsequent step invokes a file inside the created directory.
+ Path scope:
+ - With ``project_root=None`` (today's behavior): only absolute paths are
+ recognized.
+ - With a project root provided: relative paths are resolved against it.
+ In both modes, paths that escape ``project_root`` (`../etc/passwd`,
+ out-of-root absolute paths, symlink-escapes) are rejected — the
+ workspace-boundary guard protects against false-clears of
+ ``self-cycle-produces``.
+
Returns a list of created/written file paths.
"""
created: list[str] = []
- # Redirect writes: > /path or >> /path
- for m in re.finditer(r"(?:>>?)\s*(/[a-zA-Z0-9_/.-]+)", action):
- created.append(m.group(1))
- # cat > /path <\s*(/[a-zA-Z0-9_/.-]+)", action):
- created.append(m.group(1))
- # tee /path
- for m in re.finditer(r"\btee\s+(/[a-zA-Z0-9_/.-]+)", action):
- created.append(m.group(1))
- # cp src /dest — destination is the last abs path
- for m in re.finditer(r"\bcp\s+\S+\s+(/[a-zA-Z0-9_/.-]+)", action):
- created.append(m.group(1))
- # install ... /dest
- for m in re.finditer(r"\binstall\b[^&|;]*\s(/[a-zA-Z0-9_/.-]+)(?:\s|$)", action):
- created.append(m.group(1))
+
+ def _add(raw: str) -> None:
+ guarded = _path_inside_root(raw, project_root)
+ if guarded is not None:
+ created.append(guarded)
+
+ cs = _AUTHORED_PATH_CHARSET
+ # Redirect writes: > path or >> path
+ for m in re.finditer(rf"(?:>>?)\s*({cs}+)", action):
+ _add(m.group(1))
+ # cat > path <\s*({cs}+)", action):
+ _add(m.group(1))
+ # tee path
+ for m in re.finditer(rf"\btee\s+({cs}+)", action):
+ _add(m.group(1))
+ # cp src dest — destination is the last path
+ for m in re.finditer(rf"\bcp\s+\S+\s+({cs}+)", action):
+ _add(m.group(1))
+ # install ... dest
+ for m in re.finditer(rf"\binstall\b[^&|;]*\s({cs}+)(?:\s|$)", action):
+ _add(m.group(1))
+ # touch path1 [path2 ...] — single-arg authoring verb. Touch creates the
+ # named file if absent, so it qualifies as authoring for self-cycle
+ # purposes. Multiple paths handled by greedy tokenisation.
+ for m in re.finditer(r"\btouch\s+([^\n&|;]+)", action):
+ for part in m.group(1).split():
+ if re.fullmatch(rf"{cs}+", part):
+ _add(part)
# CLI output flags: -o path, --out path, --out=path, etc.
# Tokenise the action to find flag → next-token pairs.
tokens: list[str] | None = None
@@ -985,6 +1041,7 @@ def _module_path_candidates(module: str) -> list[str]:
def _check_action_invokes_uncreated_artifact(
steps: list[dict],
mutates_paths: list[str],
+ project_root: pathlib.Path | None = None,
) -> list[_findings.Finding]:
"""Gap A: block if an action invokes an absolute path under mutates: that no
prior step authored.
@@ -1067,7 +1124,7 @@ def _check_action_invokes_uncreated_artifact(
))
# --- Now record what this step authors ---
- authored.extend(_action_authored_path(action))
+ authored.extend(_action_authored_path(action, project_root=project_root))
return results
@@ -1593,7 +1650,10 @@ def _action_token_matches_produces_path(token: str, produces_path: str) -> bool:
return produces_path == token or produces_path.endswith("/" + token.lstrip("/"))
-def _check_self_cycle_produces(steps: list[dict]) -> list[_findings.Finding]:
+def _check_self_cycle_produces(
+ steps: list[dict],
+ project_root: pathlib.Path | None = None,
+) -> list[_findings.Finding]:
"""Tier 1 block: a step's action consumes a path it also produces, with no
earlier step's produces: covering that path.
@@ -1618,7 +1678,7 @@ def _check_self_cycle_produces(steps: list[dict]) -> list[_findings.Finding]:
action_tokens = _extract_action_path_tokens(action)
# Exclude paths the action itself *writes* (redirect destinations,
# cp/install/tee targets) — those are outputs, not consumed inputs.
- authored = set(_action_authored_path(action))
+ authored = set(_action_authored_path(action, project_root=project_root))
this_produces_paths = _produces_file_paths(produces)
for tok in dict.fromkeys(action_tokens):
@@ -2238,11 +2298,18 @@ def _check_verification_depth(step: dict, step_n: int) -> list[_findings.Finding
)]
-def classify(spec_path: pathlib.Path) -> list[_findings.Finding]:
+def classify(
+ spec_path: pathlib.Path,
+ project_root: pathlib.Path | None = None,
+) -> list[_findings.Finding]:
"""Tier 1 deterministic classifier. Returns Finding list (possibly empty).
PURE parse/structure/tautology. Does NOT call bin.tier or bin.resources.
Budget: <100ms.
+
+ ``project_root`` (optional) enables relative-path authoring detection in
+ self-cycle and uncreated-artifact checks. Paths that resolve outside the
+ root are still treated as unauthored (workspace-boundary guard).
"""
text = spec_path.read_text(encoding="utf-8")
text = text.replace("\r\n", "\n").replace("\r", "\n") # CRLF/CR normalization
@@ -2325,7 +2392,9 @@ def classify(spec_path: pathlib.Path) -> list[_findings.Finding]:
# ── Check 6 (Gap A): action invokes uncreated artifact ───────────────────
mutates_paths = _parse_mutates_paths(body)
if mutates_paths:
- results.extend(_check_action_invokes_uncreated_artifact(steps, mutates_paths))
+ results.extend(_check_action_invokes_uncreated_artifact(
+ steps, mutates_paths, project_root=project_root
+ ))
# ── Check 7 (Gap C): unowned-requirement (e2e assertions) ────────────────
results.extend(_check_unowned_requirement(steps))
@@ -2334,7 +2403,7 @@ def classify(spec_path: pathlib.Path) -> list[_findings.Finding]:
results.extend(_check_negative_paths(steps, body))
# ── Check 9 (v0.8 §42): self-cycle produces ──────────────────────────────
- results.extend(_check_self_cycle_produces(steps))
+ results.extend(_check_self_cycle_produces(steps, project_root=project_root))
# ── Check 10 (v0.9 §46): implicit-precondition-missing ───────────────────
results.extend(_check_implicit_precondition_missing(steps))
diff --git a/bin/spec_evaluator.py b/bin/spec_evaluator.py
index adf2b3d..90fa796 100644
--- a/bin/spec_evaluator.py
+++ b/bin/spec_evaluator.py
@@ -597,6 +597,7 @@ def evaluate(
*,
config_path: pathlib.Path | None = None,
bundle_persist_dir: pathlib.Path | None = None,
+ project_root: pathlib.Path | None = None,
) -> EvaluatorResult:
"""Full pipeline: build bundle → Tier 1 → Tier 2 → Tier 3 (if config enables)
→ aggregate findings → compute max severity → persist bundle → return result.
@@ -624,7 +625,7 @@ def evaluate(
_persist_bundle(bundle, bundle_persist_dir)
# ── Step 3: Tier 1 ──────────────────────────────────────────────────────
- tier1_findings = _spec_ast.classify(draft_path)
+ tier1_findings = _spec_ast.classify(draft_path, project_root=project_root)
tier1_findings.extend(_substrate_ast.classify(draft_path))
# Tier 1.5 spec-author lints (v0.3.1): runuser-no-cd, unsafe-heredoc.
# Folded into Tier 1 results so callers see a single deterministic group.
@@ -886,7 +887,12 @@ def _resolve(p: str) -> pathlib.Path:
config_path = _resolve(args.config) if args.config else None
bundle_dir = _resolve(args.bundle_dir) if args.bundle_dir else None
try:
- result = evaluate(spec_path, config_path=config_path, bundle_persist_dir=bundle_dir)
+ result = evaluate(
+ spec_path,
+ config_path=config_path,
+ bundle_persist_dir=bundle_dir,
+ project_root=project_root,
+ )
except Exception as exc: # noqa: BLE001
_status.emit("error", "eval.run", dest="stderr", reason=str(exc),
remediation="check spec syntax then run 'spectre walker get-state' to inspect walk state")
diff --git a/bin/substrate_ast.py b/bin/substrate_ast.py
index c67c3ea..439ba06 100644
--- a/bin/substrate_ast.py
+++ b/bin/substrate_ast.py
@@ -274,8 +274,20 @@ def _trust_profile(body: str) -> set[str]:
r"|\beval\b"
r"|\$\("
)
+# SQL keyword opener must be preceded by start-of-string, whitespace, a shell
+# statement boundary, or a quote (start of a kept interpreter payload) — never
+# by `.` (which would indicate a method call like `hashlib.update()`,
+# `os.replace()`, `.replace()`). `UPDATE` and `REPLACE` are bare verbs that
+# collide with English ("update the index", "replace the value"), so they
+# additionally require a following SQL-shape: `UPDATE SET` or
+# `REPLACE INTO|VALUES`. `INSERT INTO` and `DELETE FROM` are specific enough
+# to stand alone.
_SQL_RE = re.compile(
- r"\b(?:INSERT|UPDATE|REPLACE|DELETE\s+FROM)\b", re.IGNORECASE
+ r"(?:^|(?<=[\s;|&(\"']))"
+ r"(?:INSERT\s+INTO|DELETE\s+FROM"
+ r"|UPDATE\s+\w+\s+SET"
+ r"|REPLACE\s+(?:INTO|VALUES))\b",
+ re.IGNORECASE,
)
_TEMPLATE_RE = re.compile(r"\b(?:jinja2|template_render|format_map)\b")
_NETWORK_EGRESS_RE = re.compile(
@@ -285,6 +297,76 @@ def _trust_profile(body: str) -> set[str]:
re.IGNORECASE,
)
+# Interpreters whose `-c` / `-e` argument is an executable code payload.
+# Inside a payload the sink regexes must still see the code (so a live
+# `bash -c "DELETE FROM x"` fires `_SQL_RE`). Outside a payload, an inert
+# literal — JSON body, log message, error string — is masked before scanning.
+_EXEC_PAYLOAD_INTRO_RE = re.compile(
+ r"\b(?:bash|sh|zsh|dash|python|python3|node|nodejs|perl|ruby|"
+ r"psql|mysql|sqlite3)\s+(?:-[A-Za-z]+\s+)*-[ce]\s*$"
+)
+_LITERAL_PLACEHOLDER = "\x01" # inert spans collapse to a single control char
+
+
+def _classify_and_strip_literals(action: str) -> str:
+ """Mask inert string-literal spans so sink regexes scan only live code.
+
+ Walks the action left-to-right. Each quoted span (``"..."``, ``'...'``,
+ ``\"\"\"...\"\"\"``, ``'''...'''``) is classified:
+
+ - *Executable* — the span is the argument of a recognized interpreter
+ `-c` / `-e` invocation (`bash -c`, `python3 -c`, `psql -c`, …). The
+ span body is kept verbatim because it is live code.
+ - *Inert* — JSON body, log string, error message, argv element. The
+ span body collapses to a placeholder so collision-prone tokens
+ inside data (``$(book)``, ``"update"``, ``"DELETE FROM"``) no longer
+ reach the sink scanners.
+
+ Conservative on unbalanced input: returns the original action unchanged
+ so coverage degrades to today's behavior instead of silently passing
+ dangerous payloads.
+ """
+ out: list[str] = []
+ i = 0
+ n = len(action)
+ while i < n:
+ ch = action[i]
+ if ch not in ("\"", "'"):
+ out.append(ch)
+ i += 1
+ continue
+ # Detect triple-quote first; longer-match-wins.
+ if i + 2 < n and action[i + 1] == ch and action[i + 2] == ch:
+ quote = ch * 3
+ else:
+ quote = ch
+ close = action.find(quote, i + len(quote))
+ if quote == ch: # single-char quote: honour backslash escapes
+ scan = i + 1
+ while scan < n:
+ c = action[scan]
+ if c == "\\" and scan + 1 < n:
+ scan += 2
+ continue
+ if c == ch:
+ close = scan
+ break
+ scan += 1
+ else:
+ close = -1
+ if close == -1:
+ return action # unbalanced — fall back to today's behavior
+ body_start = i + len(quote)
+ body = action[body_start:close]
+ prefix = "".join(out)
+ is_exec = bool(_EXEC_PAYLOAD_INTRO_RE.search(prefix))
+ if is_exec:
+ out.append(quote + body + quote)
+ else:
+ out.append(_LITERAL_PLACEHOLDER)
+ i = close + len(quote)
+ return "".join(out)
+
def _value_in_action(action: str, contract_entry: str) -> bool:
"""True if the contract entry's value substring appears in action."""
@@ -321,12 +403,13 @@ def _classify_source_step_sinks(action: str) -> list[str]:
def _sink_kinds_from_action(action: str) -> list[str]:
+ scanned = _classify_and_strip_literals(action)
sinks: list[str] = []
- if _SHELL_EVAL_RE.search(action):
+ if _SHELL_EVAL_RE.search(scanned):
sinks.append("shell-eval")
- if _SQL_RE.search(action) or _TEMPLATE_RE.search(action):
+ if _SQL_RE.search(scanned) or _TEMPLATE_RE.search(scanned):
sinks.append("sql-or-template")
- if _NETWORK_EGRESS_RE.search(action):
+ if _NETWORK_EGRESS_RE.search(scanned):
sinks.append("network-egress")
return sinks
diff --git a/bin/supervisor.py b/bin/supervisor.py
index 48a2447..fc1b2bf 100644
--- a/bin/supervisor.py
+++ b/bin/supervisor.py
@@ -53,30 +53,49 @@ def _atomic_write_json(path: Path, data: dict) -> None:
raise
+_VALID_OPERATOR_MODES = ("interactive", "auto")
+
+
class LockState:
"""In-memory + JSON-persisted lock state for one project."""
def __init__(self, locks_path: Path):
self.locks_path = Path(locks_path)
self.resources: dict[str, int] = {} # resource_id -> capacity
- # Internal 4-tuple storage: (track, pid, start_time, granted_at_iso)
- self._holders: dict[str, list[tuple[str, int, float, str]]] = {}
- self.queues: dict[str, list[tuple[str, int, float, str]]] = {}
+ # Internal 5-tuple storage: (track, pid, start_time, granted_at_iso, operator_mode)
+ # operator_mode (v1.2.1 #7) records whether the lock was acquired by
+ # an interactive /vision/walker session or an auto-mode /implement run.
+ # Downstream consumers (audit, evidence trail) can distinguish the two.
+ self._holders: dict[str, list[tuple[str, int, float, str, str]]] = {}
+ self.queues: dict[str, list[tuple[str, int, float, str, str]]] = {}
def register_resource(self, resource_id: str, capacity: int) -> None:
self.resources[resource_id] = capacity
self._holders.setdefault(resource_id, [])
self.queues.setdefault(resource_id, [])
- def acquire(self, resource_id: str, *, track: str, actor_pid: int, actor_start_time: float) -> bool:
+ def acquire(
+ self,
+ resource_id: str,
+ *,
+ track: str,
+ actor_pid: int,
+ actor_start_time: float,
+ operator_mode: str = "interactive",
+ ) -> bool:
if resource_id not in self.resources:
raise KeyError(f"unknown resource: {resource_id}")
+ if operator_mode not in _VALID_OPERATOR_MODES:
+ raise ValueError(
+ f"operator_mode must be one of {_VALID_OPERATOR_MODES}, got {operator_mode!r}"
+ )
granted_at = datetime.now(timezone.utc).isoformat()
+ entry = (track, actor_pid, actor_start_time, granted_at, operator_mode)
if len(self._holders[resource_id]) < self.resources[resource_id]:
- self._holders[resource_id].append((track, actor_pid, actor_start_time, granted_at))
+ self._holders[resource_id].append(entry)
self._persist()
return True
- self.queues[resource_id].append((track, actor_pid, actor_start_time, granted_at))
+ self.queues[resource_id].append(entry)
self._persist()
return False
@@ -129,19 +148,25 @@ def reconcile(self) -> None:
st = entry["actor_start_time"]
if _actor_alive(pid, st):
granted_at = entry.get("granted_at", datetime.now(timezone.utc).isoformat())
- self._holders[rid].append((entry["track"], pid, st, granted_at))
+ # v1.2.1 #7: default to interactive for pre-1.2.1 lock files
+ # that don't carry the field.
+ operator_mode = entry.get("operator_mode", "interactive")
+ self._holders[rid].append(
+ (entry["track"], pid, st, granted_at, operator_mode)
+ )
self._persist()
def _persist(self) -> None:
all_locks = []
for rid, holders in self._holders.items():
- for track, pid, st, granted_at in holders:
+ for track, pid, st, granted_at, operator_mode in holders:
all_locks.append({
"resource": rid,
"track": track,
"actor_pid": pid,
"actor_start_time": st,
"granted_at": granted_at,
+ "operator_mode": operator_mode,
})
_atomic_write_json(self.locks_path, {
"version": LOCK_FILE_VERSION,
@@ -156,11 +181,14 @@ def handle_request(state: LockState, req: dict[str, Any]) -> dict[str, Any]:
for k in ("track", "resource_id", "actor_pid", "actor_start_time"):
if k not in req:
return {"ok": False, "error": f"missing field: {k}"}
+ # v1.2.1 #7: operator_mode defaults to "interactive" so pre-1.2.1
+ # callers (and tests) keep working unchanged.
granted = state.acquire(
req["resource_id"],
track=req["track"],
actor_pid=req["actor_pid"],
actor_start_time=req["actor_start_time"],
+ operator_mode=req.get("operator_mode", "interactive"),
)
resp: dict[str, Any] = {"ok": True, "granted": granted}
if not granted:
diff --git a/bin/track.py b/bin/track.py
index b301b0c..519c49d 100644
--- a/bin/track.py
+++ b/bin/track.py
@@ -108,7 +108,13 @@ def _self_actor() -> tuple[int, float]:
return pid, float(after[19])
-def acquire(project_root: Path, *, track_name: str, resource_id: str) -> dict[str, Any]:
+def acquire(
+ project_root: Path,
+ *,
+ track_name: str,
+ resource_id: str,
+ operator_mode: str = "interactive",
+) -> dict[str, Any]:
pid, st = _self_actor()
return _send(project_root, {
"op": "acquire",
@@ -116,6 +122,7 @@ def acquire(project_root: Path, *, track_name: str, resource_id: str) -> dict[st
"resource_id": resource_id,
"actor_pid": pid,
"actor_start_time": st,
+ "operator_mode": operator_mode,
})
diff --git a/bin/walker.py b/bin/walker.py
index 9a487cb..c19c5a9 100644
--- a/bin/walker.py
+++ b/bin/walker.py
@@ -1253,6 +1253,22 @@ def _refresh_pending(state: WalkState, draft_text: str) -> None:
state.pending.extend(generate_operator_concerns(state, draft_text))
+def _recommend_stop_predicate(state: WalkState, draft_text: str) -> dict:
+ """Single source of truth for the walker stop signal.
+
+ Refreshes dynamic concerns from the current draft text, then computes the
+ coverage dict — including the ``recommended_stop`` boolean. Every
+ code path that reports or acts on the stop signal MUST call this rather
+ than computing coverage from a possibly-stale state snapshot.
+
+ Mutates ``state`` (via :func:`_refresh_pending`); callers that read the
+ result must persist the state if they want subsequent reads to see the
+ same view.
+ """
+ _refresh_pending(state, draft_text)
+ return _compute_coverage(state, draft_text)
+
+
def _compute_coverage(state: WalkState, draft_text: str) -> dict:
"""Compute coverage metrics for the current walk state.
@@ -2662,11 +2678,9 @@ def _drive_to_completeness_satisfied(
except OSError:
pass
- # Fire newly-applicable generators now that the draft may have evolved
- _refresh_pending(state, draft_text)
-
- # Coverage and recommend-stop transition
- cov = _compute_coverage(state, draft_text)
+ # Unified stop-signal predicate — refreshes generators and computes
+ # coverage from a single state snapshot.
+ cov = _recommend_stop_predicate(state, draft_text)
prev_emitted = state.last_recommend_stop_emitted
if cov["recommended_stop"] and not prev_emitted:
_status.emit("result", "walker.recommend-stop", reason="coverage-complete")
@@ -2721,7 +2735,7 @@ def _drive_to_completeness_satisfied(
)
sys.exit(1)
- # Compute and emit full coverage line
+ # Compute and emit full coverage line — unified predicate
draft_text = ""
draft_path_for_cov = pathlib.Path(args.draft) if getattr(args, "draft", None) else state.spec_draft_path
if draft_path_for_cov.exists():
@@ -2729,7 +2743,7 @@ def _drive_to_completeness_satisfied(
draft_text = draft_path_for_cov.read_text(encoding="utf-8")
except OSError:
pass
- cov = _compute_coverage(state, draft_text)
+ cov = _recommend_stop_predicate(state, draft_text)
_status.emit(
"result", "walker.coverage",
answered=cov["answered"],
@@ -2771,7 +2785,12 @@ def _drive_to_completeness_satisfied(
draft_text = draft_path_cov.read_text(encoding="utf-8")
except OSError:
pass
- cov = _compute_coverage(state, draft_text)
+ cov = _recommend_stop_predicate(state, draft_text)
+ # Persist refreshed pending so subsequent reads see the same view.
+ try:
+ persist(state, state_path)
+ except OSError:
+ pass # coverage is read-only-ish; persistence failure is non-fatal
if args.json_mode:
print(json.dumps(cov, indent=2))
diff --git a/tests/test_cross_view_gate_no_compatible_exception.py b/tests/test_cross_view_gate_no_compatible_exception.py
new file mode 100644
index 0000000..8c6a1ee
--- /dev/null
+++ b/tests/test_cross_view_gate_no_compatible_exception.py
@@ -0,0 +1,93 @@
+"""`excessive-post-ship-iteration` exception for zero-exemplar views (v1.2.1 #5).
+
+When a view's catalog has zero compatible exemplars, the operator is forced
+to pick `post-ship-iteration` — penalizing that choice is wrong. The
+deferral is tagged `reason="no-compatible-exemplar"` and the aggregate check
+counts only `operator-deferral` deferrals.
+"""
+from bin import cross_view_gate, findings
+
+
+def _deferral(section: str, reason: str) -> findings.Finding:
+ return cross_view_gate._emit_deferral_finding(section, reason=reason)
+
+
+def test_112_two_no_compatible_deferrals_do_not_trigger_aggregate_warn():
+ # Both views forced into post-ship-iteration due to empty catalog —
+ # operator had no choice; aggregate warn must NOT fire.
+ f1 = _deferral("9", "no-compatible-exemplar")
+ f2 = _deferral("10", "no-compatible-exemplar")
+ warns = cross_view_gate._check_excessive_post_ship_iteration([f1, f2])
+ assert warns == []
+
+
+def test_113_two_operator_deferrals_trigger_aggregate_warn():
+ # Both views had compatible exemplars available; operator chose to
+ # defer — aggregate warn fires as designed.
+ f1 = _deferral("9", "operator-deferral")
+ f2 = _deferral("10", "operator-deferral")
+ warns = cross_view_gate._check_excessive_post_ship_iteration([f1, f2])
+ assert len(warns) == 1
+ assert warns[0].kind == "excessive-post-ship-iteration"
+
+
+def test_114_mixed_deferrals_count_only_operator_chosen():
+ # One forced, two chosen — only the two operator-deferrals count.
+ f1 = _deferral("9", "no-compatible-exemplar")
+ f2 = _deferral("10", "operator-deferral")
+ f3 = _deferral("11", "operator-deferral")
+ warns = cross_view_gate._check_excessive_post_ship_iteration([f1, f2, f3])
+ assert len(warns) == 1
+
+
+def test_115_no_compatible_finding_carries_catalog_contribution_hint():
+ f = _deferral("9", "no-compatible-exemplar")
+ assert f.reason == "no-compatible-exemplar"
+ assert "contributing an exemplar" in (f.suggested_fix or "")
+
+
+def test_116_operator_deferral_finding_carries_catalog_install_hint():
+ f = _deferral("9", "operator-deferral")
+ assert f.reason == "operator-deferral"
+ assert "~/.spectre/exemplars/" in (f.suggested_fix or "")
+
+
+# ── End-to-end: gui-only fingerprint forces deferral, no excessive warn ─────
+
+def test_117_gui_only_fingerprint_emits_no_compatible_deferral_no_aggregate_warn(
+ tmp_path,
+):
+ """Spec where §8.5 uses gui-only but help-text catalog has only cli-*
+ exemplars. The §11 deferral is forced (no-compatible-exemplar) and the
+ aggregate warn does not fire even with two deferred views."""
+ from bin import _catalog, cross_view_gate
+ # Reset catalog cache so the live plugin catalog is loaded.
+ _catalog._LOAD_CACHE = None
+ body = (
+ "# X\n\n**Generated:** 2026-05-15\n**Slug:** x\n**Spec-version:** 1.0\n\n"
+ "## 8. Receiver Calibration\n\n"
+ "### 8.1 Hard contract\n"
+ "- mutates: /tmp/out\n"
+ "- never-touches: /etc\n"
+ "- decision-budget: none\n"
+ "- reboot-survival: none\n\n"
+ "### 8.5 Human-user substrate\n"
+ "- receiver-fingerprint: gui-only\n\n"
+ "### 8.7 Operator substrate\n"
+ "- receiver-fingerprint: self-operated\n\n"
+ "## 11. Human-User View\n\n"
+ "### Exemplar bindings\n"
+ "- help-text-style: post-ship-iteration\n\n"
+ "## 13. Operator View\n\n"
+ "### Exemplar bindings\n"
+ "- log-format-style: post-ship-iteration\n"
+ )
+ spec = tmp_path / "x.spec.md"
+ spec.write_text(body, encoding="utf-8")
+ findings = cross_view_gate.classify(spec)
+ deferrals = [f for f in findings if f.kind == "post-ship-iteration-deferral"]
+ excessive = [f for f in findings if f.kind == "excessive-post-ship-iteration"]
+ no_compat = [d for d in deferrals if d.reason == "no-compatible-exemplar"]
+ assert len(deferrals) == 2
+ assert len(no_compat) >= 1, "gui-only fingerprint must produce a no-compatible-exemplar deferral"
+ assert excessive == [], "deferrals forced by empty catalog must not trigger excessive warn"
diff --git a/tests/test_eval_sidecar_substitutions.py b/tests/test_eval_sidecar_substitutions.py
new file mode 100644
index 0000000..f0ab598
--- /dev/null
+++ b/tests/test_eval_sidecar_substitutions.py
@@ -0,0 +1,113 @@
+"""Eval sidecar substitution log (v1.2.1 #6).
+
+`write_sidecar(substitutions=...)` writes a `substitutions: [...]` array to
+the sidecar payload as contemporaneous evidence of agent rewrites that
+satisfied Tier-1 checks. Empty when no rewrite happened; populated when
+rewrites did happen. Not a finding — auditable evidence.
+"""
+import json
+import pathlib
+
+from bin import eval_metadata
+
+
+def test_118_substitutions_omitted_when_none(tmp_path: pathlib.Path):
+ spec = tmp_path / "x.spec.md"
+ spec.write_text("# x\n", encoding="utf-8")
+ returned = eval_metadata.write_sidecar(
+ spec,
+ evaluator_version="1.0.0",
+ tiers_run=[1],
+ findings=[],
+ dismissals=[],
+ config_path=None,
+ config_hash=None,
+ deepseek_model_version=None,
+ policy_hash="abc123",
+ substitutions=None,
+ )
+ assert returned.exists() and returned.name == "x.spec.md.eval.json"
+ payload = json.loads(returned.read_text(encoding="utf-8"))
+ assert "substitutions" not in payload
+
+
+def test_119_substitutions_empty_list_written_as_empty(tmp_path: pathlib.Path):
+ spec = tmp_path / "x.spec.md"
+ spec.write_text("# x\n", encoding="utf-8")
+ returned = eval_metadata.write_sidecar(
+ spec,
+ evaluator_version="1.0.0",
+ tiers_run=[1],
+ findings=[],
+ dismissals=[],
+ config_path=None,
+ config_hash=None,
+ deepseek_model_version=None,
+ policy_hash="abc123",
+ substitutions=[],
+ )
+ assert returned.exists()
+ payload = json.loads(returned.read_text(encoding="utf-8"))
+ assert payload["substitutions"] == []
+
+
+def test_120_substitutions_populated_round_trips(tmp_path: pathlib.Path):
+ spec = tmp_path / "x.spec.md"
+ spec.write_text("# x\n", encoding="utf-8")
+ entry = {
+ "from": "python3 -c 'import x'",
+ "to": "pip show x | grep -q 'Name: x'",
+ "reason": "Tier-1 _SHELL_EVAL_RE false-positive workaround",
+ "tier1_check_name": "untrusted-flow-unguarded",
+ "step_id": "step-3",
+ }
+ returned = eval_metadata.write_sidecar(
+ spec,
+ evaluator_version="1.0.0",
+ tiers_run=[1],
+ findings=[],
+ dismissals=[],
+ config_path=None,
+ config_hash=None,
+ deepseek_model_version=None,
+ policy_hash="abc123",
+ substitutions=[entry],
+ )
+ assert returned.exists()
+ payload = json.loads(returned.read_text(encoding="utf-8"))
+ assert payload["substitutions"] == [entry]
+
+
+def test_121_substitutions_multiple_entries_preserved_in_order(
+ tmp_path: pathlib.Path,
+):
+ spec = tmp_path / "x.spec.md"
+ spec.write_text("# x\n", encoding="utf-8")
+ entries = [
+ {
+ "from": "tee /etc/x", "to": "tee state/x",
+ "reason": "out-of-root", "tier1_check_name": "self-cycle-produces",
+ "step_id": "step-1",
+ },
+ {
+ "from": "bash -c 'echo'", "to": "echo hi",
+ "reason": "simplify", "tier1_check_name": "soft-verification",
+ "step_id": "step-2",
+ },
+ ]
+ returned = eval_metadata.write_sidecar(
+ spec,
+ evaluator_version="1.0.0",
+ tiers_run=[1],
+ findings=[],
+ dismissals=[],
+ config_path=None,
+ config_hash=None,
+ deepseek_model_version=None,
+ policy_hash="abc123",
+ substitutions=entries,
+ )
+ assert returned.exists()
+ payload = json.loads(returned.read_text(encoding="utf-8"))
+ assert payload["substitutions"] == entries
+ assert len(payload["substitutions"]) == 2
diff --git a/tests/test_spec_ast_relative_authoring.py b/tests/test_spec_ast_relative_authoring.py
new file mode 100644
index 0000000..fff8fce
--- /dev/null
+++ b/tests/test_spec_ast_relative_authoring.py
@@ -0,0 +1,130 @@
+"""Relative-path authoring + workspace-boundary guard for spec_ast (v1.2.1 #3).
+
+`_action_authored_path` now recognizes relative paths (when a project root is
+supplied) and the `touch` authoring verb. Paths that resolve outside the
+project root — `../etc/passwd`, absolute paths to system locations, symlink
+escapes — are not considered authored, so `self-cycle-produces` keeps firing
+for them.
+"""
+import pathlib
+
+import pytest
+
+from bin import spec_ast
+
+
+@pytest.fixture
+def project_root(tmp_path: pathlib.Path) -> pathlib.Path:
+ (tmp_path / "schemas").mkdir()
+ (tmp_path / "state").mkdir()
+ return tmp_path
+
+
+# ── Relative-path authoring (was rejected pre-v1.2.1) ────────────────────────
+
+def test_99_tee_relative_path_is_authored(project_root: pathlib.Path):
+ paths = spec_ast._action_authored_path(
+ "tee schemas/x.json", project_root=project_root
+ )
+ assert "schemas/x.json" in paths
+
+
+def test_100_touch_relative_path_is_authored(project_root: pathlib.Path):
+ paths = spec_ast._action_authored_path(
+ "touch state/cookie.json", project_root=project_root
+ )
+ assert "state/cookie.json" in paths
+
+
+def test_101_redirect_relative_path_is_authored(project_root: pathlib.Path):
+ paths = spec_ast._action_authored_path(
+ "echo hello > state/log.txt", project_root=project_root
+ )
+ assert "state/log.txt" in paths
+
+
+# ── Boundary guard: out-of-root paths must NOT be cleared ───────────────────
+
+def test_102_dotdot_escape_not_authored(project_root: pathlib.Path):
+ paths = spec_ast._action_authored_path(
+ "touch ../etc/passwd", project_root=project_root
+ )
+ assert paths == []
+
+
+def test_103_absolute_outside_root_not_authored(project_root: pathlib.Path):
+ paths = spec_ast._action_authored_path(
+ "tee /etc/spectre.conf", project_root=project_root
+ )
+ assert paths == []
+
+
+def test_104_absolute_inside_root_authored(project_root: pathlib.Path):
+ target = project_root / "schemas" / "y.json"
+ paths = spec_ast._action_authored_path(
+ f"tee {target}", project_root=project_root
+ )
+ assert str(target) in paths
+
+
+# ── Backward compatibility: None project_root keeps today's behavior ────────
+
+def test_105_no_project_root_only_abs_paths_authored():
+ paths = spec_ast._action_authored_path("tee schemas/x.json")
+ assert paths == [] # relative path unrecognized without a root
+
+
+def test_106_no_project_root_abs_path_authored():
+ paths = spec_ast._action_authored_path("tee /tmp/x.json")
+ assert "/tmp/x.json" in paths
+
+
+# ── Touch with multiple paths ───────────────────────────────────────────────
+
+def test_107_touch_multi_path_all_authored(project_root: pathlib.Path):
+ paths = spec_ast._action_authored_path(
+ "touch state/a.json state/b.json", project_root=project_root
+ )
+ assert "state/a.json" in paths
+ assert "state/b.json" in paths
+
+
+# ── classify() integration: relative authoring clears self-cycle finding ────
+
+def test_108_classify_with_project_root_clears_self_cycle(
+ project_root: pathlib.Path, tmp_path: pathlib.Path
+):
+ # A two-step spec where step 1's action authors a relative path via
+ # `tee` and step 2 references that path. Without the project_root,
+ # step 1's authoring goes unrecognized and `self-cycle-produces` may
+ # fire when step 1's produces: file matches its own action.
+ spec = tmp_path / "spec.md"
+ spec.write_text(
+ "# Test Spec\n"
+ "**Slug:** test-spec\n"
+ "## 1. Hard Problem\nfoo\n"
+ "## 2. First Principles\n- foo\n"
+ "## 3. Algorithm Audit\n- Delete: none\n- Simplify: none\n- Accelerate: none\n"
+ "## 4. Speed-of-Light Limit\nfoo\n"
+ "## 5. Physics Guardrails\n- foo\n"
+ "## 6. Steps\n\n"
+ "```yaml\n"
+ "- step: 1\n"
+ " why: seed config\n"
+ " action: tee schemas/x.json\n"
+ " verification: test -f schemas/x.json\n"
+ " produces: [file:schemas/x.json]\n"
+ " requires: []\n"
+ "```\n\n"
+ "## 7. Success Criteria\n- [ ] done\n\n"
+ "## 8. Receiver Calibration\n\n"
+ "### 8.1 Hard contract\n\n"
+ "- mutates: schemas/x.json\n"
+ "- never-touches: /etc\n"
+ "- decision-budget: none\n"
+ "- reboot-survival: none\n",
+ encoding="utf-8",
+ )
+ findings = spec_ast.classify(spec, project_root=project_root)
+ self_cycles = [f for f in findings if f.kind == "self-cycle-produces"]
+ assert self_cycles == []
diff --git a/tests/test_substrate_ast_lexical_filtering.py b/tests/test_substrate_ast_lexical_filtering.py
new file mode 100644
index 0000000..0fa0fe6
--- /dev/null
+++ b/tests/test_substrate_ast_lexical_filtering.py
@@ -0,0 +1,133 @@
+"""Lexical-context filtering for substrate_ast sink detection (v1.2.1 #1+#2).
+
+`_classify_and_strip_literals` masks inert quoted spans (JSON bodies, log
+strings, argv elements) before sink regexes scan, while keeping the body of
+recognized interpreter `-c` / `-e` payloads verbatim so live code still
+fires. `_SQL_RE` also tightened so method-call positions (`hashlib.update`,
+`os.replace`, `.replace`) no longer match.
+"""
+from bin import substrate_ast
+
+
+# ── 6 false-positives from v1.2 dogfood (must NOT fire) ──────────────────────
+
+def test_81_hashlib_update_method_call_no_sql_sink():
+ assert substrate_ast._sink_kinds_from_action(
+ "python3 -m mytool hash --algo sha256 # uses hashlib.update() internally"
+ ) == []
+
+
+def test_82_os_replace_method_call_no_sql_sink():
+ assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(
+ "python3 -c 'import os; os.replace(src, dst)'"
+ )
+
+
+def test_83_errors_replace_kwarg_no_sql_sink():
+ assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(
+ 'open(path, encoding="utf-8", errors="replace")'
+ )
+
+
+def test_84_ts_dot_replace_no_sql_sink():
+ assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(
+ 'const x = "a".replace("b", "c");'
+ )
+
+
+def test_85_dollar_paren_inside_json_string_no_shell_sink():
+ assert "shell-eval" not in substrate_ast._sink_kinds_from_action(
+ 'echo \'{"title": "$(book)", "id": 42}\' > out.json'
+ )
+
+
+def test_86_python3_dash_c_payload_strings_masked_no_sql_sink():
+ # python3 -c 'INSERT INTO …' inside a literal would fire SQL today even
+ # though it is the verb in a docstring; the kept-span policy means we
+ # still see the body, but the SQL-shape requirement prevents prose
+ # collisions. A live `INSERT INTO` inside the payload is a real sink and
+ # is expected to fire (covered separately).
+ assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(
+ "python3 -c 'print(\"this update is benign\")'"
+ )
+
+
+# ── 8 true-positives that MUST still fire ────────────────────────────────────
+
+def test_87_live_bash_dash_c_with_sql_fires():
+ sinks = substrate_ast._sink_kinds_from_action(
+ 'bash -c "DELETE FROM users WHERE id=1"'
+ )
+ assert "shell-eval" in sinks
+ assert "sql-or-template" in sinks
+
+
+def test_88_live_psql_dash_c_with_insert_fires():
+ sinks = substrate_ast._sink_kinds_from_action(
+ 'psql -c "INSERT INTO t VALUES (1)"'
+ )
+ assert "sql-or-template" in sinks
+
+
+def test_89_live_sh_dash_c_with_curl_fires():
+ sinks = substrate_ast._sink_kinds_from_action(
+ 'sh -c "$(curl -fsSL https://example.com/install.sh)"'
+ )
+ assert "shell-eval" in sinks
+
+
+def test_90_python3_dash_c_with_var_interpolation_fires():
+ sinks = substrate_ast._sink_kinds_from_action(
+ 'python3 -c "$USER_INPUT"'
+ )
+ assert "shell-eval" in sinks
+
+
+def test_91_node_dash_e_payload_fires_shell_eval():
+ sinks = substrate_ast._sink_kinds_from_action(
+ 'node -e "require(\'fs\').writeFileSync(p, body)"'
+ )
+ assert "shell-eval" in sinks
+
+
+def test_92_live_insert_top_level_fires():
+ sinks = substrate_ast._sink_kinds_from_action(
+ "INSERT INTO audit (event) VALUES (?)"
+ )
+ assert "sql-or-template" in sinks
+
+
+def test_93_live_dollar_paren_subshell_fires():
+ sinks = substrate_ast._sink_kinds_from_action(
+ "tar -czf /tmp/out.tar.gz $(find . -name '*.log')"
+ )
+ assert "shell-eval" in sinks
+
+
+def test_94_unbalanced_quote_conservative_fallback():
+ action = 'bash -c "DELETE FROM x' # unterminated literal
+ sinks = substrate_ast._sink_kinds_from_action(action)
+ assert "shell-eval" in sinks # `bash -c` still fires
+ assert "sql-or-template" in sinks # falls back to today's scan; SQL fires
+
+
+# ── 4 boundary cases ─────────────────────────────────────────────────────────
+
+def test_95_triple_quoted_multiline_literal_masked():
+ action = '''python3 myscript.py --readme """contains DELETE FROM in docs"""'''
+ assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(action)
+
+
+def test_96_escaped_quote_inside_literal_handled():
+ action = r'echo "he said \"INSERT\" but meant push"'
+ assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(action)
+
+
+def test_97_mixed_shell_quoting_inert_kept_inert():
+ action = """echo '"DELETE FROM logs"'"""
+ assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(action)
+
+
+def test_98_comment_containing_update_no_sink():
+ action = "echo hello # call update on the index later"
+ assert "sql-or-template" not in substrate_ast._sink_kinds_from_action(action)
diff --git a/tests/test_supervisor_operator_mode.py b/tests/test_supervisor_operator_mode.py
new file mode 100644
index 0000000..29a998c
--- /dev/null
+++ b/tests/test_supervisor_operator_mode.py
@@ -0,0 +1,51 @@
+"""Supervisor lock state carries operator_mode (v1.2.1 #7).
+
+Each lock entry records `operator_mode: "interactive" | "auto"` so downstream
+audit/evidence can distinguish operator-driven locks from /implement auto-mode
+locks.
+"""
+import json
+import pathlib
+
+import pytest
+
+from bin import supervisor
+
+
+def test_122_acquire_defaults_to_interactive_mode(tmp_path: pathlib.Path):
+ state = supervisor.LockState(tmp_path / ".locks.json")
+ state.register_resource("port:8080", capacity=1)
+ granted = state.acquire(
+ "port:8080", track="auth", actor_pid=12345, actor_start_time=1000.0
+ )
+ assert granted is True
+ payload = json.loads((tmp_path / ".locks.json").read_text())
+ assert payload["locks"][0]["operator_mode"] == "interactive"
+
+
+def test_123_acquire_records_auto_mode_when_passed(tmp_path: pathlib.Path):
+ state = supervisor.LockState(tmp_path / ".locks.json")
+ state.register_resource("port:8080", capacity=1)
+ granted = state.acquire(
+ "port:8080",
+ track="auth",
+ actor_pid=12345,
+ actor_start_time=1000.0,
+ operator_mode="auto",
+ )
+ assert granted is True
+ payload = json.loads((tmp_path / ".locks.json").read_text())
+ assert payload["locks"][0]["operator_mode"] == "auto"
+
+
+def test_124_acquire_raises_on_unknown_operator_mode(tmp_path: pathlib.Path):
+ state = supervisor.LockState(tmp_path / ".locks.json")
+ state.register_resource("port:8080", capacity=1)
+ with pytest.raises(ValueError, match="operator_mode"):
+ state.acquire(
+ "port:8080",
+ track="auth",
+ actor_pid=12345,
+ actor_start_time=1000.0,
+ operator_mode="unsupervised",
+ )
diff --git a/tests/test_v1_1_e2e.py b/tests/test_v1_1_e2e.py
index 91b9b56..b07046f 100644
--- a/tests/test_v1_1_e2e.py
+++ b/tests/test_v1_1_e2e.py
@@ -218,10 +218,19 @@ def test_v1_1_acceptance_synthetic_spec(tmp_path: pathlib.Path) -> None:
assert "view-fingerprint-contradicts-exemplar-binding" in kinds
# Fix 2: two post-ship-iteration deferrals (§9 and §12)
- assert sum(1 for f in all_findings if f.kind == "post-ship-iteration-deferral") == 2
-
- # Fix 2 aggregator: excessive-post-ship-iteration fires when count > 1
- assert "excessive-post-ship-iteration" in kinds
+ deferrals = [f for f in all_findings if f.kind == "post-ship-iteration-deferral"]
+ assert len(deferrals) == 2
+
+ # v1.2.1 #5: §9 (help-text placeholder catalog) has no exemplar compatible
+ # with `human-typed` fingerprint → tagged `no-compatible-exemplar`.
+ # §12 (api-shape) has exemplars compatible with `api-consumer` →
+ # tagged `operator-deferral`. The aggregate warn counts only the
+ # operator-deferral subset, so with one operator-deferral the warn
+ # does NOT fire (≤ 1 threshold).
+ by_reason = {d.reason for d in deferrals}
+ assert "no-compatible-exemplar" in by_reason
+ assert "operator-deferral" in by_reason
+ assert "excessive-post-ship-iteration" not in kinds
# Fix 3: behavioral why + structural-only verification on step 2
assert "verification-too-shallow-for-claim" in kinds
diff --git a/tests/test_walker_no_compatible_exemplar.py b/tests/test_walker_no_compatible_exemplar.py
index 4967b92..0fe3c69 100644
--- a/tests/test_walker_no_compatible_exemplar.py
+++ b/tests/test_walker_no_compatible_exemplar.py
@@ -162,14 +162,19 @@ def test_single_deferral_emits_info(tmp_path, monkeypatch):
def test_two_deferrals_emits_excessive_warn(tmp_path, monkeypatch):
"""Spec with two views bound to post-ship-iteration → two info findings
- PLUS one excessive-post-ship-iteration warn finding."""
+ PLUS one excessive-post-ship-iteration warn finding.
+
+ v1.2.1 #5: fingerprints chosen so the catalog has compatible exemplars,
+ making both deferrals operator-chosen (not catalog-forced). The aggregate
+ warn only fires for operator-chosen deferrals.
+ """
monkeypatch.setattr(_catalog, "_LOAD_CACHE", None)
spec = _write_spec(
tmp_path,
extra_substrate=(
"### 8.5 Human-user substrate\n"
- "- receiver-fingerprint: gui-only\n\n"
+ "- receiver-fingerprint: cli-power-user\n\n"
"### 8.7 Operator substrate\n"
"- receiver-fingerprint: on-call-engineer\n\n"
),
@@ -210,11 +215,15 @@ def test_prefixed_sentinel_emits_deferral_not_exemplar_not_found(tmp_path, monke
"""
monkeypatch.setattr(_catalog, "_LOAD_CACHE", None)
+ # v1.2.1 #5: fingerprints chosen so compatible exemplars EXIST in the
+ # catalog (cli-power-user for help-text; on-call-engineer for log-format).
+ # The deferral is therefore an operator choice, not catalog-forced —
+ # `excessive-post-ship-iteration` aggregate warn fires as designed.
spec = _write_spec(
tmp_path,
extra_substrate=(
"### 8.5 Human-user substrate\n"
- "- receiver-fingerprint: gui-only\n\n"
+ "- receiver-fingerprint: cli-power-user\n\n"
"### 8.7 Operator substrate\n"
"- receiver-fingerprint: on-call-engineer\n\n"
),
@@ -237,5 +246,7 @@ def test_prefixed_sentinel_emits_deferral_not_exemplar_not_found(tmp_path, monke
assert len(deferral) == 2, f"expected 2 deferral findings, got {len(deferral)}"
assert all(f.severity == "info" for f in deferral)
assert all(f.tier == 2 for f in deferral)
- assert len(excessive) == 1, "two deferrals must trigger excessive-post-ship-iteration warn"
+ assert all(f.reason == "operator-deferral" for f in deferral), \
+ "compatible-exemplar deferrals must be tagged operator-deferral"
+ assert len(excessive) == 1, "two operator-chosen deferrals must trigger excessive warn"
assert excessive[0].severity == "warn"
diff --git a/tests/test_walker_stop_predicate_consistency.py b/tests/test_walker_stop_predicate_consistency.py
new file mode 100644
index 0000000..fb0f5aa
--- /dev/null
+++ b/tests/test_walker_stop_predicate_consistency.py
@@ -0,0 +1,62 @@
+"""Walker stop predicate consistency (v1.2.1 #4).
+
+`_recommend_stop_predicate` is the single source of truth for the walker
+stop signal. Every code path that reports or emits ``recommended_stop`` must
+go through it, so the post-answer view and the explicit ``walker coverage``
+subcommand cannot disagree.
+"""
+import pathlib
+
+from bin import walker
+
+
+def _fully_satisfied_state() -> walker.WalkState:
+ """A walk state with every gating flag flipped on and no pending concerns."""
+ return walker.WalkState(
+ spec_intent="x",
+ spec_draft_path=pathlib.Path("specs/x.spec.md.draft"),
+ lifecycle_asked=True,
+ prompt_design_asked=True,
+ semantic_criteria_asked=True,
+ product_input_asked=True,
+ product_output_asked=True,
+ human_user_asked=True,
+ integrator_asked=True,
+ operator_asked=True,
+ )
+
+
+def test_109_predicate_recommends_stop_when_all_gates_pass():
+ state = _fully_satisfied_state()
+ cov = walker._recommend_stop_predicate(state, draft_text="")
+ assert cov["recommended_stop"] is True
+ assert cov["pending"] == 0
+ assert cov["deferred"] == 0
+
+
+def test_110_predicate_blocks_stop_when_pending_remains():
+ state = _fully_satisfied_state()
+ state.pending.append(
+ walker.Concern(
+ id="dummy-1",
+ kind="edge-case",
+ receivers=["implement"],
+ depends_on=[],
+ summary="x",
+ )
+ )
+ cov = walker._recommend_stop_predicate(state, draft_text="")
+ assert cov["recommended_stop"] is False
+ assert cov["pending"] >= 1
+
+
+def test_111_predicate_runs_refresh_pending_so_post_answer_view_agrees():
+ # Both call sites (post-answer + walker coverage) must call the predicate;
+ # this test pins that the predicate is the single point where
+ # `_refresh_pending` fires before coverage is computed.
+ state = _fully_satisfied_state()
+ cov1 = walker._recommend_stop_predicate(state, draft_text="")
+ cov2 = walker._recommend_stop_predicate(state, draft_text="")
+ # Idempotent: re-running on the same state produces the same view.
+ assert cov1["recommended_stop"] == cov2["recommended_stop"]
+ assert cov1["pending"] == cov2["pending"]