Synaptixs-spine

Latest version: v3.59.0

Safety actively analyzes 1037354 Python packages for vulnerabilities to keep your Python projects secure.

Scan your dependencies

Page 1 of 13

3.59.0

Fixed

- **Upgrade note: a plan approved on 3.58.0 reads as stale once — re-plan and re-approve.** The plan's twelve sections are
hashed, and several fixes below change what they say for the same spec (the ranking and the landing set reach `Areas`,
the blast-radius diagram, containment and coverage). Measured: a Bug and a Story spec rendered against one tree by 3.58.0
and by this release give different plan digests (`1c7fa8c2…` → `7451a3cd…`, `abffda4b…` → `655ecc4e…`). Nothing else about an
approval changes.
- **The run budget counts a model whose price Spine does not know (SSPN-180 / B87).** A call on a model the pinned price table
does not list recorded $0, so `SDLC_RUN_BUDGET_USD` never tripped on it: a run could spend without limit while the budget read
$0. Measured on the pinned table: the GPT-6 models make the price lookup raise (B81 marked those "not priced", and the budget
ignored the mark), and the **Claude 5.5 family** (`claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`, `claude-fable-5-1`)
makes it return 0.0 *without raising*, so nothing marked them at all. Now a zero is "free" only if the table lists the model
(a local model listed at zero stays free), and the budget charges every call with no known price an **estimate** at
`SDLC_UNPRICED_USD_PER_MTOK` (`input,output` dollars per million tokens; default `5,25`, Claude Opus 5's price, in the top tenth of the
table's chat models by price), from the reported tokens or, if the provider reported none, from characters (about four a token). The
ledger is unchanged (`$0`, "not priced"); the budget keeps the estimate apart, the exhausted-budget message says how much of the
spend was estimated and for which models, and a disabled cap (`0`) still tracks it and never refuses. `SDLC_MODEL_PRICES` (a JSON
object, or the path of one) gives a model its exact price through litellm, so nothing is estimated for it. Both variables fail at
startup, naming the variable, when malformed (a malformed value is also refused by every entry point that reads the cap, with
the cap's exit 2). **A local model the table does not list** (for example `ollama/qwen3:30b`) is not free to this rule: it is
estimated unless you give it a price of `0` in `SDLC_MODEL_PRICES` (a model listed at zero, tag ignored, stays free). The
estimate is blunt by design: it overcounts a cheap model and undercounts the
priciest tenth. **Not measured:** a full client call against a live provider for the Claude 5.5 names — the probe called the
same cost function on a synthetic response.
- **A `bin/` directory is skipped only where it is .NET build output (B63).** `bin` sat in `DEFAULT_IGNORE_DIRS` with the
comment ".NET build output", so `pkg extract`, doc ingestion and C include admission skipped every `bin/` in every
language, silently: on SWE-bench Verified base commits sympy's `bin/*.py` (12 files) and django's `django-admin.py` and
`compress.py` produced no nodes, so their symbols were absent from `blast_radius`, `investigate` and `localize`. A
`bin/` is now skipped only when a `.csproj`, `.fsproj`, `.vbproj`, `.sln` or `.slnx` file sits beside it
(`is_dotnet_output_dir`); compiled output and published static assets (`wwwroot/*.js`, which would enter the graph as
phantom JavaScript) stay out, a Python or shell `bin/` is walked. Only the three `pkg` walkers the ticket measured use the
new set (`PKG_IGNORE_DIRS`); `DEFAULT_IGNORE_DIRS` is unchanged for the other ten files that read it (codegen layout,
language detection, the agent's file listing), which were not reported and are not measured. `pkg extract` now names the
directories that can hold real source and that it did not enter, e.g. `(ignored directories: build (2), vendor (1))`;
`node_modules` and `__pycache__` are not listed. No `bin/` exists in Spine's own tree, so its graph is unchanged. The
other ignored names the ticket mentions (`build`, `dist`, `target`, `vendor`, `obj`) are not changed: each needs
measuring on the pinned repositories first.

- **A cross-repo Python import of a re-export lands on the symbol, not the provider's package (N18).** Inside one
repository `from shared import to_cents` resolves to `shared.money.to_cents` when `shared/__init__.py` re-binds it,
but across a declared `package` join the consumer's placeholder matched no provider symbol and fell back to the
longest provider module prefix, `shared` — so two imports collapsed into one edge to a module and both calls landed
on it. The provider's re-export tables were built while its files were read and thrown away, and a warm cache never
re-reads them, so they now travel with the join: `PythonExtractor` keeps them, `load_or_extract_repos` saves them in
a `.exports.json` sidecar beside the cached facts (a missing or unreadable one means extracting again, never a
guess) and passes them to `link_joins`, whose package join resolves the name through them with the same resolver the
single-repository pass uses (aliases, chains, star imports, a member of a re-exported class). A name the tables
cannot pin down (two imports that disagree, a rebinding) keeps the old package fallback, so the change only
sharpens an edge. New corpus case `multirepo/package_reexport_join`; the other six multirepo cases are
byte-identical.
- **A Python function's own name is no longer read as the file's import or `def` of the same name (B54).** The call
pass looked the leading name of `Store.make()` or `Store()` up in the file's imports and module-level defs and never
asked whether the calling function binds that name itself — as a parameter (`def f(Store)`), an assignment
(`Store = Rocket`, `whisper = _load_whisper()`), a loop or `with` target, a nested `def`/`class`, or an import of a
different target — so the call was emitted to the module-level name, and the receiver pass then landed the invented
`Store.make` on the ancestor that declares `make`. Such a call now produces no edge (refuse, never re-point), and a
nested function inherits the locals of the functions around it. A local import of the *same* target keeps its edge.
Edges only disappear: an old-vs-new run over Spine's `src` and `tests`, the Python corpus, pydantic, httpx and litellm
removed 291 `CALLS` edges and added none, and each removed edge's caller binds the name in an enclosing function.
New corpus refusals in `corpus/python/instance_calls`; no known gap closed.
- **A Kotlin constructor call to a nested class lands on the nested class (N17).** `Inner()` written inside `Outer`,
inside a class nested in `Outer`, or two levels down is `Outer.Inner` by Kotlin's scoping, but the call pass built the
id from the package and the simple name alone — `java:<package>.Inner`, which exists nowhere — so the edge pointed at
an external placeholder of that name. The call now looks the name up through the caller's enclosing classes first
(`_FileContext.nested_type`), so a nested class also shadows a top-level class of the same name inside its outer
class. Anything else keeps its old id: the enclosing class's own constructor, a top-level class, a type alias, and
the qualified `Outer.Inner()`. Old-vs-new over the 21 existing Kotlin corpus cases, the 385 Gradle accessor sources and a local
Android project moved 2 `CALLS` edges (both genuinely nested classes) and changed nothing else. New corpus case
`kotlin/nested_class_calls`.
- **`explain_symbol` says how many it dropped (B59).** It cut `called_by`, `called_through_interface`, `calls` and
`instantiated_via_type` to 15 and `contains` to 25 with no total, so a symbol with 40 callers read as having 15
(CLAUDE.md invariant 7: bound honestly). Each list now has its total beside it — `called_by_count`,
`called_through_interface_count`, `calls_count`, `contains_count`, and `instantiated_via_type_count` for a
constructor — the way `blast_radius` already reports `caller_count`. New output keys only; the caps are unchanged.
On Spine's own source 73 symbols have more than 15 callers (the most, `typer.echo`, 339).
- **The doc radius walks every owner of a symbol, and an ORM entity owns its columns (B41, items 1, 2, 3 and 5).**
`blast_radius` and `explain_symbol` list the docs naming a symbol, its class or its module. (1) The owner walk kept
one parent per child, so a C function declared in `cart.h` and defined in `cart.c` listed the docs naming only one of
the two files — a doc naming `src/cart.c` was missed entirely; it now walks every parent
(`FactStore.all_parents_index`; `parents_index` is unchanged, it has seven other callers). (2) A column under an ORM
entity (`py:entity:users.id`) got none of the entity's docs, because the walk accepted only a type or a module; an
entity now counts as a class. (3) `_in_repo_store` / `_in_repos_store` take their callback by `Callable[..., dict]`
and pick a 2-, 3- or 4-argument call by flag, so mypy could not catch a mismatch; they are now overloaded on the
flags, with no change in behaviour. (5) Text in an HTML page that spelled the `<pre>` placeholder (NUL-delimited) was
rewritten as a code fence; NUL is now dropped from page text. Item 4 (`_NOT_DOCS_RE` missing `dev-requirements.txt`
and dropping a prose `Requirements.txt`) is not changed: it needs a content heuristic, and Spine's own tree has no
such file. The C partial-class case the ticket names does not reproduce: both files sit under the same namespace
module, so nothing was lost there.
- **The PR review no longer calls an unchanged route "stale" (N19).** `GroundingVerifier.stale_findings` re-extracts
each changed file but never runs the front-end's `finalize`, and a route (`py:endpoint:GET /health`) or an ORM entity
is created there. So every route and entity in a touched file was reported "Knowledge graph is stale: ... the current
source no longer defines it" about source that had not changed, as a WARNING on each PR review touching one. Running
`finalize` on a single file is not the fix: a route's id carries the prefix set where its blueprint is registered, in
another file (`GET /v2/api/items` exists only because `app.py` registers the blueprint under `/v2`), so a walk over the
changed files alone yields `GET /api/items` and a new false finding. Those facts are now not judged: listed in the
new `GroundingVerifier.unjudged_freshness` (replaced on every call) rather than reported, as `skipped_freshness`
already does for a suffix no front-end claims. A deleted or unparseable file is still judged stale in full, and a
symbol removed from a file that also holds a route is still flagged. The ticket's second claim, per-walk state growing
across runs, did not reproduce (three passes on one verifier, sizes flat).
- **`is_test_node` no longer classes production names as tests (B85).** After the path check it fell back to a name
match with no word boundary, so `latest`, `contest`, `attest`, `testify`, `AgentSpec`, `ToolSpec`, `TestRunner` and
`_run_tests` counted as test code, in coverage and regression-gap decisions and in the build document's importer
list. The fallback now accepts only a `test_` function and a CamelCase `...Test` / `...Tests` class (C keeps its
test classes in a `Foo.Tests/` project, which the path rule does not match). On Spine's own graph (13,609
function/type symbols): 49 of the 53 symbols classed as tests by name alone are now production, none moved the other
way, and the 4 left (`test_author`, `test_command`, `test_files`, `test_functions`) are `test_`-prefixed names. Not
shown: the effect on a rendered build document or regression-gap report, and no .NET or Java repository was measured.

- **`investigate` ranks the fixing file higher and shows ten files, not ten symbols (SSPN-176; the retrieval half of SSPN-168 / B84).**
Three changes. (1) `build_investigation` lists the ranked symbols of up to ten distinct *files*, at most three symbols each and
thirty in all (ten symbols named six or seven files), and says how many matches it did not list — an exact count below 200
matches, "at least" beyond; an explicit `max_symbols` keeps the old meaning, so `design` (8) is unchanged. `score_label` now ranks
by distinct file, as its description always said. (2) A *weak* match (its words also name symbols in other files; flagged since
81029ecc but only for hits that survived the cut, and ranking nothing) now has its score halved for every match before the cut, so
a stem such as `handl` no longer lets every `handle` outscore the one symbol a ticket named. (3) Each ticket word found in a hit's
file path and not already matched by its name adds 0.5, at most two, to a hit that already matches by name. Settings were chosen
from pre-registered grids on 30 labels held out from the original 38; two other candidates (less aggressive word-ending
stemming, which breaks the CB-686 fixture; rare-word weighting, which loses five hits from the top ten) were tried and dropped.
Net on the 38 benchmark labels, top-1/3/5/10: ranking view 9/16/20/27 → **12/18/22/27**; as shown 9/16/20/23 → 12/18/22/27 (four
of the top-10 labels are display, not ranking); nothing that hit now misses. On 40 further labels from seven other projects
(30 chose the settings, 10 sealed and read once), top-3 18 → 23; over all 78 the top-3 gain is 34 → 41 (p = 0.065 — consistent in
direction, **not significant at 0.05**). `scoreboard.json`'s localization block is re-recorded at the file view: 9/14/17/23 (symbol
view) → 12/18/22/27, so the gated top-1 floor rises 9 → 12.
**What else moves.** `pkg_relevant_symbols`, `pkg_api_surface` and the codegen grounder use the same ranking: their top-8 differs
on 62 of 78 benchmark titles (the first item on 23). Callers that take the default `max_symbols` (`autorun`, `build`, `change`,
the plugin, `evidence`, `builddoc`, `spec_context`) now receive up to ten files — briefs are about 37% longer (median 3,433 →
4,709 characters). The effect on delivery quality is **not measured**. (This entry first said the validity gate's context-size
check would see the larger file set; it did, and the next entry reverses it.)
- **The validity gate, blast radius, churn and the `in_evidence` badge measure the files of the first ten non-weak rows, not the ten
files the brief lists (SSPN-181 / B88).** The change above widened the "files this ticket lands in" from six or seven to ten for
every consumer that took the default list. Replayed on 40 recorded real autorun tickets (SWE-bench pilot runs from the
maintainers' machine; a corpus of large files, so a ceiling for big repos and not a Spine-wide rate), **6 flipped from PROCEED to
TOO_BIG** (13 → 19 refused). The first idea, dropping weak rows, fixes none of the six — weak rows occur in 2 of the 40 — and graph
mode, which already dropped them, was affected too (the same six flip, one other ticket flips back: 13 → 18). One definition now
serves every consumer, `landings.landing_set`: the files of the first ten non-weak rows, first-seen order. It is used by
`evidence.landing_files` (graph mode: gate, blast radius, churn, `bind_criteria`), the `autorun` fallback, `cli/build.py` and
`builddoc.build_plan` (so `sdlc_plan` and `autorun` cannot disagree about one ticket; a path the ticket names still leads). The
brief still shows ten files. On the same 40 tickets, on one graph, it refuses 13, as before the change; it differs from the
pre-575 verdict on two (scikit-learn-25747 → TOO_BIG, sphinx-8621 → PROCEED), which come from the new ranking, not from this fix.
**One behaviour change to know:** in the `autorun` fallback an all-weak ticket now has an empty landing set, as it already did in
graph mode, `design` and `builddoc` — a Bug is refused `UNLOCALIZED` and a Story proceeds with the "names no file" finding; before,
the weak files counted as located and it proceeded silently. **Not measured:** whether codegen succeeds on any of them. The gate
also counts up to ten files where a design proposes one to six (median 2, 27 recorded runs); whether it should measure the
design's files instead is SSPN-185 / B89, not changed here.
- **The CLI says "cannot reach the API" in one line instead of a 245-line traceback (B36).** `orchestrator template list`
and `contract list` against a closed port ended in a Rich traceback and exit 1: `_check` handled an HTTP *status* of 400
or more, and a refused connection or a timeout never produced one. The CLI's HTTP client now turns a connect failure,
a timeout and any other transport error into one line on stderr and exit 1 — the URL it tried, `orchestrator up` for a
refused connection, `ORCHESTRATOR_API_TIMEOUT_SECONDS` for a timeout — and a 401/403 adds a line pointing at
`ORCHESTRATOR_API_KEY`. The happy path is unchanged. Reproduced before the fix; the timeout and 401 paths are tested
against a local server and a patched `send`, not a live API.
- **`SDLC_MAX_CRITERIA` raises the validity gate's acceptance-criteria cap (B82).** `TOO_BIG` is a hard stop at 12 stated
criteria and the only way past it was editing the source. The cap now reads `SDLC_MAX_CRITERIA` (a positive integer;
anything else is ignored with a logged warning and 12 applies), and the refusal's evidence names the variable. An
explicit `max_criteria=` argument still wins, and the gate's accuracy eval is pinned to 12 so it cannot drift with an
environment. No flag and no split hint: on 262 saved specs the largest has 12 criteria and none exceeds the cap, so the
reporter's case is not reproduced locally.
- **`MCPRegistry.call` now refuses a write tool, so intake's MCP source can no longer reach one (B43).**
`MCPToolHandler` has always refused a tool that is not declared read-only (`readOnlyHint: true`) unless its server is
`write_enabled`; `MCPRegistry.call` checked only the allow-list, and intake's `MCPSourceAdapter`, `db.py`, the docs
pull and `orchestrator mcp call` all call the registry directly. A misconfigured `MCP_SOURCE_*` tool name could reach
`jira_create_issue` (which mcp-atlassian reports as `read_only=None`) with no gate; reproduced with a fake client, the
registry reached the server. The rule now lives in the registry: a tool whose hint is not `True` is refused unless its
server is `write_enabled`, before the server is contacted, and an unannotated tool counts as unknown, not safe. The hint
comes from discovery when it has run; otherwise the first call to a tool costs one `list_tools` session and the answer
is cached (skipped for a `write_enabled` server). If that lookup fails the call is refused. The one deliberate exception
is a new `vouched=True` argument for a caller that has already cleared the tool: the docs pull (its tools are vetted
against `readOnlyHint` or the source's `trust_read_only`) and database introspection (a constant SELECT Spine wrote, sent
through a query tool most Postgres servers never annotate). Not measured against a live server: the real per-tool flags
of the Atlassian server were not re-read.

- **A token count or a price the provider did not give is no longer recorded as a real zero (B81).**
A reply with `usage` absent, null or all-zero was recorded as 0 tokens, indistinguishable from a real zero, so a run
record, a Jira worklog or a cost figure built from it was wrong without anyone knowing. The cause is not where the
ticket put it: litellm itself pads a missing or null `usage` with `Usage(0, 0, 0)` (and `usage` is set either way),
so `_extract` never sees an absence; what is reliable is that a real call always has at least one prompt token.
A reply with none is now marked `CompletionResult.usage_reported = False`, said once per model
(`llm.usage_missing`), counted per stage in the token ledger, and shown beside the figure: the run journal
(`0 tokens (usage not reported on 3 of 3 calls)`), the Jira worklog (a **Caveat** line, and the claim that every count
is the provider's own is dropped), the HTML report and the worklog log line. The same goes for the price:
`completion_cost` raising was swallowed to `$0.0`, so every GPT-6 call (the bundled price table lists none) recorded
$0 even with usage present; it is now `cost_known = False`, said once per model (`llm.cost_unknown`) and shown as
`not priced on K of N calls`. A genuine zero, and a price of exactly zero (a local model), stay as they were. Both
fields default to `True`, so the 69 places that build a `CompletionResult` are unchanged, and a clean ledger renders
byte-identically to the previous release. Not fixed: a run budget still charges an unpriced call as $0, so
`SDLC_RUN_BUDGET_USD` cannot trip on it (filed as B87, SSPN-180, a design choice). The tester's zero totals were not
reproduced; 383 local run records hold no call with 0/0 tokens.

- **A C static call inside a class that derives from ASP.NET's controller bases is no longer refused (B26).**
`Helper.Run()` inside `class Home : ControllerBase` got no edge, because an external base class might declare a
member called `Helper` (C looks a simple name up in the enclosing class, inherited members included, before it
looks for a type; `File.Read()` in a controller binds the method group `ControllerBase.File`, not `System.IO.File`),
and the pass cannot see inside the framework. On a .NET service that refused 91 true static calls. A committed table,
`typed_receivers.EXTERNAL_MEMBER_NAMES`, now lists every member name `ControllerBase` (65) and `Controller` (76)
declare, inherited members included, read from the accessible metadata of the ASP.NET Core 8.0.28 reference pack and
the 10.0.9 runtime (the two agree) plus `System.Object`; a static call whose first name is absent from it is the type.
Keyed by the fully qualified id, so a bare `ControllerBase` counts only where a `using Microsoft.AspNetCore.Mvc`
makes it Microsoft's. A base the table does not list still refuses, and so does a type named like a member the base
does declare (`User`, `File`, `Url`, `ViewBag`, `Equals`). On the service: **81 calls gained, none lost** (all 81 sit
under `ControllerBase`, on two in-repo static helper classes; the 10 others sit under `ClientBase` and one unlisted
base and remain refused); Spine's own graph and a 698-file Java repository are unchanged. `ClientBase<T>` is not in
the table: its assembly is not on the machine, and an entry is read from metadata, not recalled.

- **The localization gate now runs: a drop in top-1 or top-10 on the pinned corpus fails a weekly and a path-filtered pull-request
workflow (B84).**
The gate was recorded on 2026-09-01 as `not_measured` and compares only when both sides are measured, while ordinary CI is
offline by contract, so it could not fire and top-10 fell 27 → 23 unseen. `scoreboard.json` now holds the measured floor
(top-1 9/38, top-3 14, top-5 17, top-10 23; 0 empty; gold digest `0a6ecf8c7bd22fd8`) and `localization-gate.yml` runs
`pkg accuracy --check --pinned-corpus` with CI's extras. Not a required check yet — it needs the network. Only the localization
block of the scoreboard changed; the other baselines were not re-recorded.

- **Java enums keep their methods and fields, a field and a method of one name are two nodes, an external base stops
dangling, and an overload is contained once (B29, B25, B27, N9).**
Measured on mysql-connector-j (698 files) and a .NET service (561 files).
*Enums (B29):* a Java enum's methods, constructors, fields and nested types sit in `enum_body_declarations`, which
the member walk never entered: 81 enums held 327 methods and 328 fields the graph did not know, and 524 calls and
creations inside them. They are extracted now; enum constants (769, three with bodies) are still not.
*Same name (B25):* `private int length; int length()` minted one id and the first declared won, so a class that
declared the field first lost its method node and a call such as `z.length()` was refused. The field takes
`java:a.Lazy.lengthfield` **only when a method of that name exists**; no other id changes (38 of the repository's
fields), and 266 more calls resolve. Two old edges move: `ConnectionImpl.isClosed` reached the interface's
`Session.isClosed` because `NativeSession`'s field hid its method; it now reaches `NativeSession.isClosed`, which is
what the compiler binds.
*External bases (B27):* 270 `IMPLEMENTS` edges pointed at nodes that did not exist. A qualified name as written
(`com.google.protobuf.MessageOrBuilder`) now gets an external `Type` node; 27 simple names that fell to the
same-package guess for an implicit `java.lang` type (`java:com.mysql.cj.jdbc.Comparable`) are repointed to the name
as read (`java:Comparable`), as C already did. `pkg verify` finds 0 dangling edges; the step runs after calls are
settled, so no call can change.
*Contained once (N9):* each overload emitted another `CONTAINS`, so one member was listed up to 104 times by
`children_of`. One per (type, member) declaration now, in both languages: 2,483 duplicate rows leave the Java
graph and 114 the C one; the 91 that remain in the C service all span two files (a partial class, or one source file built by two projects), which a per-declaration rule cannot see.
Old against new: no node lost, no `CONTAINS` pair lost, no call lost except the two moved above; Spine's own graph is
unchanged. The Java `CALLS` recall on the corpus rises from 0.9231 to 0.9487 (the `field_method_same_name` gap in
`typed_receivers_refusals` closes) and the committed baseline is re-recorded.

- **The published localization figure was out of date: 27/38 → 23/38 top-10, 12/38 → 9/38 top-1 (docs only, no behaviour change).**
`BENCHMARK.md`, the README and three internal records quoted Spine 3.29.0's score. Re-measured on 2026-10-13 on the same 38
labels, pinned trees and scorer, 3.57.0 scores 23/38 and 9/38; two changes account for it (3.35.0's C++ header parse, which
removed two hits that came from locals mis-parsed as fields, and 3.38.0's stemmer, which won no label). The pages now say so,
and say that the ratchet gate described there had never compared two measured values. The retrieval fix and the gate are
tracked separately.

- **The documentation walker and language detection no longer read files behind a symlink the code walker refuses (N7,
N8).**
`RepoCodeExtractor` admits a symlinked file only when its target is somewhere the walk would go: inside the root, and
not inside an ignored, dot-prefixed or nested-checkout directory. `read_doc_pages` and `language_file_counts` walk the
same tree with the same filters but had no such rule. A `docs/guide.md` linking into `node_modules` brought vendored
prose in as a first-party `Doc`; a link leaving the repository was read too, and its text can reach a codegen
prompt when its page names a symbol; and three JavaScript links into `node_modules` beside one real `.py` made
`--language auto` pick JavaScript for a Python repository whose graph then held no JavaScript. A dangling link was
counted as a source file. All three walkers now share one rule, `pkg.extractor.symlink_admitted`. Directory links were
never followed (`os.walk` does not descend them) and are pinned by a test. Measured against the previous code on a
synthetic repository: 6 inadmissible docs leave, JavaScript 3 to 0, the code walker is unchanged; Spine itself, its
`corpus/` and 8 real checkouts are identical (none holds a symlinked doc or source file), and no walk gained a file.

- **A refine whose every edit is refused now ends the loop instead of crashing the run, and the guard that refuses test
edits recognises more ways to switch a test off (B50, B48).**
When every edit refine proposed was refused (a weakened test, or a file nothing names), the refusal was filed under
"the model submitted no files": two corrective retries, advice that said "it needs at least one file", and then,
with the retries spent, an error that left `refine` (declared to tolerate an empty answer) and crashed the run past
the code that releases the ticket. A refusal is now its own kind: one corrective retry that says what was refused
and why, and an empty change with that reason as its summary when it is spent, so the loop reaches its normal FAILED
verdict. Implement and author_tests, which do not allow an empty answer, still raise. The guard itself
(`_weakens_test`) refused only a lost `assert` or test; a test can be disabled without either. It now also refuses,
in a test that exists in both versions: a changed decorator (`skip`, `xfail`, `skipif`, a `parametrize` case) or
`pytestmark`; an added `pytest.skip`/`xfail`/`importorskip` call or early `return`; an assert put under `if False:` or
a `try` that swallows it; `pytest.raises` removed, widened to `Exception` or stripped of its `match`; a second
`def test_x` shadowing the first; a value an assert compares against changed; and a removed `self.assert*`. A new
test may use `importorskip`, and renaming an exception inside `pytest.raises` is not widening it. Measured on 118
real test-file edits from lint, format and type commits in this repository's history: all 29 the old guard refused
are still refused, 3 more are (a changed `parametrize` case and two changed test inputs, edits to what a test
checks rather than lint repairs). Not covered: a value changed in a fixture or helper the test calls.
Not shown: either guard against a live model's refine pass.

- **`openspec://` no longer hands a REMOVED requirement to codegen as something to build (B56).**
Every ` Requirement:` in a change's delta specs became an acceptance criterion, whatever ` ADDED / MODIFIED /
REMOVED` section it sat under, so the requirements a change exists to *delete* were written into the contract the
generated tests are checked against. On a real upstream change (`merge-init-experimental`, 47 criteria) four of
them were removals such as "File Generation: **Reason**: AGENTS.md and project.md are no longer generated".
A requirement under ` REMOVED` is now never an acceptance criterion; the change's scope says what it removes
instead, one line per requirement (`Removes: File Generation — AGENTS.md and project.md are no longer generated`,
using the `**Reason**:` line OpenSpec writes, or the bare name when there is none), so the plan and the reviewer
still see it. ADDED and MODIFIED (which carries the full replacement) are unchanged, as is a spec with no
recognised section header. The same read serves `sdlc feature --source`, `ingest`, the backlog, autorun, the studio
preview and the requirements check. Measured on the 104 changes of a real OpenSpec checkout: 102 read exactly as
before, criteria and scope; the 2 that differ are the 2 that contain a REMOVED requirement, 6 criteria leave and
none are added. `RENAMED` is not parsed (none of 185 real spec files uses it, and its entries are not requirement
headings, so it never produced a criterion); a test pins that.
- **The run cap now covers intake, and the MCP and remediate entry points, which ran without one (B32, B33).**
`SDLC_RUN_BUDGET_USD` (and `--max-cost`) bounded the build and the review but not the stage that runs first: intake
built its own model client, outside the cap and outside the run's ledger, so a run could spend on intake with the
cap untouched and the run record reporting $0.00 for it. Intake is the only planning stage that can spend (research
runs tools that reach no model, and design is called without one, a test now pins that), and it now calls the model
through the run's own client in both `sdlc autorun` and `sdlc feature`: its spend counts toward the cap, a cap
spent during intake parks an autorun run (exit 4, as in the build and the review), and the ledger has an `intake`
row. **Ledger and run-record totals for a run that does intake go up; that is the missing spend now being counted,
not new spend.** A cached intake still costs nothing. Separately, three entry points ran with no cap at all: the MCP
`sdlc_feature` tool, the MCP remediate tool and `sdlc remediate`. They read the same variable now (a malformed value
refuses before any spend, an exhausted cap comes back as an error result), with one budget per call. For remediate
the cap is **per task**: a batch of N tasks can spend up to N times it.
Not shown: what intake costs in practice, or that a live run is stopped by the cap; the tests drive fake clients, and
the figures in the ticket's comment (108 s and 320 s "of model calls" booked at $0.00) are elapsed time, part of
which is graph extraction, so they do not say how much of it was model spend.

- **The build document no longer calls a change "contained" because its importers have `test` somewhere in their
name (B78).**
"Nothing in the product depends on what changes" is printed when every module that imports the change is a test,
and the rule that decided that matched `test` as a substring: `testimonials.views`, `app.latest_tests_runner`,
`app.system_test.cli` and `app.testing.utils` were all read as tests, and so were Spine's own production modules
`sdlc.testenv`, `sdlc.testpaths` and `sdlc.testrunner`. A change visible to production code was reported as
invisible to it. A module is now a test only by a whole segment of its name: `tests`, `spec`, `conftest`,
`__tests__` and the like, a PascalCase `...Tests` suffix, or a file name that is `test_*` or `*_test(s)`; a
package merely called `system_test` or `test_data` is read as product, and `testing` no longer counts by itself.
Where the name is doubtful it is read as product, because the opposite mistake only inflates a count the caveat
beneath already hedges. Measured against the old rule on 9,072 module names from installed packages: 72 go from
test to not-test, none the other way. 57 of those 72 are `testing` packages (`sqlalchemy.testing`,
`alembic.testing`), shipped test-support code that now counts as product; the rest are mostly genuinely
production (`temporalio.api.testservice`) or private test helpers (`anyio._testing`). The same defect class in
`coverage.is_test_node` (symbol names such as `ToolSpec` and `latest`) is tracked separately as B85.

- **GPT-6 models (`gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-6.1-sol`, bare or `openai/`-prefixed) can now be
called (B45).**
Every call to one failed, in three ways that surfaced one after another. litellm refused `reasoning_effort` for
them before the request left the process; sent without it, the request carried `max_tokens`, which OpenAI
refuses for these models; and under the price table Spine pins, litellm did not know the bare name's provider at
all ("LLM Provider NOT provided" - that table lists no GPT-6 model). They are now called through litellm's
Responses bridge (`openai/responses/<model>`), which names the provider, carries the token limit as
`max_output_tokens` and the level as `reasoning.effort`; the level is named explicitly (GPT-6 is chosen by name
because the pinned table cannot say) and passed through `allowed_openai_params`. Checked against a local server
that records the request, with the real litellm in a process set up as the CLI is: it POSTs `/v1/responses` with
`max_output_tokens` and `reasoning.effort`. Exactly 4 of the 4,083 names in litellm's table change route; every
other name, including 47 that merely contain `gpt-6` (`azure/gpt-6-astra`, `bedrock/...`), gets the request it
always got. `azure/gpt-6-*` is not covered: sending it to OpenAI's route would call a different provider.
Not shown: that OpenAI accepts this request and returns usable tool calls for these models. The harness shim this
replaces got both at reasoning `high` on `gpt-6-astra`; this exact request has not been sent to the API.

- **A provider-prefixed model name (`openai/gpt-5.1`, `anthropic/claude-opus-4-5`) now gets the reasoning level you
set, instead of silently running at the model's own default (B79).**
The check that decides whether to send `ORCHESTRATOR_REASONING_EFFORT` looked the name up in litellm's price table
by exact key, and that table holds bare names, so `openai/gpt-5.1` missed and was treated as a model with no
reasoning. Nothing said so. A name the table does not hold is now put to `litellm.supports_reasoning`, which
resolves the prefix; a name the table does hold answers exactly as before (checked on all 4,083 table entries: none
changed, and of 1,300 prefixed names tried, 93 went from "no" to "yes" and none the other way). An unknown model
still gets no parameter. New: when you set `ORCHESTRATOR_REASONING_EFFORT` yourself and it is not sent to a model,
one warning per model says so (`llm.reasoning_unsupported`); the unset `high` default stays silent.
Not shown: that a provider accepts the level on a prefixed name it now receives. Where the bare name was already
sent the parameter it is unchanged; `openai/gpt-6-*` now meets the same GPT-6 rejection the bare name already did
(B45, a separate fix).

- **Codegen's file list is now requested as native structured output on models that can constrain their output, to
stop it coming back as a garbled string (B77).**
On `claude-opus-5` the forced `submit_files` tool call returned `files` as a string, not an array, in about a quarter
of calls: the original responses to one captured run were malformed 3 times in 11, and the same captured request
replayed unchanged, straight to the API with no Spine, litellm or proxy involved, 3 times in 12. The string is not
valid JSON (37 of 37 in the third benchmark pilot, a fresh draw of 20 instances), so it cannot be repaired. 12 of the
14 instances that ran produced at least one; the B72 retry recovered 9 of those 12, and the other 3 ended with no
patch. A tool can now opt in with
`ToolSpec.native_schema`; `LiteLLMClient` then sends its schema as a `json_schema` response format, with **no tools and
no `reasoning_effort`**, when litellm's table says the model supports native structured output (and see the next
entry: sending no thinking parameter at all turned out to leave thinking on). The effort part is measured, not a
preference: the same request with `thinking` and `effort` sent explicitly used about 14 times the output tokens, took about
17 times as long, cost about 1.6 times as much, and truncated one call in ten at `max_tokens`. Codegen's own call is
unchanged and the text reply goes through the same parse and the same B72 refusal. Any other model, a tool that did not
opt in, an explicit caller schema, or `ORCHESTRATOR_NATIVE_STRUCTURED_OUTPUT=0` gets the request Spine has always sent,
and a provider that rejects the native request gets that request instead, and once the forced tool works where the
native request did not, the model is not asked again in that process. A rejected `temperature` is left to the
existing temperature retry and does not count as a refusal.
Not shown: that the API accepts this exact variant, and its malformed rate. The first live call is the acceptance test.

- **The native structured-output request now switches the model's thinking off explicitly, on Anthropic reasoning
models (B83).** The request above sent no thinking parameter, on the assumption that this keeps thinking off. It does
not. On `claude-opus-5` the first live run of one instance returned 7,935, 7,566 and 5,157 output tokens per codegen
reply, against a median of about 2,008 under the forced tool (which prevents thinking), and every response opened with
an empty `thinking` block. Replaying two of those requests straight to the API with `thinking: {"type": "disabled"}`
was accepted next to the format and came back with 1,800 to 2,554 tokens and no thinking block (three calls); the
unchanged request, replayed once, thought again (4,551 tokens). `LiteLLMClient` now adds that parameter to the native
request when litellm lists the model under the `anthropic` provider and as a reasoning model (22 table entries), and
to nothing else: for 31 of the 125 native-capable entries (Bedrock, Friendli, Vertex-Anthropic) litellm raises
`UnsupportedParamsError` only because of this parameter, so those models keep the request they had. A model that
rejects it gets the forced tool through the existing fallback, which drops the parameter with the response format.
Not shown: the malformed rate with thinking off (three replies cannot give one); that the whole path through Spine
reaches the API this way (the replay went straight to it; litellm's request build was checked offline); patch quality;
and any model but `claude-opus-5` (the other 21 entries in the set take the same API parameter but were not run, and
an older Claude model that does not think by default would simply see a no-op). Codegen's default path on these models
no longer sends `ORCHESTRATOR_REASONING_EFFORT` at all; `ORCHESTRATOR_NATIVE_STRUCTURED_OUTPUT=0` brings it back.

- **The SWE-bench test wrapper no longer reports a run that executed zero tests as green (B74).** In the second
benchmark pilot two instances ended at the coverage stop after in-loop test runs that had executed nothing
were reported green (django-13516: all three runs, so refine never ran; sympy-23950: runs 2 to 4, after one
real failure and one refine pass): Django printed `Ran 0 tests` and exited 0 (the model's tests were plain classes, which
Django's unittest runner does not collect), and sympy's `bin/test` printed `tests finished: 0 passed` and exited
0. pytest returns 5 for "no tests collected"; these two runners do not, so the refine loop never ran and the
model was never shown that its tests were not being run. `orchestrator.evals.swebench.exec` now reports exit
status **5** when a Django or sympy run exits 0 and the runner's own final summary line says it executed zero
tests, and appends one line saying so. It is refuse-only: it can turn a false green into a failure and never the reverse;
a non-zero exit, a run that executed tests (including all-skipped), pytest-kind repositories and an empty output
are left exactly as they were. Harness only; no product behaviour changes. **Not claimed:** that the two
instances now produce patches (best case on the 20 pilot instances is 12 → 14), why sympy collected zero
(unverified), that the sympy "skipped" wording matches a real run (synthetic), or that the first pilot's
runs also executed zero tests (it kept no test output).
- **A reply whose `files` is not an array is no longer read as "nothing to change" (B72).** The attempt record
added in 3.58.0 showed what ended a benchmark run at `[refine] [] - <a fix in words>`: the model's refine
reply had `files` as a JSON *string* (a list written out as text, cut off mid-file, holding the fix its own
summary described) where the other stages of the same run sent arrays. `_apply` treated any non-list like an
empty list, so a refine pass that had produced its fix was read as a legitimate no-op and the loop stopped,
and a stage that must send files told the model it had "submitted NO FILES", which was untrue. A `files` that
is **present and not a list** (a string, `null`, an object, a number) is now a malformed reply: it gets its
own failure kind, `shape`, with two corrections like an empty submission, and advice that says what was
found (type and length, never the content), quotes the summary the model gave (the retry is a fresh draw and
cannot see its last reply), and says `files` must be an array of objects. The string is never parsed, even
where it would parse. An absent `files` key and `[]` are unchanged. **If the corrections run out, the stage
ends as it always did:** a stage that must send files (`implement`, `author_tests` on testable source)
raises `CodegenError`, as before; a stage that allows an empty reply (`refine`, `revise`, `author_tests` on
a change with nothing to test) returns an empty change if the summary claims no change, and raises the same
`CodegenError` if it claims one. So a malformed reply costs up to **two extra model calls** and never ends a
run in a worse place than before; a first version of this change raised in the empty-allowed case, which
skipped the caller's cost record and ticket release, and was corrected before release. The log gains
`sdlc.codegen.files_not_a_list type=… len=…` on each malformed reply, `sdlc.codegen.shape_retry` on each
correction and `sdlc.codegen.shape_exhausted` when a malformed reply is let through as a no-op; the attempt
record shows each as `rejected`. **Not claimed:** how often the model sends this shape (one recorded reply was
read), that its retry returns an array, or that this is the cause of B68 (`no 'files' list` in
`author_tests`) — that message comes from the same line, which is why it is suspected, but it is not shown.

3.58.0

Added

- **Every codegen model call and every test run of a run is now kept (B72).** In 9 of 29 benchmark runs a
refine pass ended with a summary that described a fix and no files, the loop stopped at "no file changes —
not fixable by editing code", and nothing written down could say why: the raw reply, the files the write step
dropped and the failing test output were kept nowhere. This is the instrument for that, **not a fix**: no
value a stage returns and no decision the loop makes changes. When a run is given a directory — `sdlc
autorun` hands it the run's artifacts directory — each model call of `implement`, `author_tests` (the
coverage pass included), `refine` and `revise`, retries included, writes `attempts/<stage>-<n>.json`: the
raw reply (capped at 32 KiB, and marked when cut), the summary, the outcome (`applied`, `empty`,
`unparseable`, `rejected`, `error`), every file that landed or was dropped with a one-word reason (`edit_failed`, `syntax`,
`missing_target`, `too_large`, `placeholder`, `skipped_existing_file`, `refused_unnamed_edit`,
`out_of_scope`, `weakened_test`, `shadows_module`, `not_an_object`, `no_path`, `no_content_or_edits`) and the hash of each named file before and after. Each `[run_tests n]`
output is kept as `run-tests-<n>.txt`, the last 64 KiB when it is longer, and one `[record]` line at the
start of the run names the directory. **On by default; `SDLC_ATTEMPT_RECORD=0` (or `false`, `off`, `no`) turns it off.** Every write
is best-effort — a record that cannot be written, or encoded, is one log line, never an error. Each
attempt file also holds what the model said beside its answer and why it stopped (`meta`: model, token
counts, finish reason), what the stage was asked to act on (`context`: refine's failure text, which
includes a type-check, coverage or required-behaviour failure, and revise's blockers), and a model
error that ends the call. A resumed run (`--resume` keeps the run id and so the directory) continues the
numbering and never replaces a file, and with recording off nothing is traced or hashed. **A raw reply
or a test's output can echo a credential**, so the value of every environment variable whose name says
KEY, TOKEN, SECRET, PASSWORD or CREDENTIAL (eight characters or more) is replaced by `[redacted]` before
anything is written, in its JSON-escaped form too, longest value first, and never a value that is an
existing path — it cannot catch a secret in a changed form such as base64 or inside a URL — and the reusable workflow
`spine-sdlc.yml` **leaves `attempts/` and `run-tests-*` out of the artifact it uploads**, because that
job holds `ANTHROPIC_API_KEY` and `GITHUB_TOKEN` and an uploaded artifact outlives the run. Limits: the
Temporal worker and the experimental agentic loop write no attempt record; the test runs inside the
review-fix loops and the pre-change baseline run (taken once before the loop) are not kept; a failure
longer than 16 KiB is recorded by its last 16 KiB and says so (`context_truncated`); and an exception
raised while building a prompt, before any model call, has nothing to record. ([SSPN-155](https://fibonacci-solutions.atlassian.net/browse/SSPN-155))

- **`SDLC_TEST_COMMAND` runs a project's tests through a command you supply.** The pipeline ran
generated tests only as `python -m pytest` from an interpreter it builds itself, so a project
that needs another interpreter (an old Django on Python 3.5) or another runner (`tests/runtests.py`)
could not be built at all. Set it to a JSON array — an argv, run without a shell — typically a
`docker exec` into an image that already has the project's environment. One element may be exactly
`{files}`; it expands in place to the Python test files this run has added or modified **since it
began**, read from git against the commit the run started from, so a review fix made after the
build was committed still finds the build's tests. **An empty selection fails with exit code 5
and never falls back to the whole suite**, and a command without the token runs as given. While
it is set the host builds no venv, skips its in-loop `mypy` pass (the host interpreter is not the
project's), takes no whole-suite baseline and does not try to auto-install a missing module into
the host; a malformed value stops `sdlc autorun` and `sdlc feature` with exit code 2 before any
model is called. It applies wherever the feature runner runs — `sdlc feature`, `sdlc autorun` and
the MCP tools that call it — for Python projects; the Temporal worker and the experimental
agentic tools build their runner directly and ignore it. Unset, nothing changes. If the project
is installed into the command's environment, put the worktree first on `PYTHONPATH` there — an
installed copy otherwise shadows the files Spine edited. The command is run as you and is read
from the environment or `.env` like the other `SDLC_` settings; the run log names only its
program, since an argument can carry a credential. ([SSPN-137](https://fibonacci-solutions.atlassian.net/browse/SSPN-137),
[spec](docs/specs/sandboxed-test-execution.md))

- **A SWE-bench Verified harness: run Spine's pipeline on benchmark instances and keep honest books.**
`orchestrator.evals.swebench` plus `scripts/swebench_prep.py` and `scripts/swebench_run.py` (standard
library only; the official `swebench` grader stays separate and pinned). **There is no benchmark
number yet** — this is the instrument, exercised against the real images of the twelve Verified
repositories (the in-loop test command ran on each; see the runbook for what did not pass) and on a
handful of real runs, not a result. What it does: it builds each instance's
generation repository from the pinned image as one fresh commit (no history, remote or future object);
refuses an instance file that carries a gold field (the patch, the test patch, the grading tests, the
issue's comment thread) and hands generation a type that has no field to hold one; runs the pipeline
as its own process group under a wall-clock limit and a spend cap, with only an allow-listed
environment (the model key is the only thing read from a `.env`); runs the project's tests in a
throwaway offline container from the instance image with only the run's changes laid over it (a
mounted clean checkout was measured to fail on a compiled repository); and files every instance in
exactly one of six buckets — resolved, unresolved, parked, generator-error, budget-stop, grader-error
— refusing a summary over a ledger with a missing or ungraded row (`scripts/swebench_report.py` settles
the books from the official grader's report). The generator starts from each instance's base commit,
not the image's `HEAD` (SWE-bench's setup commit is not empty), exported by a checkout rather than
`git archive` and compared tree-for-tree with the image before it is used; the first version of that
check refused six of twelve repositories and was fixed. The total cap is a projection over recorded
spend, not a tripwire, and a `generator-error` is re-run only with `--retry-generator-errors`. A run
that ends without a change is `unresolved`; a crash is a `generator-error`. Limits: a C or Cython edit is not rebuilt in the test
container, and a test that already fails in a file a run modifies stays red there. How to run it:
[`docs/evals/swebench-harness.md`](docs/evals/swebench-harness.md).
([SSPN-138](https://fibonacci-solutions.atlassian.net/browse/SSPN-138))

Fixed

- **A C run judges only what it caused, and stops before spending anything when it cannot
(B73, [SSPN-156](https://fibonacci-solutions.atlassian.net/browse/SSPN-156)).** NSS-1243 ended
`FAILED after 6 test run(s)` with the feature unwritten: after the model fixed its own first error,
five attempts went on three unrelated `.razor` files. Two causes, both in code, neither shown by a
raw log yet (**live confirmation is owed** — the run's output was not kept, and the build needs a
private NuGet feed). (1) `DotnetTestRunner` took no part in the pre-change baseline, so failures
the repository already had looked like the run's own; and refine may edit any file a failure
names. It now names the tests that failed, a baseline is taken, and the failing tests it recorded
are left out of the text refine reads. (2) `dotnet_errors` read only `path(line,col): error`, so a
restore failure (`NU1301`), an MSBuild task failure and a bare `error CS…` line matched nothing and
refine was handed the last 4,000 characters — warnings, on a project with 138 of them. Those
errors are read now, warning lines no longer fill the tail, failing tests lead when no error
explains the failure, and a failure that still explains nothing says `UNPARSED FAILURE … warnings
are not the cause` instead of inviting an edit to silence them. **What you will see:** a C run
whose repository does not compile before any change, or whose machine cannot build it (an
unreachable or unauthorised NuGet feed, no matching SDK), now ends `VERDICT: BLOCKED` (exit code
2) before generating anything and hands the ticket back; a baseline that times out stops nothing.
Mid-run, an unreachable feed, a missing SDK or the time limit end the run; a package the change
itself added that does not resolve, or a project file asking for an SDK the machine lacks, still goes
to refine. A run whose test host crashed, or whose own summary disagrees with the failures named,
is **unreadable** — it names none, so "every failure predates the change" can never pass a run whose
tests did not finish; an `error CS…` inside a failing test's own block is that test's message
text, not a compiler error, while an MSBuild error line anywhere else is one — even when another
project's tests ran and printed a summary, as they do when one project of a solution fails to
compile. Because recognising a build error is a guessing game that three review rounds each
defeated with a real input, the one verdict that must be right does not rely on it: while
error-like text (`error CS…:`, a code-less `error :`) appears anywhere in the output, a run is never
judged "only the old failures" and refine is shown the whole output. **Cost:** a repository whose old
tests print such text never gets that shortcut and is judged as it was before the baseline existed
(the baseline is still recorded). `SDLC_TEST_BASELINE=0` skips the baseline, the BLOCKED check and
the mid-run stops; the digest changes (errors without a position, no warnings in the tail,
`UNPARSED FAILURE`) apply either way. **Unchanged:** every pytest baseline behaviour, and
Maven/Node/Go/Rust runners, which have the same gap and are not covered here. **Verified on:** real
xunit 2.7 output on .NET 10 (captured as fixtures) and a real `dotnet` for the compile-error,
unreachable-feed and nothing-to-build cases. **Not verified:** NUnit and MSTest — a test is
identified by the name vstest's console logger prints, and those adapters may print the method name
alone, in which case an old failure could hide a new one with the same short name. Measured on four
synthetic outputs: one hid its cause from refine before (an `MSB` error with 138 warnings after it),
none does now. A maintainer review of the first version found a false green (a crashed host), a
false BLOCKED (message text that looks like a compiler error) and an incomplete strip; all three
are fixed and pinned on the real output; a second review then found a regression in the first fix
(a multi-project solution), now fixed, and a third round's real input (a custom MSBuild
`<Error>` task) by the refuse-only rule above. 58 deliberate breakages of the change were all
caught (two survived a first sweep until tests were added for them).

- **Codegen's tidy-up no longer rewrites compatible code into syntax the repository's Python cannot run
(B69).** After writing, codegen runs `ruff check --fix` and `ruff format` on the files the run
created (B64). Where the repository configures no ruff, ruff applied its own default rules and
default target, and it reads a declared minimum only from `pyproject.toml`, and only for 3.7 and up
— so a floor declared in `setup.cfg`, `setup.py` or classifiers (every Django instance in the
SWE-bench pilot) was invisible to it. A model that had written `typing.Dict`, `Optional[str]` and a
nested `with` for a Python 3.6 repository got `dict[...]`, `str | None` and a parenthesised
multi-item `with` back. Measured on Django 3.0's `tests/` (1,720 files, valid on 3.6) through the
real function: ruff's default introduced 36 parenthesised `with` statements; with the floor given
to ruff it introduced none. Files in a repository that configures no ruff now get
`--target-version` from the declared minimum Python (the same reader as the TARGET PYTHON
paragraph), clamped to ruff's range of py37 to py315. **Unchanged:** which files are tidied (B64),
ruff's rule set, repositories that configure ruff (their config governs and no flag is passed),
and repositories that declare nothing. A pass that changes files now writes one log line per group (with and without the
repository's ruff config) naming the files, relative to the repository, and the target, never their
content, so a rewrite is visible in `autorun.log`.
**Limits:** ruff's lowest target is py37, so a 3.6 floor is approximated (checked at syntax level
on the Django corpus, not executed on 3.6); a repository at 3.9 still gets ruff's parenthesised
`with`, which Python 3.9's parser accepts although it is documented as 3.10; ruff still
reformats whole files it is allowed to touch, as B64 left it. Whether this was why the SWE-bench
Django instances failed is not shown until they are re-run with their files kept.
([SSPN-152](https://fibonacci-solutions.atlassian.net/browse/SSPN-152))

- **Codegen is told the oldest Python the repository declares (B65).** It wrote tests and code for the
interpreter it runs on and never asked which one the project targets: in a 20-instance SWE-bench
pilot, 5 instances failed because the model wrote `str | None`, `list[str]` or a parenthesised
multi-item `with` for repositories whose tests run on Python 3.6 or 3.8, the tests could not be
imported, and refine spent its passes on syntax. For a **Python** target the conventions block that
implement, author-tests, refine and revise already receive now carries one deterministic **TARGET
PYTHON** paragraph naming the declared minimum and the syntax and standard-library features newer
than it. The minimum is read, never executed, from `pyproject.toml` `requires-python`, then
`setup.cfg` `python_requires`, then a *literal* `python_requires="…"` in `setup.py` (tokenized, so a
comment or docstring is not a declaration, and classifiers are read only from a `classifiers = [...]`
list), then the lowest `Programming Language :: Python :: X.Y`
classifier (last, because classifiers go stale). A specifier built at run time
(`'>={}.{}'.format(...)`, a concatenation, a name) is not a literal and falls through; an unreadable
or malformed file is skipped. A repository that declares nothing, or declares `>=3.12`, gets exactly
the block it got before. **Not changed:** the `episteme/conventions.md` page that `understand` writes
(the paragraph is an instruction to a model, not an observation) and the conventions block of every
other language's toolchain. **Limits:** it is guidance to the model, not a check — whether the model
obeys is measured separately, and a deterministic check of the written files is the next step only if
it does not. It targets the *declared* floor, so a repository whose test environment is older than its
declaration is not covered, and a stale low classifier makes the paragraph stricter than needed. The
feature table is keyed to each feature's documented release: `with (a as x, b as y):` is listed under
3.10 although 3.9's parser accepts it. Poetry's `python = "^3.8"` dependency is not read. The table
covers Python 3 features from 3.6 up: a declared 2.x or 3.5 is told about those, and nothing about
`print`, `unicode` or `yield from` idioms. ([SSPN-146](https://fibonacci-solutions.atlassian.net/browse/SSPN-146))

- **Codegen no longer reformats files it did not create in a repository that has no ruff configuration
(B64).** After writing, codegen ran `ruff check --fix` and `ruff format` over every file the model
touched, existing ones included. With no ruff config ruff applies its own default rule set, so a
one-line edit came back as a whole-file rewrite — measured on the first real SWE-bench attempt as a
53,109-character patch over 8 files for a change of a few lines, which also made every benchmark
patch about the formatter. A file is now tidied only if this run created it, or if the repository
configures ruff (the nearest `ruff.toml`, `.ruff.toml` or a `pyproject.toml` with a `[tool.ruff]`
table, up to the repository root; a bare `pyproject.toml` is not a config). A caller that cannot say
what the run created gets no reformatting. **Behaviour change:** in a repository without ruff
configuration, edits to existing files are no longer auto-reformatted; new files still are (and so is a
blank file the model fills, such as the scaffold's empty `__init__.py`), and a repository that configures
ruff (Spine's own does) is unchanged. **The cost, not measured:** a line the model adds to an existing file
in a different style is no longer silently corrected, so where preflight runs `ruff format --check` it can
now cost a refine round. The `write_files` tool of the agentic
loop now remembers which files it created, so a later repair pass still tidies them. That record is
the adapter's run-wide one, so refine's lint allowance for "test files this session created" (B47) now
also covers files the agentic loop created, which it did not before.
([SSPN-145](https://fibonacci-solutions.atlassian.net/browse/SSPN-145), B64)

3.57.1

Fixed

- **OpenSpec drafts preserve stated requirements context.** Intent extraction now asks for
and carries an explicitly stated problem, users, observable outcome, and non-goals into
the generated proposal. It does not invent missing fields. The source title from a
structured response is also retained. Refresh an existing cached draft with
`orchestrator openspec draft --refresh` to re-extract its source.
([spec](docs/specs/intake-backlog-progress.md))
- **Feature coverage and acceptance review see the actual change.** The feature runner ignores
`.benchmark-tmp` test scratch when identifying application code, while the acceptance judge
receives a bounded tracked diff before source excerpts. This prevents temporary test files
from counting as feature coverage and lets the judge inspect changed lines directly.

3.57.0

Added

- **`blast_radius` and `explain_symbol` say how many calls they could not trace.** The graph binds
a call only through a receiver's declared type (on purpose: a wrong edge is worse than a missing
one), so a call through an untyped receiver left no trace and the tools could not say so. For a
Python function they now return `unresolved_calls` — the count of attribute calls with the same
name whose receiver the graph could not type (as call **sites**, in function bodies — module-level
and class-body code is not scanned), a bounded list of them (caller, `file:line`, the receiver's
source text), and how many functions in that repository share the name — and the markdown gains a
"Possible untraced callers" line. It is a **hint, never a caller**: it is not part of
`caller_count` or of any edge, because the same name is also a `str.find` somewhere else. On this
repository `FactStore.find` has 35 untraced calls and 2 are the store; the receiver text beside
each is what tells them apart. The list is `null` with a note — never `0` — for a language that
does not record refusals (every front-end except Python's) or when it cannot be loaded; `0` means
"tracked, and none were recorded in function bodies". Limits: only calls to a function the call's own repository
declares; with more than 25 sites the first 25 by file path are listed and the count stays exact.
The records live beside the graph cache in a `.unbound.json` sidecar (about 2% of its size); the
graph itself, `pkg verify`, `understand` and the accuracy gate are unchanged. The first run after
upgrading re-extracts each cached repository, as any change under `pkg/` does.
([SSPN-133](https://fibonacci-solutions.atlassian.net/browse/SSPN-133), B62)

Fixed

- **`blast_radius`, `explain_symbol` and `docs_for` no longer hide matches without saying so.**
A name shared by many symbols was cut to its first five with no total, so the one you asked about
could be missing and the answer still read as complete — on this repository `summary` has 28
matches and `FactStore.summary` was never shown. Each tool now details the first **7** (it was 5)
and, when it resolves a symbol, reports `match_count` and `truncated` (a "Showing 7 of N matches"
line in the markdown); a qualified name (`FactStore.summary`) or a full node id selects one
symbol. A plain name resolves exactly as before. A name with a `.` or `:` now also matches ids
that *end* with it — on this repository two dotted names gain results and none lose any — and a
repository with no docs still answers `docs_for` before resolving the symbol, so that answer
carries neither key. ([SSPN-112](https://fibonacci-solutions.atlassian.net/browse/SSPN-112), B52)
- **`blast_radius`'s description said what it does not do.** It claimed a "cross-layer set a change
ripples into (CALLS + IMPORTS + REFERENCES)"; it reports `touches` — every node one edge away, in
either direction, of any kind — and the transitive walk (4 hops) runs only with `repos=`. The
tool description, `AGENT_GUIDE.md` and the `understand-codebase` skill now say so.
([SSPN-84](https://fibonacci-solutions.atlassian.net/browse/SSPN-84), B40)
- **`blast_radius`'s description now says it lists only the callers the graph can type.** The graph
binds a call only through a receiver's declared type (on purpose: a wrong edge is worse than a
missing one), so a call whose receiver the source does not declare — in Python, an `Any` or
unannotated parameter, a name bound twice, a closure, a chain — is not a caller, and the tool did
not say so. The description (and `explain_symbol`'s, the guide and the skill) now say
`caller_count` is a floor, and the markdown `Called by (N, at least …)` line says so too (B62).
On this repository 11 functions took `store: Any` and hid 23 of the 137 call sites on a
store-named receiver we could recognise; they are typed now (a `TYPE_CHECKING` import, no runtime
change), so 100 → 123 of those sites are in the graph and `FactStore.touches` went from 0 callers
to 2. The other 14 (11 ambiguous bindings, 3 closures) are not reshaped. That is Spine's own code:
another repository's untyped receivers are unchanged. Reporting how many calls could not be bound
is tracked separately ([SSPN-133](https://fibonacci-solutions.atlassian.net/browse/SSPN-133), B62;
closures: [SSPN-132](https://fibonacci-solutions.atlassian.net/browse/SSPN-132), B61).
([SSPN-131](https://fibonacci-solutions.atlassian.net/browse/SSPN-131), B60)

3.56.0

Spine now checks requirements, not only builds from them. Whoever drafts a requirement — a
person, Claude in a chat, a Confluence page — Spine runs a deterministic clarity gate over it,
says which of its criteria already name code that exists, and records the answers people give
with the channel it actually observed. **No new model call anywhere in this.**

Added

- **`orchestrator openspec check <change> [PATH|--repos]`.** The strict clarity gate: a stated
problem, named users, an observable outcome, and every open question answered or deferred to
a named owner. With a repository it also lists the criteria that already name code that
exists (evidence, not a verdict) and those naming code the graph cannot find; with none it
says `ungrounded` rather than reporting nothing. Exit 1 when the gate fails. Works on any
OpenSpec change, however it was drafted. ([spec](docs/specs/mcp-plugin-surface.md))
- **`orchestrator openspec answer <change>`** (`--question`/`--answer`, `--defer-to`, or
`--answers FILE`). Writes the answer under its question in `proposal.md` as a nested bullet
with the channel Spine observed (`cli` / `answers-file` → `user`). The edit is surgical — a
change a person wrote keeps its prose — and all-or-nothing across a batch.
- **`orchestrator openspec draft --idea "<one sentence>"`.** A skeleton change, no model call:
the why-fields (problem, users, outcome, non-goals) are its open questions, in fixed wording;
answering one fills the field it names.
- **MCP tools `requirements_check` and `requirements_answer`.** Over MCP every answer is
recorded as `relayed`, never `user`: Spine cannot see who typed it, so it never claims a person.
- **Why-fields on `Intent` and `FeatureSpec`** (`problem`, `users`, `outcome`, `non_goals`,
`idea_id`, `resolutions`), and an OpenSpec layout for them (` Problem` / ` Users` /
` Outcome` under ` Why`, ` Non-goals` under ` What Changes`, `idea:` under the title,
answers nested under their question). Every new field is **absent when empty**: an input with
no why produces a byte-identical cache and build-document digest, so no existing approval moves.
- **Gap-rule check kinds `pattern` and `question_state`**, and a strict rule set —
`examples/gap_rules/requirements_gaps.yaml`.

Changed

- **A cache written with why-fields cannot be read by an older Spine.** `Intent` and
`FeatureSpec` are strict, so an older version reading an entry that carries `problem`,
`users`, `outcome`, `non_goals`, `idea_id` or `resolutions` treats it as a miss and
re-extracts — which makes an approval granted on the old spec stale. Only caches that *have*
a why-field carry the new keys; everything else reads and writes exactly as before. Same
shape as the 3.45 `variants` note.
- **A legacy `proposal.md` that already has ` Problem`, ` Users`, ` Outcome` or
` Non-goals` is now read as the why-fields**, not as part of the description or scope
(a bare ` Why` or ` What Changes`, and any other subsection, reads exactly as before).
- **`ingest` now shows up to four extra *warning* findings** for an intent that states no why
(`problem_stated`, `users_named`, `outcome_stated`, `non_goal_named`; plus
`outcome_observable` and `criteria_testable` when there is something to judge). They never
gate: what `ingest` blocks on is unchanged. `examples/gap_rules/intent_gaps.yaml` mirrors the
new default set.
- **The spec writer no longer resolves open questions silently.** An answered question is
passed as a stated fact; an unanswered one is listed in `technical_notes` as a labelled
*Unanswered assumption*, replacing "resolve in technical_notes". Only a **re-extracted** spec
differs (`--refresh`); cached specs and their approvals do not move.

Fixed

- **A malformed structured response no longer ends codegen outright.** A client that parses a
forced tool call before returning (the Codex app adapter, or a native client raising
`StructuredOutputError`) raised before the parse retry could see any text, so one bad response
stopped a run during test authoring. It now gets the same bounded corrective retry as text that
fails to parse, then a `CodegenError`; transport, timeout and budget errors still raise at once.
(SSPN-127, B57)

3.55.0

Extensive Rust support broadens the Core Rust release with measured async, procedural
macro, `no_std`, and Cargo feature boundaries on supported hosts.

Added

- **Extensive Rust validation.** Three hand-labelled corpus cases cover Tokio-style
async calls, procedural-macro source boundaries, and `no_std` with `core`/`alloc`
imports and conflicting `cfg` branches. All 14 Rust cases retain 1.00 precision
on every emitted fact kind; the three known cross-module/workspace `CALLS` misses
remain labelled. Pinned real-repository runs cover mini-redis, async-trait,
embedded-hal, and Synaptreesitter, including one non-default Cargo feature and
a clean-checkout async codegen patch. The extractor now normalizes external
`core`/`std` trait aliases, and the verifier follows Rust semantic module
containment. Before governed codegen, the runner detects a missing cross
compilation target, linker, or custom JSON target and reports an actionable
refusal. Macro expansion, type-aware dispatch, full feature matrices, and
embedded firmware execution remain outside this release.
([PR 516](https://github.com/synaptixs/spine/pull/516),
[validation](docs/evals/rust-extensive-validation.md))

Page 1 of 13

© 2026 Safety CLI Cybersecurity Inc. All Rights Reserved.