Skip to content

Red CI fixed, wind-down decision layer, and the ruled 0.2.1 work - #16

Merged
NetDevAutomate merged 16 commits into
mainfrom
fix/red-ci-tests
Sep 5, 2026
Merged

Red CI fixed, wind-down decision layer, and the ruled 0.2.1 work#16
NetDevAutomate merged 16 commits into
mainfrom
fix/red-ci-tests

Conversation

@NetDevAutomate

Copy link
Copy Markdown
Owner

Summary

Works through the acceptance-harness plan (reviews/2026-09-04-acceptance-harness/PLAN.md) items 1–5 and 7, plus the ordered docs rulings and the sub-hour fixes from reviews/HANDOFF.md §4.

  • Item 1 — the three red CI tests, each root-caused from the CI logs, not re-budgeted:
    • grace-release test now polls the settled lifecycle (the _pending pop), not the first observable; mutation-verified against a broken liveness poll
    • accessible-name smoke check uses exact=True — "Generate" also substring-matched the empty-state CTA, which renders exactly on deck-less CI machines
    • wait_for_function never awaits async predicates (a returned Promise is truthy on the first poll) — six call sites were silent no-ops; all now go through _env.await_async_predicate, which has its own positive control
  • R-92: topic_frequency's FTS query was doubly broken (ambiguous content, multiple MATCH constraints); get_study_history works again, with tests over the real export schema
  • Item 2 — Layer 1: studyloop brain wind-down --json [--connector NAME] + WD-1 (8-row truth table), WD-2 (subprocess, byte-identical sentence, artefact), WD-3 (three-location sentence pin + the xTiles skill pin). Two rules, not one conjunction (D1)
  • Item 3 — 0.2.1 wording rulings (ARBITRATION Q2 rows 1–4, N1–N4, N6): the guide says what was actually proven, the planner prompt is the verified P1b tile wording, the OpenCode skill link is dropped
  • Item 4 — R-93: studyloop plan record + record_plan_learning MCP tool; parse → append → save_plan; idempotent; ADR-0010 clause 1 amended to the rule the code obeys; wind-down records into the plan first
  • Obsidian residuals O1/O4/O7: no directory created outside the vault; dry run previews the replace warning; unreadable mode refuses the replace
  • Item 5 — Layer 2: stub xtiles MCP server + WD-4 (refuses to run unlogged; AST no-network scan; mutation-verified)
  • Item 7 — OpenSpec: change reconciled truthfully then archived; ADR-0010 Accepted; release-check gains the shipped-changes guard and the always-on ADR-status check, both shown to fire

Testing

Not in this PR

  • Item 6 (first live capture, WD-5/WD-6, just gate-checks) — spends model money and needs owner credentials
  • Q5 step 3 hooks (early-warning wrappers) — two of three harnesses' block semantics are unverified; the hard gate is in release-check
  • The 0.2.1 cut itself — owner action, after CI is green on this SHA

…irst effect

test_agent_exit_while_detached_releases_immediately broke out of its poll
the instant active.current() went None, then immediately asserted the
grace timer had been popped. But release() clears the slot FIRST and only
then awaits transport.end() plus an executor hop before _deferred_release's
finally pops _pending — so the assert raced that tail and lost twice on
loaded CI runners (runs 33849778047, 33821410455) while always winning
locally. The previous de-flake (iteration count -> deadline) could not fix
this because it rebudgeted time instead of reordering observables.

Poll for the pop itself — the last effect in the release path — so every
assert runs against a settled lifecycle, the same philosophy _disconnect
already documents. The test still fails when the liveness poll is broken:
mutation-verified by forcing _transport_alive() -> True, which turns
exactly this test red (the 30s grace window cannot expire inside the 15s
deadline).
get_by_role(name=...) is a substring match, so 'Generate' also matched the
Today view's empty-state CTA 'Generate content →' — a strict-mode violation
on any machine with no decks, which is CI's state (run 33857350882) and
rarely a dev machine's. Data-dependent, not flaky.

exact=True resolves each sidebar button uniquely; the CI failure log itself
shows Playwright naming the sidebar button as
get_by_role('button', name='Generate', exact=True) alongside the CTA, and
the file already uses that exact locator for the same button elsewhere.
The sidebar SVG icons carry no accessible text, so the exact names are the
span labels. Verified: just test-browser-smoke, 20/20.
… Python

page.wait_for_function does not await a returned Promise: the Promise
object itself is truthy, so every 'async () => ...' predicate passed on
its first poll no matter what it would have resolved to. Verified
empirically — wait_for_function('async () => false') returns instantly.

That silent no-op is how phase 5 of the body-double journey went red on
main (run 33859082268): its 'wait until the server holds both notes' guard
never waited, and the active_total read raced the second POST on a loaded
runner. Six call sites carried the same defect — one had already failed,
five were latent.

Add _env.await_async_predicate, which drives the predicate through
page.evaluate (which DOES await) on a Python-side deadline, and convert
all six sites. Positive control per the campaign's trap #1: an
always-false predicate times out, an eventually-true one passes at the
flip. Sync predicates keep using wait_for_function, which is cheaper.

Verified: test_body_double_journey 13 passed; test_session_recovery_journey
+ test_ghostty_dev_terminal 40 passed, 2 skipped.
…d merge MATCH (R-92)

Two defects in one line. The join carries a content column on BOTH sides
(messages_fts.content and messages.content), so the unqualified
'content MATCH ?' raised 'ambiguous column name: content' on every call —
R-22b then correctly re-raised it, so get_study_history failed outright.
And even qualified, the shape OR'd one MATCH constraint per keyword, which
FTS5 refuses ('unable to use function MATCH in the requested context'), so
the multi-keyword path — the normal path for every caller — was broken
either way.

Replace it with ONE qualified MATCH whose FTS5 query string ORs the
keywords, each double-quoted as a phrase: several study terms carry spaces
('window functions', 'lake formation') and unquoted they would parse as
loose AND'd terms and match scattered occurrences. An empty keyword list
now returns [] without opening the DB, where it previously built
'WHERE ()' — a syntax error.

Tests build the REAL agent-session-tools schema.sql (the ambiguity only
exists in the real shape) and were verified red against the old code:
single keyword, OR'd multi-keyword, phrase-not-scattered-terms, and the
empty-list guard.
…nd-down --json

Layer 1 of the acceptance-harness plan (WD-1..WD-3). The protocol used to
ask the agent to derive the one second-brain offer from brain status flags,
which left two of the three acceptance gate checks human-only. The decision
now lives in code and the agent reads it as one JSON object:
{channel, offer, sentence, reason}.

Two rules, deliberately not one conjunction (design council D1):
- publish: configured AND supports_publish. Vault writability is NOT part
  of the rule — available is a runtime condition the publish itself reports.
- xtiles: provider == 'xtiles' AND an 'xtiles' connector attached in this
  session, passed by the caller via --connector because connector state is
  per-session — which is also why this is a subcommand, not a field on
  brain status. One conjunction would have made the xtiles channel
  permanently false, since XtilesStageOneBackend sets supports_publish=False
  on purpose.

No command field in the payload: nothing guarantees a plan id at wind-down,
and a half-filled command string is worse than none (council ruling).

WD-1 (test_wind_down_decision.py) pins all 8 truth-table rows against the
pure function; WD-2 (test_wind_down_cli.py) re-runs every row through the
real CLI in a subprocess with a real config file, byte-compares the
sentence, and writes the wind-down-truth-table.json artefact; WD-3 extends
the docs pin to a third location — the CLI's own constants — and adds the
xTiles sentence pin (skill <-> CLI). The protocol and the skill shrink to:
run the command; if offer is true say sentence verbatim once, else say
nothing about second brains.

Mutation-verified: inverting truth-table row 7 turns the row-7 tests red;
drifting one byte of the protocol sentence turns the pin tests red.
Import boundary preserved: wind_down.py imports no provider module
(test_second_brain_optionality.py still green). 110 tests across the six
touched files pass; pyright clean.
…rulings

ARBITRATION Q2 rows 1-4 plus addendum items N1-N4 and N6, with the
replacement sentences the review wrote out.

Row 1 (the BLOCKER-class honesty defect): 'three tested prompts' and
'written for Claude Code, which is where it was tested' both overstated the
evidence. The prompts have now been exercised end to end exactly once, by a
person, in Kiro CLI 2.21.0 — not Claude Code — so the guide and core.py now
say precisely that, using the sentence from the owner's filled checklist.

Row 2 / C5: the planner prompt becomes the P1b wording the owner verified —
a Markdown tile, not a task, because a tile is the only shape that returns
a URL and the only one the live UI check has validated. The guide names
which prompt produces a URL.

Row 3: the offer-once claim is hedged to what the evidence supports: the
skill instructs it; no recorded session shows it yet.

Row 4: harness wording narrowed (paths verified against vendor docs;
behaviour verified per the run), and the OpenCode skill link is dropped
from installers.py — the hub is already on OpenCode's global search path,
and nothing verified it de-duplicates two hits by name.

N1: the guide and the skill now say the next action and due reviews are the
learner's whole study state, not one plan's. N2: the project prompt stops
promising a board view or collection-page refreshes; the connector's
resource types bite before any tier does. N3: the wind-down prompt skips
the Review task when nothing is due instead of inventing a date, and the
guide carries the R-93 sentence: the xTiles learning record is not written
back into the plan. N4 (as corrected): planner tiles can be removed through
the connector; pages and projects are deleted in the UI by hand. N6: the
permission-prompt sentence is attributed to xTiles, with what you see
depending on the assistant.

76 tests across the docs/protocol/installer files pass; mkdocs builds.
…_plan_learning (R-93)

LearningRecord was constructed in exactly one place in the package: the
Markdown parser. So a record existed only if the learner typed it into the
plan document by hand — and for an xTiles learner following the wind-down
prompt, the session's learning record existed only in xTiles, inverting
ADR-0010 (the second brain became the sole store, the plan stopped being
the record of what was learned). Found as N8 in the 2026-09-04 review.

The writer follows the arbitrated design exactly:
- parse -> append a LearningRecord -> save_plan, never an append of raw
  Markdown: save_plan re-renders through render_plan, so the on-disk shape
  ('### LR-0004 — Title') stays the renderer's business and the projection
  and template guards keep pinning it unchanged.
- numbering is max(existing)+1, so a superseded record keeps its number and
  gaps are never reused.
- re-running with the same title and body is a byte-level no-op (the vault
  writer's idempotence rule), which makes agent retries safe.
- one guard the council did not ask for but the parser demands: H1-H3 lines
  in a body are refused, because _split_sections/_subsection_items would
  re-parse them as new sections or records on the next load (and
  _subsection_items ignores code fences), silently corrupting the document.

Surfaces: studyloop plan record <plan-id> --title T [--body|--body-file]
[--status] [--json], and a record_plan_learning MCP tool for the mentor at
wind-down. The guide's wind-down prompt and the xTiles skill now record
into the plan FIRST, then offer the xTiles projection — the page is a
projection of a record the plan already has, never the only copy.

ADR-0010 clause 1 is amended to the rule the code obeys (per
FINDING-adr-0010-already-inaccurate.md): the original 'nothing in a
backend, the CLI or an agent protocol writes the plan file' was untrue the
day it shipped — five plan commands write it by design. The amended clause:
nothing in the SECOND-BRAIN layer writes it; studyloop plan … is its only
writer, through render_plan.

16 new tests (store/CLI/MCP); 120 across the touched surface pass; pyright
clean.
O1 — no directory is ever created outside the vault. mkdir(parents=True)
ran BEFORE the containment re-check, so an ancestor swapped for a symlink
between projection_path and the write created directories on the far side
of the link before the file write was refused. Containment is now checked
on the nearest EXISTING ancestor before mkdir (resolve() on unbuilt
directories cannot see where a hostile link sends children), re-checked
after mkdir as before, and once more immediately before os.replace —
narrowing the accepted TOCTOU window to the rename itself. The new test
asserts the property that matters: nothing appears outside the vault, not
merely that an error was raised.

O4 — the dry run previews the replace warning. classify_write returned
WRITTEN for an owned-and-changed note, so --dry-run said 'would write'
where the real publish then said 'written + replaced your edits' — the one
warning added so a learner is told BEFORE losing text was invisible in the
preview. classify_write now returns REPLACED, plan_dry_run carries the
'would replace your edits in …' warning, and journey beat 10 asserts the
preview warns (and that the dry run itself does not touch the edit).

O7 — an unreadable mode refuses the replace. The 0o644 fallback on any
OSError meant a note the learner had locked to 0o600 could be silently
rewritten world-readable if one stat failed. A vanished file still gets
the 0o644 new-file default (the inode-identity check refuses the race);
any other stat failure on an existing note now refuses with the reason.

All three ruled MINOR-with-small-fix by ARBITRATION Q3. 95 tests across
the writer/backend/CLI files pass; 35 journey tests pass; pyright clean.
Makes 'a connector named xtiles is attached' REAL for the transcript
acceptance tests without a network, an account or an OAuth flow: a stdio
server named xtiles exposing the three write tools the prompts select
between (create_tasks, create_tiles_from_markdown_in_my_planner,
create_view_from_markdown), appending every call to a JSON artefact.

Deliberately dumb — no validation, no state, no response fidelity beyond
the one thing the prompts check (the planner-tile tool returns a URL).
Anything smarter would be a second xTiles implementation for tests to
accidentally depend on.

Two teeth worth naming. The stub REFUSES to run without a call-log path:
a stub that ran unlogged would pass WD-5's silence checks vacuously, the
exact positive-control trap (D4) this suite exists to avoid. And 'reaches
no network' is enforced two ways — stdio transport by construction, plus
an AST scan that goes red if a network-capable import creeps in.

WD-4 drives the real handshake with the official SDK client: server name
'xtiles' (the name IS the skill's gate), exactly the three tools, every
call logged in order, appends never overwrites. Mutation-verified: making
_log_call silently drop a call turns both log tests red.
… the next cut (Q5)

0.2.0 shipped the whole second-brain layer while its change sat at 0/19
tasks, unarchived, with a proposal still selling the withdrawn Obsidian-CLI
adapter — an agent following docs/contributing.md found no capability spec
and instructions to build something three families cut. Ruling: archive-
and-reconcile, never archive fiction (ARBITRATION Q5).

Reconciled BEFORE archiving: proposal.md's What Changes and Risks now match
design D4/D12 (adapter withdrawn; the four keys it used are refused with a
naming error); tasks.md records what actually happened — shipped items
ticked, the adapter and daily_note struck, the per-lane verifier items
replaced by the P2 council that actually reviewed the merged diff, and the
owner prompt run ticked against its filled checklist (run 2026-09-04, Kiro
CLI 2.21.0). Then openspec archive second-brain, which wrote the
second-brain capability spec into openspec/specs/ and applied the two
deltas. ADR-0010 is now Accepted (it shipped), here and in the index.

The guard, so this cannot recur: check-release-consistency.py gains a
--release mode wired into release-check via the new
release-consistency-shipped recipe (NOT preflight — open changes are legal
during a cycle; only shipping one is not). It fails when any directory
under openspec/changes/ (archive/ excluded) has commits since the last tag
and is neither archived nor carrying an explicit 'deferred: <reason>' in
its .openspec.yaml, and it validates archive entries added since the last
tag with the openspec CLI (soft-skip when absent, same convention as
spec-check; scoped to NEW archives because a July archive predating the
guard has unticked tasks nobody has evidence to reconcile). The always-on
ADR check fails any ADR present in the last tag that still says Proposed.

Positive controls, per this campaign's trap #1: setting ADR-0010 back to
Proposed turns the always-on check red (proving it would have fired on
0.2.0); restore turns it green. The shipped-changes guard's control run
follows in the next verification step, since it needs a committed change.
…fix archive-name parse

The Justfile half of the Q5 guard commit (missed there: the tracked file is
'Justfile', and the add used the lowercase name). release-check now runs
release-consistency-shipped — the --release superset — while preflight keeps
the plain recipe, since open changes are legal during a cycle.

Also fixes validate_new_archives' name extraction (parts[3], not parts[2] —
which read 'archive' as the change name) and records the positive controls
run against the finished guard: a committed, undeferred open change turned
the gate red with the change named; adding 'deferred: <reason>' to its
.openspec.yaml turned it green with the reason printed; the newly archived
2026-09-04-second-brain validates.
…ng .text

pyright: call_tool content is a union (Text/Image/Audio/ResourceLink/
EmbeddedResource); assert the type discriminant first, which is also the
stronger test.
The gate resolves bare-name helpers from a test's source; a method on the
test class ('self._run') is an attribute call it cannot follow, so 'plan
record' read as uncovered. The invocation now lives in a module-level
helper the gate can see.
Copilot AI lite review requested due to automatic review settings September 4, 2026 14:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The Unreleased changelog currently contradicts the new “record learning into the plan first” behavior and should be corrected for accurate public documentation.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR resolves previously red CI by fixing flaky/incorrect Playwright waits and accessibility selectors, and it adds a new “wind-down decision” layer plus a plan learning-record writer so end-of-session behavior can be driven by deterministic JSON (CLI + MCP) and pinned documentation.

Changes:

  • Add studyloop brain wind-down --json decision command + decide_wind_down() (with tests pinning truth-table rows and pinned offer sentences across code/docs/skills).
  • Add studyloop plan record + record_plan_learning MCP tool (idempotent parse→append→save via renderer) and related tests.
  • Strengthen release gates and second-brain safety: new release-consistency guards (ADR status + OpenSpec shipped/archive checks), Obsidian writer TOCTOU containment hardening, and docs/spec/ADR updates to match ruled wording.
File summaries
File Description
scripts/check-release-consistency.py Adds release-check mode guards for ADR status staleness and OpenSpec “shipped but unarchived/undeferred” changes + validates newly archived changes.
Justfile Switches release-check to run the new release-consistency shipped-mode superset.
packages/studyloop/tests/test_xtiles_stub_server.py Adds tests for the stub xtiles MCP server (handshake/tools/logging + no-network import scan).
packages/studyloop/tests/_xtiles_stub_server.py Introduces a stdio-only stub MCP server named xtiles with JSON call logging.
packages/studyloop/src/studyloop/second_brain/wind_down.py New pure decision module with pinned offer sentences and JSON payload shape.
packages/studyloop/tests/test_wind_down_decision.py Pins the wind-down truth table as a pure function and asserts “two rules, not one conjunction”.
packages/studyloop/tests/test_wind_down_cli.py Pins CLI output (python -m studyloop … brain wind-down --json) to the same truth-table contract and writes an evidence artifact.
packages/studyloop/src/studyloop/cli/_brain.py Adds studyloop brain wind-down command producing decision JSON/human-form output.
packages/studyloop/tests/test_second_brain_docs.py Pins publish offer sentence across docs/protocol/CLI constant and pins xTiles offer sentence to the skill.
packages/studyloop/tests/test_second_brain_agent_protocol.py Updates protocol contract test to require brain wind-down --json (not brain status --json) and retains flag explanation checks.
agents/shared/wind-down-protocol.md Updates Phase 1 second-brain offer flow to delegate to the decision command and handle xTiles via the skill.
agents/skills/studyloop-xtiles-wind-down/SKILL.md Updates skill gate to use the decision command and adds “record into plan first” rule + pins offer sentence in delimited markers.
agents/skills/studyloop-xtiles-wind-down/references/harnesses.md Updates harness path reference to reflect hub-served OpenCode behavior.
packages/studyloop/src/studyloop/installers.py Removes OpenCode per-harness xTiles skill link; documents why (hub already on global search path).
packages/studyloop/tests/test_install_agent_contracts.py Updates installer contract expectations for OpenCode hub-served behavior (no per-harness link).
docs/second-brain.md Updates xTiles guide wording to match observed behavior, documents limits, and adds the wind-down decision command.
packages/studyloop/src/studyloop/planning/store.py Adds record_learning() writer: idempotent learning-record append with heading-guard validation.
packages/studyloop/src/studyloop/planning/init.py Exposes record_learning in the planning public API.
packages/studyloop/src/studyloop/cli/_plan.py Adds studyloop plan record CLI command with JSON output and --body-file support.
packages/studyloop/src/studyloop/mcp/tools.py Adds record_plan_learning MCP tool mapping errors to ToolError.
packages/studyloop/tests/test_plan_record.py Adds unit/CLI/MCP coverage for record-learning writer, numbering, idempotence, and validation.
packages/studyloop/src/studyloop/history/search.py Fixes FTS query (qualified MATCH + OR inside query string + empty-keywords fast path).
packages/studyloop/tests/test_history.py Adds tests over the real export schema to prevent ambiguous-column and multi-MATCH regressions (R-92).
packages/studyloop/tests/test_web_smoke_browser.py Uses exact=True for accessible-name smoke checks to avoid substring strict-mode collisions on CI.
packages/studyloop/tests/e2e/_env.py Adds await_async_predicate() helper to correctly poll async JS predicates.
packages/studyloop/tests/e2e/test_session_recovery_journey.py Replaces wait_for_function(async …) with await_async_predicate() and adjusts timeouts.
packages/studyloop/tests/e2e/test_ghostty_dev_terminal.py Replaces wait_for_function(async …) with await_async_predicate() and adjusts timeouts.
packages/studyloop/tests/e2e/test_body_double_journey.py Replaces multiple async wait_for_function usages with await_async_predicate() and improves “what” diagnostics.
packages/studyloop/tests/test_session_ws_grace.py Fixes flake by polling settled lifecycle (pending release pop) rather than first observable effect.
packages/studyloop/src/studyloop/second_brain/obsidian_writer.py Hardens containment checks (pre-mkdir + post-mkdir + pre-replace) and refuses unreadable-mode replaces; improves dry-run classification via REPLACED.
packages/studyloop/src/studyloop/second_brain/obsidian.py Makes dry-run preview replacement warnings consistent with real publish warnings.
packages/studyloop/tests/test_obsidian_writer.py Adds regression tests for containment-before-mkdir, dry-run replace warning classification, and unreadable-mode refusal.
packages/studyloop/tests/journeys/test_obsidian_learners_week.py Adds journey assertion that dry-run previews “would replace your edits” warning without performing replacement.
packages/studyloop/src/studyloop/second_brain/core.py Docstring wording tweak (“supplied prompts”).
docs/adr/README.md Marks ADR-0010 as Accepted.
docs/adr/0010-second-brains-are-projections.md Updates ADR-0010 status to Accepted and amends clause 1 wording to match actual plan-writer rule.
openspec/specs/second-brain/spec.md Adds spec for the second-brain capability at the OpenSpec layer.
openspec/specs/configuration-and-secrets/spec.md Adds spec requirement for parsing second_brain into config with one-line errors.
openspec/specs/cli-surface/spec.md Adds spec requirement for lazy brain registration and --json command behavior.
openspec/changes/second-brain/tasks.md Removes active change tasks (archived instead).
openspec/changes/archive/2026-09-04-second-brain/tasks.md Archives reconciled implementation task list per review ruling.
openspec/changes/archive/2026-09-04-second-brain/specs/second-brain/spec.md Stores archived spec snapshot for the change.
openspec/changes/archive/2026-09-04-second-brain/specs/configuration-and-secrets/spec.md Stores archived config spec snapshot for the change.
openspec/changes/archive/2026-09-04-second-brain/specs/cli-surface/spec.md Stores archived CLI-surface spec snapshot for the change.
openspec/changes/archive/2026-09-04-second-brain/proposal.md Updates archived proposal to reflect CLI-adapter withdrawal + retired key refusal.
openspec/changes/archive/2026-09-04-second-brain/design.md Adds archived design write-up including decision table and withdrawn adapter rationale.
openspec/changes/archive/2026-09-04-second-brain/.openspec.yaml Adds archived change metadata.
CHANGELOG.md Adds Unreleased notes for new commands/guards and xTiles guide updates (contains one inconsistency noted in PR comment).
agents/manifest.json Updates manifest hashes/dates for changed agent/protocol/skill artifacts.
Review details
  • Files reviewed: 44/49 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread CHANGELOG.md
Comment on lines +39 to +44
also now says that the next action and due reviews sent to xTiles reflect your
whole study history (they are not plan-scoped), that per-write permission
prompts are xTiles' statement about its connector and what you see depends on
your assistant, that the xTiles learning record is not written back into the
plan document, and that planner tiles can be removed through the connector
while pages and projects must be deleted in the xTiles interface by hand.
…s it caught

Layer 3, plan item 6. Claude Code headless (the one harness with attested
flags) through the LiteLLM gateway - no vendor credential; the key is read
from the proxy's own config at call time, enters the child env only, and is
never printed or passed in argv. Opt in with "just gate-checks"; artefacts
(per-state transcripts, summary.json, tool-routing.json) land under
gitignored reviews/.

The three gate checks, graded on real transcripts with the arbitrated
assertions and nothing stronger: sentence at most once across the whole
transcript; silence = no pinned sentence and no second-brain vocabulary in
assistant prose WHILE the D4 positive control holds (the transcript must
show that "studyloop brain wind-down" actually ran, or a crashed run would
pass by absence); a declined offer leaves the stub log empty - with the
xtiles tools ALLOWED, so declining was a choice, not an inability. 3/3 for
the offer-once-then-decline state; one pre-response infra retry, flakes
counted in the artefact, never smoothed.

Capturing found four real defects; each is fixed here and visible in the
transcript evidence:
1. Protocol violation - the harness narrated the step ("Running the second
   brain check now"). The protocol now opens step 5 with the silence rule
   and names narration a violation.
2. Fabricated connector flag - given a literal "--connector xtiles" example,
   the model passed it with NO server attached, manufacturing an offer. The
   protocol now grounds the flag in the mcp__<server>__ tool names the
   session can actually see, and calls it a statement of fact.
3. D2 exactly as the council predicted - the hermetic HOME hid the skill and
   "Unknown skill: studyloop-xtiles-wind-down" killed an offer turn. The
   world now runs the REAL "studyloop install agents" into the isolated
   home, which also exercises the installer for free.
4. A gateway bug - LiteLLM's anthropic-passthrough adapter (Bedrock invoke
   path) dies on extended-thinking blocks ("Content block is not a text
   block"), deterministic on thinking-length prompts. Worked around with
   MAX_THINKING_TOKENS=0 in the harness env; proxy upgrade is the real fix.

Final capture: 6/6 pass, 0 flakes. WD-6's observation (records, never
gates): the P1b planner wording selected
xtiles_create_tiles_from_markdown_in_my_planner - matching the owner's hand
run. Spend recorded in the ledger: est $1.50, actual $4.45; the overrun is
Claude Code's ~30k-token own system prompt per call plus five debug
re-runs, both now named in the estimate file so the next one models them.

[message restored 2026-09-04: the original -m string was double-quoted and
its backticked command names were command-substituted by the shell, which
ran a paid gate-checks capture mid-commit and pasted pytest output into
this message. The accidental run's evidence is preserved under
reviews/2026-09-04-gate-checks/evidence/gate-checks-capture2-accidental/.]
…y naming it

An accidental extra capture (see below) caught a third violation shape the
first two rules missed: after "No thanks.", the mentor said "The learner
declined, so no xTiles write happens" — naming the declined service in the
very turn that was supposed to close the subject. The protocol now lists it
alongside the other two named violations and states the rule positively.
Re-captured after the change: S3 offer-once/decline/silence 3/3, 0 flakes.

The accidental capture itself is a lesson recorded here so it is not
repeated: the previous commit ran `just gate-checks` mid-commit because its
message was passed with -m "..." (double quotes) and contained backticked
command names — zsh command-substituted them, executing a paid live run and
pasting pytest output into the committed message. That mangled message is
in the pushed history of this branch; evidence of the run is preserved
under reviews/…/gate-checks-capture2-accidental/. Commit messages with
backticks go in single quotes or a file from now on.
…s wrappers (Q5 step 3)

EARLY WARNING, NOT ENFORCEMENT, and labelled so in every file: the hard
gate stays `just release-check` + CI. The hook exists so a release action
is questioned at the keyboard instead of failing twenty minutes later —
and per the arbitration's own caution, a hook that cannot be shown to
block is never described as enforcement. Verification state is recorded
per harness in the script header: Kiro's PreToolUse exit-2 block is
verified against the harness's own hook documentation; Claude Code's is
vendor-documented, unattested here; Codex's events were verified against
the vendor page in the 2026-09-04 review.

One body (scripts/openspec-gate.py, tool call as JSON on stdin), the same
hub pattern as the xTiles skill; the wrappers are committed (force-added
past the harness-dir gitignore rules, the same convention as openspec/) at
.kiro/hooks/openspec-gate.json, .claude/settings.json and .codex/hooks.json
so contributors inherit them. The gate IMPORTS the real release guard from
check-release-consistency.py rather than re-implementing it — two
implementations of "what counts as shipped" is how a hook and a release
gate come to disagree.

Behaviour: `git tag` / `prepare-release` are blocked (exit 2) when an
openspec change with commits since the last tag is neither archived nor
deferred; `git commit` gets a one-line warning and is NEVER blocked — open
changes are legal during a cycle; anything else exits 0 with no subprocess
spawned. UserPromptSubmit gets a terse reminder only when the guard would
fail. The hook fails OPEN on its own errors: a broken early warning must
never block work the hard gate would allow.

Tests drive the script in a subprocess against fixture repos carrying the
REAL consistency script: block-on-tag, warn-on-commit, deferred unblocks,
garbage stdin fails open, and a wrapper-drift pin that all three wrappers
call the one body. Full preflight green: 4790 passed, 4 skipped.
@NetDevAutomate
NetDevAutomate merged commit 185c53a into main Sep 5, 2026
17 checks passed
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
Version 0.2.1 in both pyprojects (and the lockfile), the CHANGELOG
Unreleased section rolled into [0.2.1] - 2026-09-05, and a real release
note in releases/v0.2.1.md summarising the review-driven work: the
wind-down decision command, the learning-record writer, the xTiles
wording corrections, the Obsidian writer hardening, the history-search
fix, and the acceptance suite with its release guards.

Cut per the Q6 ruling: from a green main (PR #16, all 16 checks), with
the tag to follow only once GitHub CI is green on THIS sha — the release
gate (release-consistency --release) passes locally, including the new
shipped-changes guard and the newly archived change's validation.
Full default suite on this sha: 4790 passed, 4 skipped.
@NetDevAutomate
NetDevAutomate deleted the fix/red-ci-tests branch September 5, 2026 11:21
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
Red CI fixed, wind-down decision layer, and the ruled 0.2.1 work
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
Version 0.2.1 in both pyprojects (and the lockfile), the CHANGELOG
Unreleased section rolled into [0.2.1] - 2026-09-05, and a real release
note in releases/v0.2.1.md summarising the review-driven work: the
wind-down decision command, the learning-record writer, the xTiles
wording corrections, the Obsidian writer hardening, the history-search
fix, and the acceptance suite with its release guards.

Cut per the Q6 ruling: from a green main (PR #16, all 16 checks), with
the tag to follow only once GitHub CI is green on THIS sha — the release
gate (release-consistency --release) passes locally, including the new
shipped-changes guard and the newly archived change's validation.
Full default suite on this sha: 4790 passed, 4 skipped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants