Skip to content

chore(release): bring release/v1.7.0 up to main (82 commits since v1.6.2) - #188

Closed
Oleg67 wants to merge 82 commits into
constructorfabric:release/v1.7.0from
Oleg67:chore/release-v1.7.0-sync
Closed

Oleg67 wants to merge 82 commits into
constructorfabric:release/v1.7.0from
Oleg67:chore/release-v1.7.0-sync

Conversation

@Oleg67

@Oleg67 Oleg67 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Brings release/v1.7.0 up to the tested state of main.

What this is

release/v1.7.0 was cut directly from the v1.6.2 tag, so it currently points at e3a283fd — byte-identical to v1.6.2. main is 82 commits ahead of it and zero commits behind, i.e. the release branch is a strict ancestor:

git merge-base --is-ancestor release/v1.7.0 main   # true
git rev-list --left-right --count release/v1.7.0...main   # 0   82

So everything that has landed since v1.6.2 is in this one PR, and it applies as a fast-forward — no conflicts, no cherry-picks, no rewritten SHAs. The head commit here is 309003df, the same object as main.

Please merge with a merge commit or fast-forward — not squash

Squashing would collapse 82 independently reviewed commits into one and detach the release tag from the history that produced it. Every commit below was reviewed and CI-gated in its own PR; this PR is a transport, not a change.

What is included

144 files, +39,763 / −559. Grouped by area, with the upstream PR numbers:

Area Changes
PDSL and gates gate-resolution ledger (#168), blocking EMIT_MENU through the harness question dialog (#184), declaration-region precedence (#167), plan production gate pair (#163), mode/gate-type carve-out (#162), declared gate risk with a TYPE header and lint (#153), ADR for autonomous interaction default (#151), CDSL FAIL-rule enforcement and dashless DO/RULES handling
change-summary advisory branch digest (#149), window and decision-log resolution (#125), changed files → declared requirements (#133), shallow clones and streamed sweep (#154), path newline fix (#160)
Retrieval: cascade and doc-index heading-nav, two-tier routing, large-read gate (#111), round-3 findings that missed main (#135), OKF build signal from Tier-2 escalations (#136), heading-nav/TF-IDF fusion (#137), section-granularity inference and caching (#109), JIT-retrieval readiness checks (#108)
spec-coverage advisory --semantic pass (#122), semantic-coverage engine (#105), symlinked-root resolution (#126), applicability on an empty scope (#93), usable per-file granularity floor
Evaluation harness deterministic structural compliance scorer (#94), advisory rule-compliance judge with gold-set calibration, and the review series hardening it
cf-ux evals let the cf skill execute under claude -p and refuse to score a run where it did not (#161), skill-name matcher trade pinned (#171)
decision-log local decision/outcome log writer, dispatcher and validator instrumentation, $HOME redaction at a path boundary, write-failure surfacing
Traceability and validate shared cpt-id def↔ref scan extracted to a util (#165), code checks reached when a scan resolves to no files (#95), one exclusion policy for registry entries (#106), where-used --include-code, list-ids dedupe preferring the definition
artifact-quality shared advisory finding model + JSON schema (#127)
UI and docs skill-invocation ASCII art default-off (#166), first-class OpenCode support (#71), Ring B/C competitive analysis (#65), Pass 3 citation fixes (#157)
init and CI source-root registration (init), enforcement-gate corpus as its own check (#121), CI fails loudly when act job discovery yields no jobs (#156)

What is not included

Version pin

No version-bump commit is included, deliberately. Per ADR-0021 (GitHub Release Provenance as Version Authority) the Release/tag is the version authority and "the maintainer creates a GitHub Release/tag and does not perform any extra local version bump for release recognition". The repo's own history follows that order: v1.6.2 was tagged while .bootstrap/version.toml still read v1.6.1, and the pin moved to v1.6.2 on main afterwards. So tagging v1.7.0 from this branch is the release action; the .bootstrap pin bump lands on main after, as it did last time.

Verification

main @ 309003df is green on all 20 required checks, including Test / Validate Artifacts / Validate Kits on Python 3.11–3.14, Spec Coverage, Pylint, Dead Code Scan, Enforcement Gates, SonarQube and SonarCloud. Because this PR is a fast-forward, the tree tested there is exactly the tree this PR delivers.

ainetx and others added 30 commits July 24, 2026 12:05
Signed-off-by: ainetx <viator@via-net.org>
A stdlib-only, local-only JSONL writer that records what the engine decided per run (routing, dispatch, validation, review, escalation, invocation), correlated by run_id and decision_id. Local-only (no network), opt-out honoured (env var + sentinel file), never raises into the caller, no-op outside a project, home paths redacted, size-based rotation, plus read/summarize helpers. Records command name / exit code / duration / arg-shape, never raw argv. Traced under cpt-studio-algo-core-infra-decision-log (declared in architecture/features/core-infra.md); cfs validate passes 216/216 and spec-coverage meets thresholds. Adds a .gitignore entry so the log is never committed. Command instrumentation is a deliberate follow-up.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
The per-file coverage gate (make test-coverage, min 90%) failed on
decision_log.py at 84.71% — the defensive and filter branches were not
exercised: env/in-project path resolution, the opt-out/redaction/rotation
fail-safes, the fcntl-absent append fallback, and read_events'
missing-file / unreadable / non-dict / filter / limit paths.

Add targeted tests for each; no production change. Per-file coverage for
decision_log.py: 84.71% -> 100% (diff coverage 100%), full suite green.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
_redact() used a naive str.replace, so a sibling path was mangled when
$HOME was a prefix of it — home /Users/max turned /Users/maxine into
~ine. Substitute $HOME only at a separator/end boundary via re.sub, and
skip redaction when home resolves to root. Add a boundary regression test.

Addresses CodeRabbit review on PR constructorfabric#79.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
…sion-telemetry

Add local decision/outcome log writer
…sPath

test_posix_absolute_registered_root_fails_on_windows patched os.name to "nt"
and drove the full migration. pathlib.Path.__new__ reads os.name at call time
to choose its concrete flavour, so the patch turned every Path(...) in the
call graph into a WindowsPath, which pathlib refuses to instantiate on a
POSIX host. The test therefore failed for every contributor on Linux and
macOS with "cannot instantiate 'WindowsPath' on your system", instead of
asserting anything about the rejection it was written to check.

Split it in two. A new unit test asserts _resolve_same_os_absolute_path
returns None for a POSIX absolute path under Windows semantics; that branch
constructs no Path at all, so it runs everywhere. The end-to-end variant is
now guarded to Windows hosts, mirroring the sibling test that already guards
itself to non-Windows.

The Windows branch stays covered on every platform and the suite is green on
POSIX: 4383 passed, 4 skipped, 15 xfailed.

Signed-off-by: ou <ou@constructor.tech>
- cli.py: a single fail-open dispatch wrapper around handler(rest) records one
  invocation event (command, exit code, duration, safe arg-shape) for every
  command, on normal and exception exits, without changing command behaviour.
- Correlation: a contextvars decision id, established once per run and scoped to
  it, so events inside a command (validation) chain to the invocation.
- validate / spec-coverage / validate-kits / self-check record their final
  verdict via the run's id (record() is fail-safe, so the hooks are one-liners).
- tests: telemetry isolated to a throwaway path; new test_cli_telemetry covers
  emission, correlation, fail-open, crash logging, and id scoping.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
record() caught write/serialization errors internally and returned
False, so an unwritable log dropped every event silently while the
dispatcher's guard (which only fired on a raised exception) never
triggered.

- record() now emits exactly one WARNING on the first real write
  failure, then latches telemetry off for the run so it neither spams
  events nor keeps retrying a target we already know is unwritable.
- Redact the exception before logging it: an OSError carries the
  absolute log path, $HOME included.
- Drop the now-dead fail-open guards from the dispatch wrapper; id
  minting, the contextvar set, and record_invocation are all
  non-raising, so nothing there can throw.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
…metry-dispatch-wrapper

Instrument the command dispatcher + validators with decision/outcome telemetry
…ute-path-test-guard

test(kit): correct the Windows path assertion that fails on Python 3.11
Add `cfs eval`: score completed workflow runs for how faithfully they
followed their plan. This is the scaffold — a scenario format and a
runner — that a deterministic structural scorer and an advisory
LLM-judge plug into later.

- Scenario format: a per-directory scenario.toml pointing at a completed
  run (plan.toml + phase-*.md), with an expected-outcome oracle and an
  optional gold-set reference.
- Runner + Scorer seam: pluggable scorers tagged deterministic or
  advisory. Only deterministic verdicts contribute to structural
  compliance; advisory results are reported but never gate.
- Opt-in gating: --check fails (exit 2) when structural compliance is
  below --min, or on a per-scenario regression vs --baseline; eval
  otherwise reports and exits 0. --save writes a report as a baseline.
- Honest signal: an unloadable run or a raising scorer degrades to
  UNKNOWN (never a silent zero or a crash); the report states its own
  coverage and a failing-check histogram.
- Ships a labelled placeholder reference scorer plus a compliant and a
  deliberately non-compliant fixture; the real structural scorer and the
  judge are separate follow-ups.

CPT-traced; stdlib-only.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
…bustness

Address review findings on the eval-harness scaffold:

- Reference scorer: a run whose phases declare no checkable file is UNKNOWN, not
  a vacuous 100% pass.
- Regression contract: --check gates on a per-scenario compliance drop or a
  scenario that broke (was scoreable, now unscoreable); a scenario removed from
  the suite is surfaced but does not gate; an unusable --baseline under --check
  fails closed (a check that could not run is not a pass).
- --min is validated (finite, in [0,1]); the [scenario] section and the baseline
  report shape are type-checked; diff_reports is self-guarding against a
  malformed baseline; non-numeric baseline compliance is ignored, never a crash.
- The regression key is always present under --baseline (a diff, or an error
  object), for a stable JSON schema.
- --save writes atomically via a unique temp file + replace, kept readable (0644).
- The report carries a machine-readable gate field consistent with the exit
  code; --check help and the feature doc document the full gating contract.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
…ontract

- --save: revert to a simple sibling-temp atomic write (no mkstemp/chmod),
  clearing two SonarCloud vulnerabilities (S2612 permissive chmod, S8707 path)
  that the over-engineered version introduced; still atomic (temp + replace).
- --check gates on has_regression only. An unusable --baseline is surfaced via
  the regression `error` field but does not by itself fail the build (the
  documented warn-and-skip contract); the --min floor still applies.
- Exclude booleans from baseline compliance (bool is an int subclass).
- Correct the module docstring's gate description.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Add explicit guard tests so each reviewed issue class has a dedicated case:
the gate field on a --check pass, --check help documenting the baseline
regression path, an empty suite scoring an honest zero, and --check gating on
a scenario that broke while still present.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
An unhashable/non-string scenario id in a baseline row (e.g. a list) raised
TypeError when used as a dict key. diff_reports now indexes only string ids
(a non-string id never matches a real scenario anyway). Test added.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
…sal gate

The atomic temp+rename renamed into the user-supplied --save path, which
SonarCloud flags as a gate-failing path-traversal vulnerability
(pythonsecurity:S8707). Revert to a direct write to keep the security-rating
gate green; the atomic pattern can be restored if the S8707 is marked a
false positive (it is one — the user chooses their own --save path).

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Reinstate the atomic temp-file + replace write (a crash mid-write cannot corrupt
an existing baseline), per review. SonarCloud's S8707 path-traversal flag on the
user-supplied --save path is a false positive for a CLI file argument — the user
chooses their own path — and is marked as such in SonarCloud rather than worked
around; a plain write is flagged identically, so no code change clears it.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Use tempfile.mkstemp for a unique per-save temp file (kept without an explicit
chmod so it doesn't re-trip SonarCloud's permissive-permission rule), so
concurrent saves can't race the same .tmp and no unrelated file is clobbered,
while keeping the atomic temp+replace write.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
OLE-41: `list-ids` (default, no `--all`) kept whichever hit for an ID
appeared first in scan order, with no preference for its type. When an
ID's inline/reference mention physically preceded its formal `**ID**:`
definition line in a doc, the dedupe silently discarded the definition
record — dropping 17 of 51 expected definitions in one observed case.

`_dedupe_hits` now keeps a `"definition"`-typed hit for each ID whenever
one exists anywhere in the scanned hits, falling back to the first-seen
hit only when no definition exists at all. `--all` behavior is unchanged.

Fixes constructorfabric#81

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
OLE-42: `where-used` only scanned artifacts, never code — an ID with 87
code-marker references and 49 code edges would report 0, with no signal
that code was never looked at. `list-ids` already had a working
`--include-code` path; `where-used` never adopted it.

Move the shared codebase-scanning helper (`_code_paths_for_entry` +
`_scan_code_references`) out of `list_ids.py` into
`utils.codebase.scan_registered_codebase_references`, so both commands
(and any future caller) share one marker parser. `where-used` gains
`--include-code`, using the `ctx` `resolve_target_and_artifacts` already
returns but previously discarded. Without the flag, behavior is
unchanged (artifact-only).

Fixes constructorfabric#82

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
OLE-03: CDSL.md documents ~10 rules as FAIL severity, but error_codes.py
had no codes for them and there was no enforcement path — malformed CDSL
step lines were silently skipped by scan_cdsl_instructions instead of
flagged.

Add `_validate_cdsl_structure` to utils/constraints.py, wired into the
same per-artifact structure phase as the existing CDSL checks. Detection
is scoped to lines inside a real `**Steps**:`/`**Transitions**:` block
(with continuation-line joining), not line-shape alone — this repo's own
docs reuse the same `pN` priority-tag shape in DoD checklists, component
lists, and ADR prose that isn't CDSL at all, and line-shape-only
detection produced hundreds of false positives against those.

Missing-token rules (S.3/S.4/S.5/CO.4) ship as warnings rather than
errors: ~150 pre-existing CDSL steps across 12 architecture/features/*.md
files predate the inst-id convention (tracked separately in
constructorfabric#85), and promoting them to FAIL today would
break `make validate` for content outside this fix's scope. Every other
rule (S.6/S.7/CL.1-4, CO.5/CO.6) is a hard error, since real content has
~zero pre-existing hits — the one genuine hit found (workspace.md using
`==`) is fixed here as a one-line rewording.

Fixes constructorfabric#83

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
TK-02: the PDSL cap/graph check hard-required a leading `- ` on every
top-level DO/RULES/STATE/WHEN/INVARIANTS item (ACTION_HEAD_RE,
_is_valid_section_item_start, _check_section_cap), so a UNIT authored
without dashes was invisible to PDSL200 and the DO/RULES compactness cap
entirely — reported as ~363 affected files.

Sampling this repo's own PDSL content (workflows/*.md, skills/**/*.md)
showed the dashless style is actually the dominant convention already in
use, not a minority deviation, so the fix teaches the validator to
recognize it rather than rewriting the corpus:

- ACTION_HEAD_RE's leading dash is now optional.
- _is_top_level_item_start recognizes a bare `KEYWORD`-shaped line as a
  new item, but only within the sections that have keyword-led items
  (STATE/WHEN/DO/RULES/INVARIANTS) — OPTIONS and free-form sections are
  untouched, and indent depth (already checked by both call sites) still
  separates a genuine item from a continuation line.
- Whether the keyword is actually *valid* for the section is still
  PDSL200's job (_validate_starter), so an invalid dashless keyword is
  now caught instead of silently ignored.

PDSL.md documents the dashless form as equivalent to the dashed one.

Note: applying the existing cap thresholds (5 RULES / 7 DO actions)
uniformly surfaces a much larger pre-existing gap between those
thresholds and real prompt content (131 files, up from 28) — tracked
separately in constructorfabric#87, out of scope here.

Fixes constructorfabric#84

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>


Quality Gate failed on 3.2% new-code duplication (threshold 3%): the two
`where-used --include-code` tests shared a near-identical 16-line setup
block (temp artifact + mocked context/scan patches). Extract
`_run_where_used_with_mocked_scan` so both tests call one helper.

Also reduce `scan_registered_codebase_references`'s cognitive complexity
(27, limit 15) by splitting the per-file ignore-check, hit-building, and
per-file scan into small helpers (`_is_ignored_code_file`,
`_code_reference_hit`, `_scan_code_file_references`) — this function
moved from `list_ids.py` for OLE-42 and was flagged as new code.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
- list_ids.py: `--include-code` returned an empty result on a project
  with zero registered artifacts, because the empty-artifact early
  return ran before the code-scan block. Same class of bug already
  fixed in where_used.py for OLE-42, missed here since this is the
  code the where-used fix was mirrored from. Gate the early return on
  `not args.include_code` too, and add a CLI regression test.

- constraints.py: two false-positive paths in the CDSL structure
  validator (OLE-03). (1) Trailing prose after a `**Steps**:`/
  `**Transitions**:` block, separated only by a blank line with no
  following heading/label, was folded into the last step as a
  continuation — because `_iter_cdsl_block_lines` relied on
  `document._iter_non_fenced_lines`, which drops blank lines entirely,
  so the boundary was invisible. Blank lines are now preserved as an
  explicit item-closing signal. (2) The CO.5 duplicate-inst-id check
  ran against the unscoped `cdsl_hits` from `scan_cdsl_instructions`,
  so a `**Supporting**:` bullet reusing an inst-id from a real Steps:
  block could misfire; the check is now restricted to hits whose line
  falls inside an actual Steps:/Transitions: block.

- pdsl.py: `_handle_section_header_line` reset `do_count`/`rules_count`
  on every `DO:`/`RULES:` header, not just at a new UNIT boundary
  (already handled in `_handle_unit_or_menu_line`), so a UNIT with two
  DO: sections could split actions across them and bypass PDSL600.
  Reset now happens only per-UNIT.

- traceability-validation.md: corrected the OLE-03 spec text to match
  the implementation (missing-token findings are warnings, not errors;
  candidacy is block-scoped, not line-shape-based; duplicate-inst-id
  checking is block-scoped too).

- constraints.py: collapsed `_validate_cdsl_step_candidate`'s redundant
  `raw_line`/`stripped` parameters (always passed the same value) into
  one `step_text` parameter (nitpick).

Regression tests added for all three functional fixes.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
vulture-ci (CI mode) flagged _warn_list_ids as unused: its only caller
was _scan_code_references, which moved to utils/codebase.py (using its
own _warn_codebase) as part of OLE-42. Remove the now-dead wrapper;
`logger` itself stays, since _resolve_registered_artifact_scan still
uses it directly.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
CI surfaced a real TK-02 regression: architecture/specs/PDSL.md's own
"Core Shape" example nests an illustrative `MENU <name>:` line inside a
DO: block. `<name>` isn't a valid identifier, so it's not recognized as
a real MENU declaration — it fell through to dashless item detection,
which read the bare word `MENU` as an attempted DO action and failed
PDSL200 ("got MENU"). This passed cleanly before TK-02, since a
non-dashed "MENU <name>:" line was never recognized as an item attempt
at all.

UNIT/MENU are reserved block-starter keywords and are never valid in
any section's starter-keyword vocabulary, so excluding them from
_is_top_level_item_start's dashless recognition only removes false
positives — it can't hide a genuine dashless keyword mistake.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
CI's Pylint job flagged cmd_where_used with 18 local variables (limit
15) after OLE-42's --include-code additions. Extract the artifact-scan
loop and the code-scan-and-filter loop into
_collect_artifact_references/_collect_code_references, matching the
same shape list_ids.py's helpers already use. No behavior change.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
test_prompt_pdsl_blocks_pass_cfs_pdsl_validate asserted zero PDSL
findings across the whole prompt corpus. TK-02 made the DO/RULES cap
and PDSL200 starter-keyword check apply uniformly regardless of dash
usage, which correctly surfaced that 144 pre-existing files don't meet
the current thresholds/vocabulary (tracked in issue constructorfabric#87) — this test
has been red on every push since.

A blanket xfail would hide that gap from CI entirely (green check, no
visible signal) and stop protecting against regressions in this same
test. Instead, KNOWN_PDSL_CAP_VIOLATIONS records the exact (file,
rule_id) pairs already tracked by constructorfabric#87; the test now fails only on
findings NOT in that map — any new file, or any new rule_id on an
already-listed file, still fails loudly. As files get fixed, remove
their entries so the map shrinks toward empty.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
…hold

CI's Spec Coverage job failed on granularity 0.4596 < 0.4600. OLE-03
added ~270 lines of new CDSL-validation helpers to constraints.py
(already the largest file in the codebase) without wrapping several of
them in their own @cpt-begin/@cpt-end blocks, dragging the file's own
block-density score down enough to pull the whole codebase's
lines-weighted average under the gate.

Add markers around the previously-unwrapped helpers (_cdsl_missing_tokens,
_iter_cdsl_block_lines, _cdsl_block_line_numbers) and split the single
big block in _append_cdsl_prohibited_syntax_errors into one per check
(function-syntax, type-annotation, operator, plain-English) — each is a
genuinely distinct instruction, previously bundled under one marker.

Net effect: granularity_score 0.4620, above both the 0.46 gate and the
pre-PR baseline (~0.4614) it dipped below — a comfortable margin instead
of a razor's edge. `cfs validate` still passes clean (0 errors): fixed
one duplicate-@cpt-begin and one mismatched-@cpt-end introduced while
threading these new markers through nested regions of the same function.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Covers all 19 findings (2 issue-level, 17 inline) raised on PR constructorfabric#88:

- list-ids: surface conflicting duplicate ID definitions instead of
  silently dropping them (duplicate_definitions field + warning log)
- where-used/list-ids code scanner: fan out to reachable workspace
  source repos, apply default excludes for conventional non-source
  directories, bound file size, fail closed on unresolvable path
  containment, guard against a codebase entry escaping the project
  root, sort scanned files for deterministic output, and report
  code_files_scanned/code_files_skipped whenever --include-code is
  passed so "not used" is distinguishable from "used, found nothing"
- CDSL structure validation: recognize content that's already fully
  well-formed CDSL even without an explicit **Steps**:/**Transitions**:
  label (fixes a coverage gap on CDSL.md's own canonical examples and
  this repo's bundled kit example); cite CDSL.md rule IDs in all 10
  error messages; track the missing-token backlog (issue constructorfabric#85) with an
  explicit allowlist test mirroring the PDSL one; fix a live false
  positive where a `<ARTIFACT_KIND>`-style doc placeholder was misread
  as a `<T>`-style generic type annotation
- validate.py: run the LANG001 content-language check unconditionally
  instead of skipping it for the whole run whenever any artifact has
  an unrelated structural error
- fixing.py: add _REASONS/fixing_prompt entries for all 10 new CDSL
  error codes
- error_codes.py: suppress the Ruff S105 false positive on
  CDSL_MISSING_PHASE_TOKEN
- where-used: document --include-code's registered-paths-only scope
  and its --artifact no-op in both --help and architecture/specs/cli.md
- add an end-to-end test through cmd_validate's real call path for the
  CDSL structure check, and through cmd_list_ids for the dedupe fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
ainetx and others added 24 commits September 3, 2026 17:51
)

* feat(agents): add first-class OpenCode documentation

Signed-off-by: ainetx <viator@via-net.org>
Co-authored-by: Constructor Studio <291158726+constructor-studio[bot]@users.noreply.github.com>
Studio-Generated-By: Constructor Studio
Studio-Source-Repo: https://github.com/constructorfabric/studio
Constructor-Fabric: https://github.com/constructorfabric
Studio-Version: skill=1.0.0, cli=1.5.10
Studio-Workflows: cf-sdlc-doc-prd,cf-sdlc-doc-design,cf-sdlc-doc-feature

* feat(agents): add first-class OpenCode support

Signed-off-by: ainetx <viator@via-net.org>
Co-authored-by: Constructor Studio <291158726+constructor-studio[bot]@users.noreply.github.com>
Studio-Generated-By: Constructor Studio
Studio-Source-Repo: https://github.com/constructorfabric/studio
Constructor-Fabric: https://github.com/constructorfabric
Studio-Version: skill=1.0.0, cli=1.5.10
Studio-Workflows: cf-sdlc-implement,cf-code-planning,cf-documenting-gen

* fix(agents): address CodeRabbit and reviewer findings for OpenCode integration

- Fix atomic-write robustness in _save_opencode_unowned_outputs: warn on
  secondary cleanup failure, prevent lost-update races between concurrent
  writers via file locking + read-merge-write delta (add/remove), and use
  a unique per-call tmp filename instead of a deterministic one.
- Stop treating a path recorded as unowned as a permanent exclusion:
  re-check live ownership signals every run and reclaim the file once
  ownership is provable again (e.g. after a transiently missing sentinel).
- Make OpenCode ownership proof file-specific: require the file's
  frontmatter name to match its own filename, since the generic
  _GENERATED_MARKER literal is shared by every agent adapter and cannot
  alone prove a file was produced by the OpenCode template.
- Sync architecture/DECOMPOSITION.md and DESIGN.md with the implemented
  OpenCode scope/requirements/data and correct the OpenAI output surface
  claim (.codex/agents/) in both places it's stated.
- Qualify CONTRIBUTING.md's make ci description to name the sonarqube/
  code-ranker exclusions instead of claiming unconditional GH Actions parity.
- Add regression tests covering: invalid-path warning/exclusion, atomic-write
  failure/rollback, non-vacuous pipeline-execution assertions, ownership
  reclaim after sentinel loss, rejection of a generic-marker-only collision,
  positive two-run ownership idempotency, and --agent opencode write-path
  parity with --opencode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: ainetx <viator@via-net.org>

* fix(agents): address follow-up CodeRabbit findings on OpenCode ownership fix

- Fall back to a Windows-compatible sentinel lock (O_CREAT|O_EXCL + bounded
  retry, stale-lock recovery) when fcntl is unavailable, instead of silently
  proceeding without any lock at all.
- Register the new .opencode/.cf-studio-unowned-outputs.json.lock file as a
  managed output and add it to .gitignore so it stops appearing as an
  untracked runtime artifact after every generate run.
- Fix substring-containment bug in _is_opencode_owned_subagent: match the
  frontmatter name line exactly (e.g. cf-pr must not match cf-pr-review's
  name line), preventing a shorter-named collision from being silently
  adopted/deleted as if it were the longer-named agent's own file.
- Add regression tests for all three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: ainetx <viator@via-net.org>

* fix(agents): address second CodeRabbit review round on OpenCode integration

- Isolate path-traversal ValueError and write OSError in
  _write_opencode_subagent_or_preserve as a per-item error instead of
  letting them propagate uncaught and crash the whole generate-agents run.
- Count every preserved OpenCode output in the dry-run preview, not only
  opencode_unowned_collision, so stale-unowned preservations are no longer
  invisible in the aggregate count.
- Record a visible skip entry when a kit agent's name lacks the 'cf-'
  prefix, instead of silently dropping it from OpenCode generation.
- Split the compatibility fixture's required_frontmatter (what OpenCode
  itself requires) from studio_emitted_frontmatter (what this generator
  chooses to emit), and add a schema-aware test proving the check can
  distinguish compliant from non-compliant input.
- Surface collision reason, sentinel state, and preserved-output path/reason
  in the human-readable (non-JSON) renderers, matching what --json exposes.
- Clarify --agent's help text: omitting it targets the default set, which
  excludes opencode.
- Add CLI-level e2e coverage for the plain success path of
  generate-agents --opencode (previously only unit-tested), and a comment
  pointing to where OpenCode-bypasses-v2 coverage already lives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: ainetx <viator@via-net.org>

* fix(agents,ci): satisfy CI gates for OpenCode ownership-lock refactor

- Extract lock acquire/release, Windows sentinel fallback, and atomic
  write into small dedicated functions to clear pylint's too-many-locals/
  branches/statements on _save_opencode_unowned_outputs and
  _reconcile_opencode_subagents; fix the unused 'exc' and silent-except
  findings pylint flagged on the earlier refactor.
- Restructure the sentinel-lock retry loop so it no longer line-for-line
  duplicates utils/toml_utils.py's private Windows-lock helper
  (pylint duplicate-code), while keeping identical retry/stale-lock
  behavior.
- Add matching CDSL steps to agent-integration.md for the 8 newly
  extracted OpenCode helpers so their @cpt-begin/end markers aren't
  orphaned, restoring spec-coverage granularity above threshold.
- Add 10 unit tests covering the previously-uncovered failure branches in
  the new lock/atomic-write/skip-diagnostic code, restoring agents.py's
  per-file coverage above 90% under the CI's -n 6 run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: ainetx <viator@via-net.org>

* fix(agents): address third CodeRabbit/reviewer round on OpenCode ownership fix

- Stop auto-clearing the Windows sentinel lock by age: an active-but-slow
  writer is indistinguishable from a crashed one by mtime alone, so
  deleting it let a second process steal the lock mid-write and lose an
  exclusion update. On timeout the fallback now refuses to proceed and
  warns, leaving the sentinel in place, instead of removing it.
- Decouple the OpenCode install-marker write from whether the
  unowned-outputs record write succeeded: gating it on
  ownership_recording_failed left the marker permanently unwritten after
  any first-run record-write failure, which then made every subsequent
  run misclassify Studio's own already-generated files as
  ownership-unproven collisions.
- Add regression tests for both: one proving a live writer's lock survives
  a timeout, one proving the marker still gets written (and previously-
  generated files are recognized as owned on the next run) after a
  simulated first-run record-write failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: ainetx <viator@via-net.org>

* fix(agents): address new SonarCloud findings and a symlink path bug

- Deduplicate the ".opencode" literal (S1192) behind a shared
  _OPENCODE_AGENTS_DIRNAME constant used by all three call sites.
- Reduce _human_agents_list's cognitive complexity (S3776) by
  extracting the OpenCode row and the per-agent row into their own
  helpers, _human_agents_list_render_opencode and
  _human_agents_list_render_row.
- Fix generate_report()'s _rel() in utils/coverage.py: it compared an
  unresolved project_root against a resolved file path, so
  is_relative_to() was always False through a symlinked temp dir
  (e.g. macOS /var -> /private/var), and every "files" key silently
  fell back to the absolute path instead of the project-relative one.
  Resolve both sides, matching spec_coverage.py's own _rel_path().
  Covered by test_generate_report_files_key_survives_a_symlinked_project_root
  (verified red without the fix, green with it).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: ainetx <viator@via-net.org>

---------

Signed-off-by: ainetx <viator@via-net.org>
Co-authored-by: Constructor Studio <291158726+constructor-studio[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…t frameworks) analysis (constructorfabric#65)

Adds a Pass 3 adjacent-category analysis to COMPETITIVE-ANALYSIS.md covering
two categories converging on Studio's space but absent from the original tiering:

- Tier 4: enterprise agentic BPM / process-orchestration platforms (Appian,
  Pega, ServiceNow, Salesforce Agentforce, Microsoft Copilot Studio, UiPath,
  IBM watsonx Orchestrate, Camunda, Google/AWS agent platforms)
- Tier 5: developer agent-orchestration frameworks (LangGraph, CrewAI,
  Microsoft Agent Framework, n8n/Pipedream/Activepieces)

Includes a convergence thesis (Appian Composer's move into spec-driven dev,
MCP as connective tissue), a cross-category comparison matrix, a positioning
conclusion, strategic findings Rf-038..Rf-045, a Priority 4 action group, and
updated Summary Score rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
…log events inside it (constructorfabric#125)

* feat(change-summary): resolve the window a digest covers, and the events inside it

The decision log has recorded what the engine decided since it landed, and
`usage-report` now aggregates it per method across the whole log. What no reader
can answer is "what changed on this branch, and why": there is no git or window
logic anywhere in the log's API.

This adds the two halves that have no output format — which span of work counts
as "the run", and which recorded decisions fall inside it. Rendering and
requirement linkage are separate changes.

The window comes from the merge-base with the canonical remote rather than from a
decision-log `run_id`. A `run_id` is one CLI invocation, while a reviewer's "run"
is a branch's worth of work, so `run_id` becomes a grouping key inside the span
instead of the span itself. `upstream/*` is preferred over `origin/HEAD` because
in a fork-based workflow `origin` is the contributor's fork and lags behind —
measured five weeks behind on a real checkout, which would have silently widened
every window.

Every path returns a value carrying an explicit reason rather than raising or
going quiet. An unavailable dimension is named; an event whose timestamp will not
parse is excluded *and counted* rather than guessed into or out of the window;
unparseable log lines are reported as a lower bound on corruption. A requested
base ref is honoured or refused, never silently swapped for a discoverable
default. Reason strings carry no filesystem paths, so no home directory or
username can reach a rendered digest through them.

Git access is a narrow read-only query helper rather than a third general runner:
the two existing private helpers in this package have incompatible contracts, so
a generic copy would duplicate both.

Nothing calls this yet, so the public names are whitelisted for the dead-code
scan; the command wrapper follows.

`cfs validate` 231/231, 0 errors. `spec-coverage --system studio`: granularity
0.4606 -> 0.4614 against the 0.46 floor, coverage 90.50% -> 90.54% — the module
raises the margin rather than consuming it. 45 new tests, 100% line coverage on
the new module, full suite 5,173 passed.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): never present an unreadable log as an empty window

Two review findings, both real.

An existing but unreadable log produced an *available, empty* selection —
"no decisions in this window" having read nothing. `Path.is_file` only needs
`stat`, so a mode-000 log passes the probe, and `decision_log.read_events`
swallows the subsequent open failure and yields nothing. That is the exact
failure this module exists to prevent, so readability is now proved by opening
the file, and absent is reported separately from unreadable via a new reason.

`upstream/HEAD` now leads the candidate refs. It is the canonical remote's own
symbolic default, so it is right even when that default is neither `main` nor
`master`; guessing branch names first skipped it and fell through to the stale
fork ref, which is the same defect the ordering was added to prevent, one level
deeper.

Both fixes are mutation-checked: reverting either fails exactly one test, and
the failing test is the one written for it.

Full suite 5,176 passed. 48 tests on this module, 100% line coverage (146
stmts). pylint and vulture clean, `cfs validate` 0 errors, spec-coverage passes
at granularity 0.4613.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): bind the log to the window's project, and never raise on a bad log

Four maintainer review findings, all reproduced before fixing.

**The decision log followed the cwd, not the window's project.** `resolve_window`
takes an explicit project root; `default_log_path()` derived its location from
the current working directory. Those are independent inputs, so a window built
for project A while the process sat in project B selected B's decisions — a
digest describing one project's changes alongside another project's history.
`ChangeWindow` now carries the project it describes, and the log is resolved from
it. `default_log_path()` gains an optional start path so path knowledge stays in
one place rather than being reconstructed from private constants here.

**An undecodable log raised out of `select_events`.** The readability probe opened
the file but never decoded it, so `read_events` performed the first strict UTF-8
read and, catching only OSError, let UnicodeDecodeError escape — breaking the
never-raises contract outright. The probe now decodes, and the read loop is
guarded as well for the case where the file changes between the two.

**A git failure after the repository probe was reported as a fact about history.**
`_git_line` collapsed timeouts and launch failures into the same `None` as a
valid negative, so a transient failure surfaced as "no merge base" or "base
commit has no readable timestamp". Queries now return the value alongside a
tool-failure flag, and that flag takes precedence. A non-zero exit is
deliberately *not* a failure: `merge-base` and `rev-parse --verify` both exit 1
to mean "no", and treating those as breakage would mislead in the other
direction.

**Selected events with no run id vanished from grouping.** They were kept in
`events` but buckets were built only for truthy ids, so a renderer summing groups
under-reported without saying so. They now land in an explicit
`(unattributed)` bucket and are counted in `runless`.

Two knock-on cleanups: promoting the git helpers to the failure-aware form left
`_merge_base` and `_commit_time` as dead wrappers, so they became the real
implementations rather than being whitelisted; and splitting the base-ref walk
out of `resolve_window` keeps pylint's return-count rule satisfied without
suppressing a check the project is actively rolling out.

Full suite 5,238 passed. 108 tests on this module at 100% line coverage (267
stmts). pylint and vulture clean, `cfs validate` 0 errors, spec-coverage passes
at granularity 0.4616.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): complete the git diagnosis, validate the bound, canonicalise run ids

Four more maintainer findings, all reproduced first.

**A post-probe read failure was an available empty selection.** `read_events`
swallows its own open failure and yields nothing, so a log that vanished between
the readability probe and the read reported success having read nothing. Worse,
the mitigation I claimed for this yesterday did not work: `_count_log_lines`
returned 0 on a read error, so `skipped_lines` was 0 too and the failure left no
trace anywhere. That count now returns `None` on failure and is the detector for
the race, mapping to `REASON_LOG_UNREADABLE`.

**Base-ref lookup discarded the tool-failure signal.** The previous round taught
merge-base and commit-time to distinguish a git failure from a valid negative,
but left base-ref resolution on the value-only helper — so a timeout there still
surfaced as "requested base ref not found". Two of three stages were covered.
The default walk also stops at the first launch failure rather than trying eight
candidates and then reporting a fact about the repository that was never
established.

**An explicit `since` was accepted unvalidated**, failing later as a complaint
about a base commit that was never consulted. It is parsed up front now, with a
reason naming the caller's input. One of my own tests had encoded that behaviour
as correct; it is rewritten to cover the direct-construction path it actually
guards.

**Run ids were used raw as grouping keys**, so case variants split one run, a
numeric id merged with its own text, and whitespace formed an attributed group.
Ids are now stripped and casefolded, and non-strings are unattributed.

On that last point I did not adopt the suggested hexadecimal restriction, and the
reasoning is on the PR: it would discard a real distinguishing identifier by
folding it into the anonymous bucket, and `decision_log`'s schema is explicit
that "readers must ignore unknown event names and unknown payload keys so that
newer instrumentation never breaks an older reader". Stripping and casefolding
fix all three reported defects without a reader rejecting what it does not
recognise. Happy to add the stricter filter if the maintainers want it.

Also drops a stale claim that the unattributed label cannot collide with a real
id — that rested on the hex assumption. The property now pinned is the
consequence: such events merge into one bucket and none is dropped.

`select_events` gained a return branch, so log resolution is extracted to keep
pylint's return-count rule satisfied without suppressing a check the project is
rolling out.

Full suite 5,262 passed. 134 tests on this module at 100% line coverage (287
stmts). pylint and vulture clean, `cfs validate` 0 errors, spec-coverage passes
at granularity 0.4618.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): pin the repository and the project the answer describes

Seven review findings; the eighth is answered on the PR rather than changed.

**A relative project root left the cwd dependence in place.** Carrying the root
on the window was supposed to stop the log resolving from the current directory,
but `resolve_window(Path("."))` recorded `"."` — so a later chdir redirected log
resolution again, and `subprocess(cwd=...)` re-resolved the relative path at call
time rather than at capture time. The root is resolved now, in both entry points.
Verified: the recorded root is absolute and survives a chdir.

**An ambient GIT_DIR overrode `cwd=`.** Verified —
`GIT_DIR=b/.git git -C a log` reports b's commit, not a's — so every query could
silently answer about a different repository than the one named. The git
environment is sanitised of the seven variables that redirect repository
location.

**Caller-controlled refs reached git without an end-of-options separator**, so a
ref beginning with a dash was read as an option. All four call sites that
interpolate a caller value now pass `--end-of-options`, with a structural test so
a new call site without it is caught here rather than in review.

Four findings were about verification claiming more than it established, which is
the recurring shape of this review:

- The log-binding test patched `default_log_path`, so it proved the root was
  *passed*, not that passing it works. There is now a test driving the real
  resolver against two genuine Studio projects with the process standing in the
  wrong one, plus one for the non-empty-root "not a project" branch.
- The "no network" test patched `socket` in this process, which cannot observe a
  child's sockets. It now says so, and a companion test asserts the property that
  actually holds: every git subcommand issued is a local read, so none has a
  remote to reach.
- The permission test errored rather than skipped on Windows. The neighbouring
  test was already guarded; this one now is too.
- The whitelist entry for `ChangeWindow.project_root` was a false positive, and
  the comment above it claimed only fields no internal caller reads were listed —
  which that very commit contradicted. Entry removed, comment rewritten to say
  that a false positive there suppresses a real dead-code signal.

Full suite 5,284 passed. 156 tests on this module at 100% line coverage (323
stmts). pylint and vulture clean, `cfs validate` 0 errors, spec-coverage passes
at granularity 0.4613.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): make the GIT_DIR redirect test falsifiable

CI caught this and local runs did not, for an instructive reason.

`_make_repo` writes identical content with a fixed identity, so two repositories
created in the same second produce the *same* commit sha. The test compared the
window's sha against a value read from the decoy repository — and since both were
the same string, it passed whether or not the environment sanitising worked. An
assertion that cannot fail.

It surfaced in CI only because the two fixture commits happened to straddle a
second boundary there, making the shas differ and the comparison meaningful for
the first time. So the red build was the test finally becoming real, not a
regression.

Two changes: the decoy repository gets a distinct commit so the shas genuinely
differ, with a precondition assertion so a future fixture change cannot quietly
restore the tautology; and the expected sha is captured before the redirect is
installed, since reading it afterwards routes the test's own helper through the
mechanism under test.

Mutation-checked: removing the environment sanitising now fails this test, which
it did not before.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): one log read, honest repo detection, frozen records

Review findings on the window and event-selection half, each reproduced
before being changed:

* The log was opened three times — probe, read, count — so a valid line
  appended between the last two was reported as corruption, and a rotation
  between the first two swapped the verified file for a fresh one with no
  trace. `_read_log` now takes one snapshot; readability, the events and
  the line count all come from it, and `skipped_lines` is exact rather
  than a bound. Parsing moves to `decision_log.parse_events`, which
  `read_events` now uses too, so there is one copy of the rules.
* `_is_git_repo` dropped the tool-failure flag and guessed with a second
  `git --version` launch, so a timeout on the real question came back as
  "not a git repository". `_detect_repo` returns the reason directly and
  launches git once. A bare repository or the `.git` directory itself now
  reports the new `REASON_NO_WORK_TREE` rather than denying a repository
  exists.
* `ChangeWindow` and `EventSelection` are frozen, with `events` and `runs`
  as tuples, so the counts cannot be made wrong through the collections
  they describe. `group_by_run` deliberately still shares event objects.
* A `$CFS_DECISION_LOG` override is followed — that is where the writer
  wrote — and reported through `EventSelection.log_overridden`, since a
  shared log cannot be attributed to the window's project.
* A NUL byte in a requested ref is refused as a ref that cannot exist,
  rather than raising `ValueError` out of `subprocess` past the
  never-raises contract.
* `_canonical_run_id` casefolds, as its docstring already promised;
  `lower()` left "Straße" and "STRASSE" as two runs.
* The boundary following the merge-base — and so moving after a rebase —
  is documented on the module and on `resolve_window`, with `since=` as
  the remedy and a test pinning both. Widening to the earliest author date
  was rejected: author dates are arbitrary, so one old commit would pull
  years of unrelated decisions into the window.

Spec: steps reworded to the failure-aware contract, the retired line-count
step removed and the list renumbered. Whitelist: `log_overridden`.

Tests: 114 on the module (was 95), 100% line coverage (186 stmts). Eleven
mutation checks each fail only the tests written for them.

Signed-off-by: ou <ou@constructor.tech>

---------

Signed-off-by: ou <ou@constructor.tech>
Co-authored-by: ou <ou@constructor.tech>
… TF-IDF on a zero-hit (constructorfabric#137)

* fix(cascade): fuse heading-nav and TF-IDF signals instead of skipping TF-IDF on a zero-hit

Problem 2 of constructorfabric#134: route_tier1 escalated
immediately whenever heading-nav found zero hits, without ever running
TF-IDF -- discarding a genuinely different signal. Heading-nav requires
the query's exact literal substring somewhere in a section's raw text;
TF-IDF tokenizes on individual words, so a query differing from the
source only in punctuation/spacing/hyphenation can score unambiguously
on TF-IDF even when heading-nav's exact-phrase match fails outright.

Both methods now always run. Two new Tier 1 rows cover heading-nav's
zero-hit case: TF-IDF alone resolves at Tier 1 when unambiguous, and
otherwise escalates with TF-IDF's own top pick as the Tier-2 candidate
(rather than none at all) unless TF-IDF also has no positive score
anywhere. Existing rows (agree/disagree/diffuse-margin when heading-nav
does hit) are unchanged.

Deliberately still out of scope: folding OKF summaries into Tier 1 as a
fourth signal -- Tier 1's whole value is being free and deterministic
without ever touching the OKF bundle.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(cascade): stop fabricating a disagree verdict when TF-IDF has no signal

Review findings on PR constructorfabric#137 (ainetx):

- Major: route_tier1 unconditionally compared tfidf_ranked[0] against
  heading-nav's pick once heading-nav found a hit, even when every
  section scored exactly 0 (no real TF-IDF signal at all). tfidf_ranked[0]
  in that case is just an arbitrary document-order tie-break, not a
  genuine top pick -- comparing it anyway fabricated a "resolved_multi" /
  "heading_nav_tfidf_disagree" verdict (or, by coincidence of order, a
  fabricated agreement) out of a signal that was never there. Added an
  explicit row 4 (heading-nav has a hit, TF-IDF has no signal anywhere)
  that escalates on heading-nav's single, unconfirmed signal instead,
  symmetric to how rows 2/3 already handle TF-IDF's own no-signal case.
  Splits route_tier1 into two functions along the table's own two-part
  structure (heading-nav miss vs. hit) rather than disabling pylint's
  too-many-return-statements once the new row pushed past it.
- Minor: five hand-built TestRouteTier2 fixtures still used the retired
  "heading_nav_no_hits" reason string (route_tier1 was renamed to
  "no_signal_from_either_method" for that case in the prior commit,
  without these being updated). Consolidated into one named constant,
  since route_tier2 never reads the reason field -- these fixtures only
  ever needed the row-1 *shape*, not a specific string -- so a future
  rename can't silently drift out of sync with these tests again.
- Renamed the TestRouteTier1 test methods to match the routing table's
  real row numbers post-insertion (row 4 new; disagree/agree/diffuse-margin
  shifted from 3/2/4 to 5/6/7) and added a regression test reproducing the
  review's exact repro case for the new row 4.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(cascade): reconcile route_tier1's candidate-count docstring and note TF-IDF's unconditional cost

Review findings on PR constructorfabric#137 (ainetx, second pass against 6645603):

- route_tier1's docstring enumerated candidate-count rows as "2/4/6/7"
  but its own parenthetical immediately after ("rows 3/4/7 despite
  escalating") named row 3 as one of the escalating-but-one-candidate
  rows -- contradicting the main list, which omitted it. Row 3 (heading-
  nav miss, TF-IDF diffuse) does return exactly one candidate, same as
  rows 2/4/6/7; the list was just wrong. Fixed to "2/3/4/6/7", so the
  list and its own parenthetical agree with each other and with the
  actual implementation.
- Fusing heading-nav and TF-IDF (this function's whole point) means
  score_sections() now runs on every call, even one that a heading-nav
  hit could otherwise have resolved without it -- tfidf.py's own module
  docstring documents this scoring method as validated against a single
  ~166-page/~9-section document with deliberately no cap on section
  count or file size. That's an accepted, explicit tradeoff of fusing
  the signals, not an oversight; documented at both the call site and
  in route_tier1's own docstring rather than left implicit.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* test(cascade): cover single-section TF-IDF resolution, row-3 provenance, and OKF independence

Review findings on PR constructorfabric#137 (ainetx, second pass against 6645603):

- No test exercised score_sections' single-section `unambiguous` code
  path: with only one ranked section, `_confidence` returns
  `unambiguous=True` purely because there's no second candidate to
  compare against (`len(ranked) == 1`), never by beating a real
  rival's score. Every existing row-2 fixture used two sections and
  only ever exercised the latter path. Added
  test_row2_tfidf_only_unambiguous_with_single_section_document with a
  genuinely single-section fixture, verified against tfidf.py's actual
  `_confidence` logic rather than asserted blindly.
- No regression test guarded that the row-2 (tfidf_only_unambiguous)
  branch never falls through to route_tier2/OKF. Added
  test_row2_tfidf_only_unambiguous_never_calls_route_tier2_or_okf,
  spying on both with unittest.mock.patch and asserting neither is
  called.
- route_tier1's OKF-independence guarantee (module docstring: Tier 1
  must stay free and deterministic, never touching the OKF bundle) had
  no automated backing. Added test_route_tier1_never_touches_okf,
  which patches get_okf_status to raise if called at all and exercises
  every routing-table row (1 through 7, plus the margin_threshold
  opt-in) against it.
- route_tier2 had no test for the new row-3 (heading-nav miss, TF-IDF
  diffuse) escalation, whose candidate is TF-IDF-sourced -- a
  provenance never exercised before (every prior route_tier2 test used
  a heading-nav-sourced candidate or none at all). Added
  test_row3_tfidf_sourced_candidate_recommends_okf_when_current,
  building the row-3 result via a real route_tier1 call so it breaks
  if row 3's actual shape ever changes.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* test(cascade): narrow row-3 OKF staleness check, cover row-4 route_tier2, tighten row-3 assertion, enforce row-number docstring

Third ainetx review pass on constructorfabric#137 found four more gaps:

- test_row3_tfidf_sourced_candidate_recommends_okf_when_current wrote every
  section's concept file current, so a route_tier2 that (bug) validated the
  whole bundle instead of narrowing to just the named candidate would still
  pass. Added test_row3_tfidf_sourced_candidate_narrows_staleness_to_only_that_section,
  which only makes the candidate's own section current and leaves the other
  section missing -- verified it actually fails under that bug by injecting it
  and running the new test before reverting.
- route_tier2's docstring claims it's "only called for the escalating rows:
  row 1 ... row 3 ... row 4, and row 7" but nothing enforced that cross-reference
  against route_tier1's real behavior. Added
  test_docstrings_row_number_cross_reference_matches_actual_escalating_reasons,
  which runs route_tier1 against a fixture for all seven table rows and asserts
  the escalating rows/reasons that actually come back are exactly what the
  docstring claims.
- test_row3_tfidf_only_diffuse_escalates_with_tfidf_pick_as_candidate asserted
  tier/reason/candidates as three separate assertions instead of one whole-dict
  equality, unlike every other row test in this file -- switched it to match
  that pattern.
- Row 4's own route_tier1 output was tested, but nothing fed a real row-4
  result into route_tier2, unlike row 3. Added
  test_row4_heading_nav_sourced_candidate_recommends_okf_when_current,
  modeled on the existing row-3 route_tier2 test.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

---------

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Co-authored-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
…schema (constructorfabric#127)

Introduce the contract every artifact-quality detector will emit: an
advisory, read-only ArtifactFinding (with a Locus) plus a versioned
FINDING_JSON_SCHEMA a presentation layer consumes. The model validates
itself on construction — advisory severity only (no error, so a finding
can never gate), structural findings carry no verdict, judged findings
carry a detector-namespaced verdict (or "unjudgeable") — and serialises
to a stable wire shape with no combined score and no edit payload.

No detection logic here; the detectors and the `cfs artifact-quality`
command land in later tasks. Adds the feature doc and DECOMPOSITION
entry so the module traces 1:1 under CPT, and tests pinning
construction, serialisation, the invariants, and the schema contract.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Co-authored-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
constructorfabric#133)

* feat(change-summary): resolve changed files to the requirements they declare

Builds on the window: for every file changed inside it, report what the file
does with requirement IDs, so a digest can say which requirement a change
serves rather than only which paths moved.

Two directions are reported separately. `references` are IDs a file points at —
code serving a requirement. `defines` are IDs a file declares — an artifact that
*is* one. Collapsing them would report a changed specification as tracing to
nothing, which is exactly backwards; the first draft did that, and a smoke test
against this repository caught it.

The parser is `codebase.load_code_file`, not `coverage.py`. Only the former
yields identifiers: `coverage.py` measures marker density — counts and line
ranges — so no requirement is resolvable from its output at all. A referenced ID
is reported as-is rather than resolved back to its declaring artifact, because
`cfs validate` already fails when a code marker names an ID no artifact defines,
so in a green tree every reported ID is known to be declared.

There is deliberately no extension list. The authoritative one is private and
lives in a `commands` module this layer must not import, so instead of asking
what suffix a file has the code asks what the file does with IDs — which needs
no list and is language-agnostic.

Untracked files are included with status `?`. `git diff` cannot see them, so
omitting them would let a newly written module be absent from the digest
entirely. The diff is taken against the working tree rather than HEAD, since a
developer asking what changed before committing is the main caller.

Renames report the new path, so a rename keeps its link instead of falling to
the unreadable branch. Deleted, out-of-scope, unreadable and binary files are
each counted separately, so the report always carries its denominator rather
than a bare list.

Scope decisions delegate to `resolve_entry_code_files`, the single shared
exclusion policy. Note it judges resolved containment but not conventional
non-source directory names for an explicitly named file, so a tracked change
under a vendored path is reported rather than hidden — the safer direction for a
review digest.

`cfs validate` 231/231, 0 errors. spec-coverage --system studio: granularity
0.4613 -> 0.4616 (floor 0.46), coverage 90.56%. 34 new tests, 89 on the module
at 100% line coverage (230 stmts); full suite 5,217 passed.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): read git paths as NUL records, and count deletions

Three review findings, all real.

Git quotes and escapes any path containing a control character, a quote or a
non-ASCII byte unless asked for `-z` output. A file legally named
`we<TAB>ird.py` arrives as the literal characters `"we\tird.py"`, which matches
nothing on disk — so the report marked an existing file as deleted. Both queries
now request `-z` and the output is walked as NUL records.

That walk has to respect arity rather than zipping pairs: under `-z` a status is
followed by one path, except renames and copies which are followed by two. Taking
one would shift every subsequent entry, not just the rename.

`LinkReport` gained the `deleted` counter its own docstring already promised.
Deletion was recorded only in an individual `FileLink.reason`, so a renderer
could not report the count without reparsing the file list — which defeats the
point of publishing denominators.

The implementation-map row for this module said window resolution and event
selection only; it now also states the changed-file linkage it acquired.

Mutation-checked: reverting the rename arity fails five tests, four of them the
ones written for it. The odd-path behaviour was reproduced against real git
output before fixing, not inferred.

Full suite 5,225 passed. 95 tests on this module at 100% line coverage (245
stmts). pylint and vulture clean, `cfs validate` 0 errors, spec-coverage passes
at granularity 0.4615.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): decode git output with surrogateescape, not strictly

POSIX paths are bytes and are not guaranteed to be UTF-8, but
`subprocess.run(..., text=True)` decodes strictly. A file legally named
`bad\xff.py` therefore made git's own output undecodable, and the resulting
UnicodeDecodeError escaped past the `(OSError, SubprocessError)` handler and out
of `link_changed_files` — breaking its never-raises contract.

Reproduced against real git before fixing, not inferred from the docs.

Both git helpers now pass `errors="surrogateescape"`, which is the handler
Python itself uses for filesystem paths, so the value round-trips back to the
same bytes when the file is reopened — the odd-named file is genuinely handled
rather than merely not crashing. `UnicodeDecodeError` is also added to the
handler, so any future strict-decode path degrades to a reported reason instead
of an exception.

This is the same defect class as the undecodable decision log fixed in the
window commit, one layer up: there the log's bytes were undecodable, here the
*filenames* are. Worth noting the pattern rather than only the two instances.

Also skips the tab-in-name test on Windows, which rejects control characters in
file names, so it skips rather than erroring. The neighbouring symlink test
already guards the same way, so the suite is intended to run there.

Mutation-checked: removing `surrogateescape` fails exactly the new test.

Full suite 5,237 passed. 109 tests on this module at 100% line coverage (267
stmts). pylint and vulture clean, `cfs validate` 0 errors, spec-coverage passes
at granularity 0.4616.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): report one file once, and bound the scan surface

Six review findings on the linkage half, all reproduced first.

**One physical file was reported twice.** `git rm --cached` on a file present in
the base commit leaves it deleted in the index and untracked on disk, so the diff
stream reported `D` and the untracked sweep reported it as new. Concatenating gave
two contradictory rows for one file and inflated every counter — and `deleted`
stayed 0 despite a `D` row, because the file still existed so it never took the
gone branch. Entries are now deduplicated by path with the diff status winning.
Git emits repo-relative POSIX paths from both commands, so the raw string is an
exact key; resolving each path would cost a syscall per entry and would wrongly
merge two distinct symlinks sharing a target.

**Three unbounded scan surfaces**, fixed as one bound rather than three ceilings.
The untracked sweep can return an arbitrary number of paths, so the entry list is
capped and the remainder counted in `truncated` rather than dropped quietly. The
per-file size limit now lives in `load_code_file`, alongside the bulk-scan path
that already enforced it — one entry point was honouring a limit the other
ignored. And a directory-shaped entry (a changed submodule arrives as a gitlink)
is refused before the shared resolver can `rglob` an entire nested tree to answer
a boolean.

**A filesystem error during the scope check was reported as an exclusion.** The
check is tri-state now: excluded by policy, not in scope, or could not be
determined. Folding the third into the first claimed a policy judgement that was
never made.

**The vendored-path decision had no test.** It is a deliberate choice, not an
oversight, so it is now pinned: a tracked change under a vendored path is
reported rather than hidden, because over-reporting costs a reader a moment while
under-reporting hides work that changed.

Also adds the untested `UnicodeDecodeError` arm of the git helper's except tuple.
Line coverage marked that line covered once any member fired, so the arm was
reported as covered while never being exercised.

Two things found while fixing: the first dedup attempt inverted the (status,
path) tuple, which the new tests caught immediately; and extracting the
per-entry classification keeps pylint's local-variable rule satisfied without
suppressing a check the project is rolling out.

Full suite 5,275 passed. 147 tests on this module at 100% line coverage (314
stmts). pylint and vulture clean, `cfs validate` 0 errors, spec-coverage passes
at granularity 0.4615. Mutation-checked: reverting the dedup fails the
status-precedence test.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): separate options from the base sha on the diff call

The structural separator test caught this during a restack: the linkage half's
`git diff` call was the one option-bearing call site without
`--end-of-options`, because the separator work landed on the window commit
while this call site lives in a later one.

That is exactly what the test was written for — a call site interpolating a
caller-supplied value without the separator, found by the suite rather than in
review.

Full suite 5,284 passed. pylint and vulture clean, `cfs validate` 0 errors,
spec-coverage passes.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): make the link records immutable too

`FileLink` and `LinkReport` are frozen, with `references`, `defines` and
`files` as tuples — the same discipline the window and selection records
now follow, for the same reason: the report's counters describe `files`,
and a caller able to grow or shrink it would silently make them wrong.

Restacking on the window half's latest fix also removed the `field` import
these two records still used, so this is the change that keeps the stacked
branch importable rather than reinstating the import.

Spec step 16 reworded; the git-records docstring names the helper that
still exists.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): sanitise the record query's environment, and fail the listing whole

Two review findings on the changed-file listing, each reproduced first:

* `_git_records` ran without the sanitised environment `_git_query` uses,
  so an ambient `GIT_DIR` could resolve the window against one repository
  and list the changed files of another. It now passes `env=_git_env()`;
  a structural test asserts every `subprocess.run` in the module does.
* A failed `ls-files` was turned into an empty untracked list with `or []`,
  so the report came back available while silently missing every new
  file. Either query failing now makes the listing unavailable with the
  diff reason, rather than presenting a partial list as complete.

Spec steps 17 and 21 say so. Both fixes mutation-checked.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): one root, one read, one tally for every entry

Second maintainer round on the changed-file listing, each finding
reproduced before being changed:

* `link_changed_files` took its own unresolved root, so a relative path
  plus a later chdir diffed a different directory from the one the
  window's base commit described. It now takes only the window and uses
  the resolved root the window carries, as `select_events` already did.
  A hand-built window without a root is refused with its own reason.
* `_file_traceability` read the file twice — once per marker direction —
  so a file edited mid-scan could report references from one version and
  defines from another. It reads once; both parsers see that snapshot.
  `codebase.read_code_text` applies the size ceiling to the bytes it
  reads rather than to a prior stat, so a file growing in between cannot
  slip past it, and reports too-large under a new `FILE_TOO_LARGE` code
  so the caller branches on the code instead of re-measuring the file.
  `CodeFile.from_text` and `document.scan_cpt_id_lines` parse text a
  caller already holds; `load_code_file` and `scan_cpt_ids` delegate.
* One entry raising something no arm anticipated propagated out of the
  loop and let the command's last-resort guard discard the whole report.
  The loop now confines it to that row with a stated reason.
* Not-a-regular-file entries were listed but bumped no tally, so the
  report's arithmetic could not see them. They have their own count.
* Rename detection inherited the ambient `diff.renames` setting, so the
  same repository state was a rename on one machine and a delete plus an
  add on another. Pinned with `-M`.
* The untracked sweep was materialised whole before the ceiling applied.
  Paths beyond the ceiling are now counted but not stored, and the
  report carries `examined` alongside `changed` so its tallies name the
  population they were computed over.

The public `MAX_CODE_FILE_BYTES` alias is gone with its only reader. Spec
steps 20–23 and two new scan-code steps say what the code now does. Nine
mutation checks each fail only the tests written for them.

Signed-off-by: ou <ou@constructor.tech>

* refactor(change-summary): make the changed-entries ceiling public

The digest command's `--help` states the ceiling so a capped change set
is not mistaken for a bug, and it must quote the one number the report
uses rather than a second copy that drifts. The constant is read by this
module itself, so it needs no whitelist entry.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): a file whose markers do not parse was read fine

A parse failure — a dangling `@cpt-end`, a mismatched id — was reported
as "file could not be read", which is untrue of the everyday case: a
test file full of deliberately malformed marker fixtures. It now has its
own reason, `REASON_MARKERS_INVALID`, and stays in the unreadable tally
as something that could not be scanned. Found by running the digest on
this repository, where `tests/test_codebase.py` said "could not be read".

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): a document that cites a requirement references it

Only `definition` hits were read from documents, so a changed artifact
that referenced an ID without declaring it came back with no references
at all — a design note pointing at a requirement is a link to that
requirement. Document `reference` hits now join the code markers, except
a document's mentions of IDs it declares itself, which point at nothing
else. Two tests; reverting the union fails one.

Signed-off-by: ou <ou@constructor.tech>

---------

Signed-off-by: ou <ou@constructor.tech>
Co-authored-by: ou <ou@constructor.tech>
…buted quote and a wrong GA quarter (constructorfabric#157)

Real gaps found in review (ainetx, misfiled onto an unrelated PR but
confirmed against the actual document): three Pass 3 claims -- Appian
Composer's launch, a quote about agent governance, and Microsoft Agent
Framework's GA timing -- were stated as fact with no citation, and the
Methodology section never gave Pass 3 an as-of date.

Verified all three against real, current sources (web search + fetch)
before citing anything, and caught two factual errors in the process,
not just missing citations:

- The quote ("an agent governed by a process is reliable...") was
  attributed to "Appian's CTO." It's actually from Jacob Rank, Appian's
  VP of Product Management, in a 2026-04-27 company blog post -- corrected
  the attribution, not just added a source.
- Microsoft Agent Framework's table entry claimed "GA Q1 2026." Multiple
  independent sources (Microsoft's own devblog, Visual Studio Magazine)
  converge on 2026-04-03 as the real GA date -- that's Q2 2026, not Q1.
  Corrected the date, kept the quarter claim out entirely in favor of the
  exact date plus a citation.
- Appian Composer's announcement date (2026-04-28, at Appian World 2026)
  was already accurate; added the citation.

Added a "Sources (Pass 3)" section with dated, linked citations for all
three, and a Methodology-section freshness caveat ("Pass 3 research as of
2026-09-08... revisit each release cycle per Rf-038") addressing the
separate "research freshness undisclosed" finding.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Co-authored-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
…-2 escalation counts (constructorfabric#136)

* feat(cascade,doc-index): auto-trigger OKF build signal from real Tier-2 escalation counts

Problem 1 of constructorfabric#134: route_tier2's break-even math
previously only ran off a human-supplied `expected_future_queries` guess,
and nothing tracked real per-document query volume at all (decision_log
redacts `target` and aggregates only by method).

Adds `doc_index.record_tier2_escalation`, a persisted, etag-independent
`tier2_escalations` counter carried forward across content-driven rebuilds
(a document's real usage history outlives any one edit). cascade.route_tier2
now records one escalation per call and reports `should_build_okf` once the
count crosses a break-even derived from this module's own measured
per-query rates -- a real automatic signal instead of a policy decision, per
Oleg67's suggestion constructorfabric#1 on constructorfabric#104. `expected_future_queries` and
`build_okf_break_even` remain for a caller reasoning about a specific future
volume.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(cascade,doc-index): move tier2_escalations to its own file, fix its silent races

Review findings on PR constructorfabric#136 (ainetx):

- Major: get_or_build_doc_index's cache-miss build+save path took no lock,
  while record_tier2_escalation's read-modify-write did (both against the
  same doc_index.json). An unlocked structural rebuild (triggered by
  cascade.route_tier1's own find_sections/score_sections calls) could read
  a stale snapshot of the counter and overwrite a newer, real escalation
  count back down -- silently reverting recorded usage. Live-reproduced
  and fixed by moving tier2_escalations out of the structural cache
  entirely, into its own small counter file with its own lock
  (_escalation_cache_path/get_tier2_escalations/record_tier2_escalation):
  nothing else ever touches or locks that file, so there is nothing left
  to race.
- Minor (same redesign): the old design also rewrote the *entire*
  structural index (all sections, summaries) on every single Tier-2
  query just to increment one integer, contradicting doc_index's own
  "read once per file" goal. The new counter file is a tiny, independent
  write -- O(1) regardless of how large a document's cached payload is.
- Major: commands/doc_index.py (cfs doc-index) never surfaced
  tier2_escalations at all, even though get_or_build_doc_index carried it
  -- same bug class as PR constructorfabric#109's retrieval_sections omission, which this
  project already has a named regression test for. Added the field to
  both JSON and human output, plus an analogous regression test.
- Minor: record_tier2_escalation discarded save_doc_index's return value
  and always reported a fabricated success count. The rewritten version
  catches a real write failure (atomic_write_text raising) and returns
  None instead, matching annotate_section_summary's existing contract.
- Minor: should_build_okf was undocumented in `cfs retrieve --help` and
  had no documented caller action anywhere in the codebase. Extended the
  CLI help text and _baseline_recommendation's docstring with the
  expected caller action (summarize each retrieval_section, then call
  okf.write_concept_file per section -- the same enrichment pass OKF
  bundles are always built by).
- The "no regression test for a cache predating tier2_escalations"
  finding is moot under the new design (the field was never part of the
  structural cache's schema to begin with) -- covered instead by a new
  test asserting the structural index never carries the field at all,
  plus a concurrency test that hammers get_or_build_doc_index rebuilds
  against record_tier2_escalation calls and asserts no increment is ever
  lost (the Major race's actual regression test).

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* chore: retrigger CI (kit-download flake on Python 3.13's run, unrelated to this change)

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(doc-index): idempotency key, schema_version, and clamped counts for the escalation sidecar file

Second ainetx review pass on constructorfabric#136, against the
9595f06 sidecar-file redesign:

- Major: record_tier2_escalation was called unconditionally on every
  route_tier2 invocation, with no way to tell a fresh Tier-2 escalation
  apart from a caller re-invoking route_tier2/route_query for the same
  logical query after a transient failure or timeout -- silently
  double-counting on retry. Adds an optional `escalation_key` idempotency
  token: record_tier2_escalation persists a bounded window of recently-seen
  keys (`_MAX_RECENT_ESCALATION_KEYS`) alongside the count, under the same
  lock as the increment itself, and a key already seen returns the current
  count unchanged instead of incrementing again. No key (every existing
  caller's current behaviour) preserves the original always-increment
  contract -- there's nothing to deduplicate a bare call against without
  one. cascade.py's half of this wiring is in the next commit.
- The counter file had no schema/version field for future format
  evolution, unlike the structural doc-index cache's deliberate
  `schema_version`/`path`/`etag`. Adds `_ESCALATION_SCHEMA_VERSION` to the
  written JSON; get_tier2_escalations degrades gracefully (no crash, no
  spurious warning) on a file predating the field, since this is a brand
  new, unreleased counter with no real pre-existing unversioned file to
  migrate from.
- get_tier2_escalations didn't reject a negative persisted count (e.g. a
  hand-edited or truncated-write `{"tier2_escalations": -5}`), which
  record_tier2_escalation's `+ 1` would otherwise propagate forward
  indefinitely. Clamped to 0, the same fallback every other corrupt-data
  case in this function already uses.
- get_tier2_escalations returning 0 for both "never escalated" and
  "corrupt/unreadable" was, and remains, ambiguous to a caller -- not
  changing that return-type contract (accepted, documented limitation),
  but the underlying logger.warning calls now use distinct wording per
  failure mode (missing Studio project, corrupt/unreadable file, wrong
  JSON shape, write failure) so a log reader can at least tell them apart.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(cascade): wire the escalation idempotency key through route_query/CLI; guard the break-even constant's sign; document the outside-project caveat

Rest of the second ainetx review pass on constructorfabric#136 (see
the prior commit for the sidecar-file half of the double-count fix):

- Threads the previous commit's `escalation_key` through route_tier2 ->
  route_query -> a new `cfs retrieve --escalation-key` CLI flag, so a real
  caller can actually use it: pass the same key again when retrying the
  same logical query (a timeout, a transient failure) and the retry
  doesn't inflate tier2_escalations a second time. Added regression tests
  at all three layers (route_tier2, route_query, and the CLI) that
  simulate exactly that retry and assert the counter only advances once,
  while a genuinely new key still counts.
- `_TIER2_BREAK_EVEN_ESCALATIONS = math.ceil(_OKF_BUILD_COST_TOKENS /
  (_BASELINE_PER_QUERY_TOKENS - _OKF_PER_QUERY_TOKENS))` is computed once
  at import time and only makes sense while baseline costs strictly more
  per query than OKF -- true for the three hardcoded rates today, but
  nothing enforced that invariant on a future edit to them. Adds an
  import-time assertion that fails loudly instead of silently producing a
  nonsensical break-even point (or raising a division-by-zero deep inside
  math.ceil with no context). Also adds a direct test pinning
  `_TIER2_BREAK_EVEN_ESCALATIONS == 2`, since the existing tests only ever
  asserted the behavioral consequence (should_build_okf False at 1, True
  at 2), never the constant's actual value.
- `cfs retrieve --help` documented should_build_okf as turning on
  automatically "regardless of whether [--expected-future-queries] is
  passed", with no mention that outside a Studio project (no resolvable
  cache dir) tier2_escalations can never be persisted at all, so the flag
  is always false there -- already covered by
  test_should_build_okf_is_false_outside_a_studio_project, just never
  surfaced in the CLI's own help text. Added the caveat.
- No test drove cmd_retrieve to an actual baseline Tier-2 recommendation
  and checked the JSON output -- every existing JSON-output test only ever
  hit the resolved (Tier 1) tier. Added one that escalates twice and
  asserts tier2_escalations/should_build_okf are both present and correct
  in the machine-readable output.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* docs(traceability-validation): add spec coverage for the escalation-file helper functions

Fixes the "Validate Artifacts" CI failure on PR constructorfabric#136 (2 code-inst-orphan
errors): commit 839afe8 added @cpt-begin/@cpt-end markers for two new
helper functions (_load_escalation_file, _escalation_count_from) but
never added their matching CDSL steps in this spec, so the traceability
validator's spec-coverage check correctly flagged both as orphaned code
markers. Added both as Supporting entries under the Document Index
component, and updated items 9/10's own descriptions to mention the
escalation_key idempotency mechanism, schema_version, and negative-count
clamping those two commits introduced (previously undocumented here even
though the code markers on get_tier2_escalations/record_tier2_escalation
themselves weren't renamed, so they hadn't been flagged as orphans).

Verified locally: `python3 skills/studio/scripts/studio.py validate`
now reports 0 errors (was 2), 269 warnings (unchanged, pre-existing
toc-* findings unrelated to this PR).

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(doc-index,cascade): catch invalid-UTF-8 sidecar reads; harden escalation_key against empty/oversized values; test the 200-key eviction boundary

Third ainetx review pass on constructorfabric#136:

- Major: _load_escalation_file read the counter file via
  read_text(encoding="utf-8") but its except clause only caught
  (json.JSONDecodeError, OSError) -- UnicodeDecodeError is a ValueError
  subclass, not an OSError, so it was NOT caught and would propagate
  unhandled out of every caller (cfs doc-index, cfs retrieve, ...) on a
  sidecar file containing invalid UTF-8 (disk corruption, a bad manual
  edit). Added a UnicodeDecodeError clause with its own warning message,
  degrading to the same "corrupt/unreadable, never-escalated" fallback
  every other corrupt-data case here already uses. Regression test writes
  real invalid UTF-8 bytes to the sidecar path and asserts
  get_tier2_escalations returns 0 with a WARNING logged, not a raised
  exception.
- record_tier2_escalation compared escalation_key to recent_keys by exact
  string equality with no emptiness check: two different callers both
  passing escalation_key="" would silently collide, since "" in
  recent_keys is True after the first call -- the second call's real
  escalation would go uncounted. An empty string is now normalized to
  None (the same "no key, always increment" path) before the dedup check,
  since it was never a meaningful caller-supplied identity. Test asserts
  two calls with escalation_key="" both increment.
- Added _MAX_ESCALATION_KEY_LENGTH (200 chars): _MAX_RECENT_ESCALATION_KEYS
  only bounds the sidecar file's growth by key *count*, but nothing capped
  an individual key's *length* before persisting it verbatim -- a caller
  passing a multi-megabyte string would defeat that growth bound via key
  *size* instead. record_tier2_escalation now treats an oversized key the
  same as no key (logged, not raised) -- consistent with the empty-key
  fallback above, since this is a library function other non-CLI callers
  also use. The CLI is the direct, interactive caller, though, so
  cmd_retrieve's --escalation-key gets its own _escalation_key_arg
  argparse type that rejects an oversized value outright with an
  immediate, actionable error instead of quietly discarding it several
  layers down (mirrors _margin_threshold_arg's existing pattern). Tests
  cover both the CLI rejection and the doc_index-level fallback, plus the
  inclusive boundary (a key at exactly the limit is valid).
- No test exercised the actual 200-key eviction boundary of
  recent_escalation_keys (`(recent_keys + [key])[-200:]`) -- existing
  tests only ever used a handful of distinct keys. Added a test that
  records 201 distinct keys and checks the three consequences a
  deliberately-broken eviction (e.g. keeping the oldest 200 via [:200]
  instead of the newest 200 via [-200:]) would get wrong: the persisted
  list length stays at 200, the oldest key is evicted, the newest key is
  present, and retrying the now-evicted oldest key increments the count
  again (documented, accepted behavior).

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(doc-index,cascade): bound the escalation-counter lock, add a retrieve exit-code truth table, and make concurrency tests real mutation-detectors

Review findings on PR constructorfabric#136 (ainetx), round 4:

- Major: record_tier2_escalation unconditionally entered with_file_lock's
  always-blocking flock -- a live process holding that lock indefinitely
  (hung, deadlocked, or just very slow) would block the entire
  route_query/cfs retrieve call forever before Tier 2 could return
  anything. with_file_lock gains an optional timeout parameter (default
  None, blocking-forever, so annotate_section_summary and okf.py's
  manifest writer are unaffected); record_tier2_escalation now passes a
  5-second bound (three orders of magnitude above this lock's normal
  millisecond-scale hold time, so ordinary contention is unaffected) and
  treats a TimeoutError as a third, distinctly-logged "could not persist"
  case alongside "no Studio project" and "write failed". Regression test
  in test_doc_index.py holds the counter's lock from a separate open file
  description and asserts the call returns None with a clear warning
  within a bounded time on a background thread (never hanging the test
  itself); test_atomic_io.py adds direct coverage of with_file_lock's new
  timeout/no-timeout paths.

- Minor: added a reasonably-scoped parameterized truth table for
  cmd_retrieve in test_cascade.py, covering --escalation-key
  omitted/supplied crossed with resolves-at-Tier-1, escalates-to-baseline,
  and escalates-to-okf -- asserting exit code and JSON shape for each row.

- Minor: the thread-based concurrency tests for record_tier2_escalation
  and annotate_section_summary relied on an injected sleep to *probably*
  produce an overlapping read-modify-write window, with no real
  scheduling barrier -- an unlocked mutation that happened to run each
  thread's cycle serially by scheduling luck could still pass. Both now
  use a threading.Barrier so every thread's call genuinely begins at the
  same instant, and every .join(timeout=...) is followed by an
  is_alive() assertion so a hung thread fails the test loudly instead of
  passing silently. Manually verified (temporarily no-op'ing
  with_file_lock) that both barrier-based tests reliably fail without the
  lock and reliably pass with it restored.

Not addressed here (flagged for a product decision instead): "Tier-2
routing serializes every request on synchronous counter persistence"
(async/batched persistence is a bigger architectural change than this
pass should make unilaterally -- though the bounded timeout above does
meaningfully cap that finding's worst case too) and "cache validity can
miss same-size edits on coarse-mtime filesystems" (doc_index.py's etag
design deliberately trades that narrow risk for never reading the file on
a cache hit; changing it is a real trade-off, not a bug fix).

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(doc-index,cascade): catch a lock-setup OSError and show recorded escalations before break-even in human output

Two new findings against the round-4 lock-timeout fix (constructorfabric#136):

- Major (coderabbitai): with_file_lock creates the lock directory and opens
  the lock file *before* it ever attempts to acquire the lock -- an OSError
  from either of those (a permissions problem, a full disk, a missing parent
  on a broken mount) is not a TimeoutError, so the round-4 fix's specific
  `except TimeoutError` clause alone let it propagate straight through
  route_tier2 and crash `cfs retrieve` outright. record_tier2_escalation now
  also catches a bare OSError there, logging its own distinctly-worded
  warning ("could not be acquired") and degrading to the same None
  "could not persist" contract as the other three failure modes it already
  handles. Regression test monkeypatches with_file_lock itself to raise
  OSError and asserts None comes back with a warning logged, instead of the
  exception propagating.

- Minor (ainetx): _human_retrieve only rendered tier2_escalations inside the
  should_build_okf branch, even though the JSON output already reports the
  field unconditionally as soon as it's known -- hiding real, already-
  recorded information from the human-readable view before break-even.
  Now renders the count whenever it's non-null, keeping the "would pay for
  itself" message conditional on should_build_okf specifically. Test drives
  cmd_retrieve in human mode (flipping set_json_mode off, then restoring it)
  for a single below-break-even escalation and asserts the count line
  appears without the payback message.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(atomic-io): only retry EAGAIN/EWOULDBLOCK in the bounded lock poll, not any OSError

Major finding against the round-4 lock-timeout fix (constructorfabric#136):
with_file_lock's bounded-timeout poll loop caught any OSError from
flock(LOCK_EX | LOCK_NB), not just the errno flock actually raises for
"someone else holds this lock right now" (EAGAIN, aliased EWOULDBLOCK on
most platforms). A real filesystem/descriptor failure (EINVAL: not a
lockable descriptor, EBADF: bad fd, ENOLCK: no lock resources on this
filesystem type) would be silently retried for the full timeout and then
reported as a generic "timed out waiting for the lock", discarding the
actual errno that would have explained the real problem.

Now only EAGAIN/EWOULDBLOCK is treated as ordinary contention and retried;
any other OSError propagates immediately (chained via `from exc` where it
still ends up wrapped as a TimeoutError on the contention path, unchanged
there). Two new tests: one mocks flock to always raise EINVAL and asserts
it propagates on the very first call, not after polling for the timeout;
the other mocks flock to raise EAGAIN exactly twice before succeeding,
proving the retry path still works for genuine contention.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(spec-coverage): raise instruction granularity back above the 0.46 gate

The "Spec Coverage" CI check started failing on this branch after the
round-4 fixes (0e28ef3, 4acc23f, 487648c): granularity dropped to
0.4594 (main sits at 0.4606) because those commits added substantial new
logic either with no @cpt-begin/@cpt-end marker at all, or folded into an
already-large existing block -- both dilute the
covered-instruction-density metric (granularity = actual_blocks /
(effective_lines / 10), weighted by effective_lines across the system).

Extracted four previously-unmarked or over-large pieces into their own
properly-scoped, separately-marked functions, each with a matching new
CDSL step in traceability-validation.md (same pattern as the earlier
`code-inst-orphan` fix on this branch):

- utils/cascade.py: the break-even constant's import-time invariant guard
  (assert + derivation) was bare module-level code with no marker at all.
- commands/cascade.py: _escalation_key_arg (this branch's own addition)
  had no marker at all.
- utils/atomic_io.py: the bounded-timeout poll loop was folded into
  with_file_lock's single existing inst-atomic-lock block; extracted into
  its own _acquire_lock_bounded, called from with_file_lock unchanged.
- utils/doc_index.py: the escalation_key normalization logic (empty ->
  None, oversized -> None-with-warning) was inline inside
  record_tier2_escalation's already-large block; extracted into its own
  _normalize_escalation_key.

None of these change behavior -- pure extraction/marking, verified by
running the full affected test suite (171 passed, same count as before)
and the full suite (5496 passed, 1 pre-existing/unrelated failure in
test_eval_semantic.py, matching every prior commit on this branch).

Verified: `cfs spec-coverage --min-granularity 0.46` now passes at 0.4601
(was 0.4594); `cfs validate` still reports 0 errors (no new
code-inst-orphan findings from the new markers).

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(doc-index): lock get_or_build_doc_index's rebuild against a racing annotation

get_or_build_doc_index's cache-miss/force-rebuild path called
build_doc_index()+save_doc_index() with no lock at all, while
annotate_section_summary's read-modify-write cycle already locks on the
same cache path. A rebuild could build a fresh, pre-annotation snapshot
while a concurrent annotate_section_summary call locked, loaded the old
cache, added a summary, saved, and unlocked -- then the rebuild's own
unlocked save would silently overwrite the file with its stale
snapshot, discarding the summary with no error to anyone
(constructorfabric#136, round-5 review, Major).

Wraps only the rebuild-and-save call site in the same per-file lock
annotate_section_summary already uses (with_file_lock on
<cache_path>.lock), leaving save_doc_index itself lock-free so
annotate_section_summary's own inner closure (which already calls
save_doc_index directly while holding that same lock) doesn't try to
re-acquire a lock it's already holding. When not force_rebuild, the
locked path also re-checks cache freshness once more after acquiring
the lock, so a rebuild that loses the race to a concurrent write
returns that fresh (possibly already-annotated) cache instead of
redundantly rebuilding a from-scratch, summary-less snapshot over it.

Adds a deterministic regression test that forces the exact interleaving
(intercepting get_or_build_doc_index's own pre-lock cache check to run
a real concurrent build-and-annotate cycle first) and asserts the
summary survives.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(cascade,doc-index): tighten remaining round-5 review findings

- cascade.py cmd_retrieve's help text now also names the lock-timeout
  and write-failure cases that produce tier2_escalations: null, not
  just the outside-Studio-project case.
- doc_index.py's _human_doc_index now renders the escalation-count line
  whenever the value is not None, so a genuine 0 ("never escalated")
  still shows instead of being silently skipped by the old truthy
  check.
- atomic_io.py's _acquire_lock_bounded docstring now notes its errno
  check is scoped to POSIX flock(2) semantics only.
- test_cascade.py: test_escalation_key_rejects_an_oversized_value now
  uses a real tmp_path file and asserts the error message names
  --escalation-key specifically, instead of a literal "doc.md" that was
  never created (so rc==2/ERROR couldn't tell "key rejected" from
  "file not found").
- test_cascade.py: the 4 bundle_dir truthy-only assertions in
  TestRouteTier2 now assert the exact expected path via
  studio.utils.okf._okf_bundle_dir.
- test_cascade.py: adds an end-to-end TestCmdRetrieve test holding the
  escalation lock externally, asserting cmd_retrieve degrades
  gracefully (tier2_escalations/should_build_okf null/False, and the
  escalation-count substep correctly omitted in human mode) instead of
  hanging or crashing.
- test_doc_index.py: renames/updates the never-escalated human-output
  test to match the _human_doc_index fix above.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(test-cascade): use list[str] instead of undefined List annotation

coderabbitai flagged a real Ruff F821 in _run_bounded's parameter
annotation (constructorfabric#136): `List[str]` referenced typing's
`List` without importing it. `from __future__ import annotations` keeps
this from breaking at runtime (annotations are strings, never evaluated),
but Ruff still correctly flags the undefined name statically. Switched to
the builtin generic `list[str]`, which needs no import and resolves the
lint error outright rather than just adding an otherwise-unused import.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(cascade,doc-index): document the real break-even threshold, add boundary tests for escalation-key normalization

Two more round-6 findings (ainetx):

- cmd_retrieve's --help named an unspecified "real break-even point" for
  should_build_okf without stating the threshold or how it's derived.
  Now interpolates the real _TIER2_BREAK_EVEN_ESCALATIONS value (2) and
  names its derivation (measured OKF-build/OKF-per-query/baseline-per-query
  token costs), instead of leaving a CLI user to go read cascade.py's
  source to find the number.

- _normalize_escalation_key was only ever exercised indirectly through
  record_tier2_escalation with a length+1 oversized key -- no test pinned
  the exact boundary (a key of exactly _MAX_ESCALATION_KEY_LENGTH must
  pass through unchanged) or isolated the warning-only-on-oversized
  behavior from the larger read-modify-write flow. Added
  TestNormalizeEscalationKey, calling the helper directly with an empty
  string, a boundary-length key, and a boundary+1 key, asserting the
  correct None/unchanged/None results and that only the oversized case
  logs a warning.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

---------

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Co-authored-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
…onstructorfabric#156)

Real gap found in review (ainetx, misfiled onto an unrelated PR but
confirmed against the actual Makefile): the `ci` target's job list was
`$(act push --list ... 2>/dev/null | ... | grep -Ev '...')`, with the
resulting `for job in $jobs; do ...; done` as the only consumer. If
`act push --list` fails outright, changes its output format, or the
exclusion filter happens to remove every discovered job, the command
substitution yields an empty string and the for-loop simply runs zero
times -- `make ci` prints nothing job-related and exits 0, reporting
success for a run that validated nothing at all.

Now captures the discovered job list into a variable first and fails
explicitly (exit 1, clear stderr message) if it's empty, before the loop
ever runs. Verified both paths by stubbing `act` on PATH: an empty
`--list` output now exits 1 with the new error message (previously
exited 0 silently); a normal non-empty list still runs each job and
excludes sonarqube/code-ranker exactly as before.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Co-authored-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
…arnings (constructorfabric#154)

* fix(change-summary): shallow clones, a shared ceiling, a streamed sweep, warnings

Third maintainer round on the linkage and window halves, each finding
reproduced before being changed:

* A merge-base miss in a shallow clone was reported as "no merge base",
  a claim about history that a depth-1 CI checkout cannot support. After
  a miss the window asks whether history is shallow and reports the fetch
  depth as the reason instead.
* `--base` was silently ignored whenever `--since` was given. Both now
  compose: the base anchors the changed-file diff and the bound replaces
  only the decision boundary; a bad base is refused even with a bound.
* The untracked sweep was captured whole and the ceiling filled from the
  diff first, so brand-new files were exactly what a large change set
  dropped. The sweep is now streamed — records kept up to the ceiling,
  counted past it — and once the ceiling bites the two streams share it
  in turn. Below the ceiling the order is unchanged.
* Git failing to launch was logged at debug like a routine miss. Both
  helpers now warn with the exception type, not its message.
* The scope check passed a fabricated one-suffix allowlist to the shared
  resolver, which does not consult it for a file. It passes none, and the
  docstring says why no extension policy applies: a changed artifact
  declares requirements without carrying a code extension.
* `read_code_text` treats NUL bytes as binary, the rule the document
  reader already applied, so the two readers agree on what is text.

Tests: a real shallow file:// clone; base-plus-since composition; fair
sharing at the ceiling; the streamed reader's cap, count, exit and hang
paths; warning levels; the no-filter call; direct `read_code_text` and
`scan_cpt_id_lines` suites with from_path/from_text and reader parity.
Eight mutation checks each fail only the tests written for them. Three
new traced blocks and steps.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): bound the streamed sweep by the query timeout

The streamed reader blocked in `read()` before its `wait(timeout)` could
run, so a git that held stdout open without writing was waited on forever.
The pipe is now pumped on a helper thread against a monotonic deadline —
the portable way `subprocess` itself bounds a read — and a silent git is
killed and reported as a tool failure. An unterminated final record is
kept and counted, as `_git_records` treats it. Two tests: a stalled pipe
released only by `kill`, and the trailing record; disabling the deadline
check fails the first without hanging.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): report a failed depth probe as git, and dedup the sweep as it streams

Maintainer round on the linkage follow-ups. Each finding was reproduced
before it was fixed, and each fix has a test that fails without it.

- A merge-base miss followed by a failed `--is-shallow-repository` probe
  was reported as "no merge base": the probe's failure flag was dropped
  and read as "not shallow". `_is_shallow` now returns the flag like every
  other probe here, and the window reports git unavailable instead.
- The untracked sweep is deduplicated against the diff inside the stream,
  before the ceiling. Checked afterwards, a `git rm --cached` file beyond
  the kept prefix was counted twice in the total, and below it took a kept
  slot from a genuinely new file.
- An exception on the pump thread ended the thread unseen, and the caller
  read a stopped pump as a finished one -- a prefix returned as the
  listing. The pump records what it raised; the reader kills git, warns
  with the exception type, and returns no answer.
- The unterminated tail buffer had no bound, so a child that never wrote
  a NUL was buffered until the deadline. A record over 1 MiB is a failure.
- Both git readers decode paths with the filesystem codec. `text=True`
  without an encoding used the locale's, so under a non-UTF-8 locale the
  captured diff and the streamed sweep could spell one path two ways and
  the dedup between them miss it.
- The tracked diff stays captured whole, and the spec now says so and
  why: the tracked-file count bounds it, and git holds it in memory to
  produce it.
- The NUL rule both text readers apply is one predicate, `document.is_binary`.

Tests: the probe failing after a miss; a shallow repository fabricated
from `.git/shallow`, so the reason is verified where `file://` clones are
refused; a skipped record taking no slot and a duplicate past the ceiling
counted once; a stream failing part-way and a record that never ends;
both readers decoding one path to one string under a swapped codec; the
lopsided interleave cases; bare-CR parity; the shared predicate; and the
empty error list on a disabled ceiling.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): a shallow miss is undecided, every record is bounded, the registered scan reads through the ceiling

Maintainer round on fdcbd61.

- The shallow reason, spec step 24 and the probe's docstring now state
  what a merge-base miss in a shallow history is: undecidable -- the
  branch point may lie beyond the fetched depth, or the histories may be
  unrelated, and a truncated history cannot tell which. "The branch point
  was not fetched" claimed the first.
- The record bound applies to a completed record as well as the tail.
  Checking the tail alone let a record that ended inside a chunk exceed
  the bound by up to one chunk.
- `_scan_code_file_references` reads through `read_code_text` and parses
  with `CodeFile.from_text`, so its ceiling is on the bytes read rather
  than on a stat the file could outgrow before the read.
- The collector's docstring says "returned for examination" rather than
  "materialised": the tracked map behind the returned list is bounded by
  the repository, not by the ceiling, and the wording claimed otherwise.

Tests: a terminated record over the bound is refused; the registered
scan declines a file that grows past the ceiling at the moment it is
read; the lopsided population keeps its minority through git, statuses
and total asserted.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): ask the sweep for only what the shared ceiling can still seat

The untracked sweep was asked for the whole ceiling and trimmed after the
interleave, so up to a ceiling's worth of new paths was retained only to
be dropped, and the spec's "no more of it is materialised than the report
will examine" was not literally true. The interleave gives tracked entries
at most half the ceiling, so once the diff is in hand the room left for
new files is known; the sweep is asked for that many and no more. The
outcome of every existing case is unchanged -- what is kept is exactly
what could have been picked.

Test: a spy on the bounded reader asserts the requested keep for zero,
one, two, three and nine tracked entries under a ceiling of four.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): an oversized record makes the whole listing unavailable, end to end

The reader's refusal was tested at the unit; this forces the same
oversized record through resolve_window and link_changed_files and
asserts the report comes back unavailable with its reason and no files
-- not as an empty, available untracked list, which an `or ([], 0)`
fallback in the collector would produce while dropping every new file.
That mutation fails this test and the failed-sweep test beside it.

Signed-off-by: ou <ou@constructor.tech>

* docs(change-summary): say what the sweep keeps, not what it materialises

Step 21 said the untracked sweep materialises no more than the report
will examine. It now says what the code does -- keeps only as many of
its paths as the shared ceiling can still seat and counts the rest -- so
the word that was being read as a claim about the tracked diff is gone.

Signed-off-by: ou <ou@constructor.tech>

* test(codebase): start the growth test from valid content so only the ceiling can decline it

The regression test's initial content was a dangling marker, which the
old stat-then-read path would have rejected as malformed and reported as
skipped -- so the test could pass without proving that growth past the
ceiling is what declined the file. It starts from `pass` now: the old
path scans the grown file; the bounded reader declines it.

Signed-off-by: ou <ou@constructor.tech>

* docs(change-summary): declare the reader bounds, the git environment and the reason vocabulary

One traced step held four distinct declaration groups, and the step's own
wording said so -- "the window and selection result types as immutable
records, *and* the reason vocabulary". Split into the groups it already
named: the bounds every git reader obeys, how git is pointed at one
repository and one base commit, the shared vocabulary of outcomes, and the
records themselves.

Measured against the current default branch rather than the one this branch
was cut from, since that is what the merge is scored on: merged, the system
scored 0.4598 granularity against a 0.46 floor, i.e. below it -- the default
branch is itself at 0.4600 with no margin left. Declaring these three steps
carries the merge to 0.4604, and this module's own score from 0.5009 to
0.5566, so the change adds margin instead of consuming it.

Also closes a space that sat on the wrong side of an assignment.

Signed-off-by: ou <ou@constructor.tech>

* refactor(change-summary): one log template per git-reader outcome, not three copies

The three git readers each spelled out the same two log messages inline, so
the launch failure and the non-zero exit were written six times between
them. SonarCloud flagged the first of the pair on this branch.

Named once in the reader-bounds block, beside the other things every reader
here obeys, which is where the shared-wording argument already lives: an
operator greps a log for a fixed phrase, and three readers wording one
outcome three ways is three things to search for. The formatted output is
byte-for-byte unchanged, so the tests asserting on the message still hold.

Also drops the one sentence this duplicated into the first call site.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): clear the config-injection variables too, not only the redirects

Git takes configuration from the environment as well as a repository
location, and configuration reaches these queries even where it cannot
redirect discovery. Measured: with `core.excludesFile` injected through
`GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n`, through `GIT_CONFIG_PARAMETERS`, or
through `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM`, `ls-files --others
--exclude-standard` returned nothing for a repository whose untracked file
it otherwise lists. A brand-new file was therefore absent from the digest
while the report still called itself available and complete -- the silent
omission this module exists to prevent, arriving through the environment
rather than through the code.

Clearing the count is enough for the indexed family: verified that git
ignores `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` unless a count says how many
to read, so no unbounded scan for indices is needed. Legacy `GIT_CONFIG` has
no effect on these queries and is left alone.

`core.worktree` is not the vector it appears to be, and the note in the
source says so: injected this way it is set -- `git config core.worktree`
echoes it back -- but ignored for discovery, so `--show-toplevel` and the
listings stay with the directory git was pointed at. It redirects only once
`GIT_DIR` is also set, which was already cleared. The two groups are
complementary, and each of the four new entries fails its own test alone.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): clear the discovery-widening variable, and say what the unreadable count aggregates

Two review points, both fair.

`GIT_DISCOVERY_ACROSS_FILESYSTEM` widens the upward search where
`GIT_CEILING_DIRECTORIES` narrows it: with it set, discovery crosses a mount
boundary and can settle on an ancestor repository on another filesystem
rather than the project's own. Clearing the narrowing variable while leaving
the widening one was the asymmetry. Covered by name rather than by
behaviour, and the test says why -- constructing a mount boundary needs
privileges a suite does not have.

`unreadable` is the aggregate of every reason no marker could be
established, not only a failed read: undeterminable scope, unparsable
markers and an unexpected scan failure all land there. The docstring said
"exactly one of deleted, unreadable or not_a_file" and left the aggregate
unstated, so a renderer could reasonably name one cause and mean all four --
which is what the human digest did. Splitting the counter would put a
counter on each cause and the exactly-one invariant on none, and the
distinction is not lost anyway: each row keeps its own reason, so the
per-file detail says which cause applied. The field now says so, and says
that a renderer naming this count must name the aggregate.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): observe both git launch mechanisms in the no-remote check, and clear GIT_CONFIG_NOSYSTEM

The no-remote claim was checked by patching `subprocess.run` and reading the
captured argv. The streamed sweep launches through `subprocess.Popen`, which
patching `run` does not intercept -- the asymmetry only runs the other way
-- so that launch was never observed. Reproduced: changing the sweep's
subcommand from `ls-files` to `ls-remote` left the test green.

Blind twice over, in fact. Raising from the fake meant the diff query failed
first and the collector short-circuited before reaching the sweep at all, so
even a patched `Popen` would not have seen it. The captured fake now answers
successfully with nothing, so the collector walks on, and both mechanisms
are counted separately with each count asserted non-zero -- a future
refactor moving a query between the two would otherwise pass in silence.
The mutation above now fails the test.

Separately, `GIT_CONFIG_NOSYSTEM` joins the cleared set. It reverses the two
config-file variables rather than adding to them, and the interaction is
measured: `GIT_CONFIG_SYSTEM` pointing at a file that sets
`core.excludesFile` emptied the untracked sweep, and adding
`GIT_CONFIG_NOSYSTEM=1` brought the file back. Left inherited, an ambient
value decided whether the machine's real /etc/gitconfig was consulted.

That completes the documented surface: `git help config` lists exactly
COUNT, KEY_n, VALUE_n, GLOBAL, SYSTEM and NOSYSTEM, plus the undocumented
PARAMETERS, which was verified by measurement.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): assert the warning templates exactly, and name the fourth one

`"OSError" in warnings[0]` passed for any wording that happened to mention
the type, so the three readers could drift back to three phrasings -- the
thing the shared templates exist to prevent -- without a test noticing. All
three loose assertions now compare against the template.

Doing that surfaced a fourth inline literal the earlier extraction missed:
the pump's "git query stream failed", which is deliberately *not* the launch
message, since git having started and then broken the pipe is a different
fact for an operator than git never starting. Named as
`_LOG_GIT_STREAM_FAILED` for the same reason as the others.

Asserting each message against its own template turned out to be
insufficient on its own: aliasing one template to the other passed the whole
streaming suite, because both assertions follow whatever the names hold. So
the distinction is pinned directly -- the two must differ, and each must
still say which failure it describes. Verified in both directions: rewording
the launch template fails the substring assertion that pins its operator
wording, and aliasing the two fails the new one.

Signed-off-by: ou <ou@constructor.tech>

---------

Signed-off-by: ou <ou@constructor.tech>
Co-authored-by: ou <ou@constructor.tech>
…sk (constructorfabric#151)

Studio turns routine workflow mechanics into chat gates, and the mode gate
suggests `normal` — which `simple-mode-normal.md` defines as a no-op that
continues with every existing menu, gate and stop, so it adds stops without
adding help. An autonomous-by-default overlay already exists but is opt-in and
re-derives risk per menu at runtime rather than reading a declaration.

Nothing in ADR-0001..0022 covers interaction modes, so both the default and
any risk model would be introduced with no recorded basis.

Record the decision:

- four modes, with `guided` distinct from `assistant` so the current `normal`
  behaviour survives under a name rather than being removed; the active mode
  is announced rather than asked — the announcement MUST precede the first
  autonomous resolution — and mode is session state that does not persist
  across sessions, so a session cannot silently start more autonomous than
  the default
- each gate declares a risk type as a static constant of its MENU block —
  `confirmation`, `decision`, `blocking` — and an undeclared gate is treated
  as `blocking`, so no installed kit or legacy path changes behaviour until
  its author declares a type
- a `decision` gate may resolve autonomously only from an approved,
  structured, explicitly keyed source using exact-match semantics: no
  inference from prose, no similarity matching, no normalization at lookup
  time, and no cached resolution state that is no longer valid for the
  current inputs
- a `blocking` gate is passable only by a fresh explicit user authorisation
  satisfying that gate; auto-proceed and proceed-after-approval are distinct
- autonomous mode does not grant authority; it selects how already-declared
  authority is exercised

The record removes the session-opening mode gate and stops there: what becomes
of the `MENU SimpleModeChoice` block is left to the trigger-set unit in
prerequisite 1, whose owner is named. An earlier draft decided that too, which
contradicted this record's own reason for naming an owner rather than inventing
a grammar -- restored, along with the consequence it exists to prevent: until
that unit exists the announced override is not reliably reachable, so the
default must not flip before it.

Review corrections. Retiring or binding the three paths that resolve undeclared
gates today is a required component of the default-flip change rather than a
follow-up: a flip that left any of them running would contradict the fail-closed
default at the moment it took effect. That change now has one name throughout --
it was called both "the change that flips the default" and "the change that
first consumes declarations", which are the same change.

Prerequisite 4's contract must also make a failed write detectable: an invariant
whose breach cannot be observed is not enforceable, and the fail-open choice is
only defensible if someone can tell it has happened. The mode table no longer
claims `guided` keeps every gate -- it keeps every workflow gate, and the
session-mode gate this decision removes is the exception. Autonomous resolution
covers `confirmation` and approved exact-match `decision`, not `confirmation`
alone. The dispatch gates are declarable today but still bypassable until the
pre-set path is bound, which is now said where they are introduced. The
mode-change trigger set is not enumerated here; constructorfabric#148 carries the proposed
wording and is where disjointness can be checked.

The `TYPE:` grammar is attributed to the open constructorfabric#153 rather than to
`architecture/specs/PDSL.md` in the present tense. It is on that branch, not on
this one and not on main, so a reader checking the spec here would not have
found it -- a dangling cross-branch reference introduced when the earlier
"no concrete syntax" finding was addressed.

Confirmation now holds only criteria someone can check, and each row names the
check, the verifier and the cadence. A verifier is a role rather than a person,
which is what makes it statable before the work is assigned.

Six of the nine rows were not checks and have been sorted into the open-gap
register instead of annotated: four restated a gap the register already carried,
in different words, which is why counts kept disagreeing between the two tables;
and two named a measurement whose instrument does not exist, so their gap is the
missing instrument and belongs where the closing change is named. Nothing was
dropped. A table of checks containing things nobody can check is how "confirmed"
stops meaning anything.

That also removes the paragraph that partitioned the nine by mechanism. It was
the source of four separate counting errors in review, and with three uniform
rows there is nothing left to partition. The register's opening sentence now
defines "the register" as a term, since three later passages refer to it while
the heading names something else.

Bounds ADR-0018 rather than superseding it: delegated unattended execution
remains valid, and this ADR governs interactive-session autonomy.

Four things must exist before the default moves, not three: the write path from
a chat gate to `record()` is a prerequisite rather than a negative consequence,
because recording every permitted autonomous resolution is mandatory and no such
path exists — flipping the default without it would produce exactly the
unrecorded resolutions the second invariant forbids.

The third prerequisite now states what its contract must define, because
approval alone is not sufficient: source identity, provenance, and binding to
the current inputs. The resolution invariant already forbids resolving from
cached state that is no longer valid for the current inputs, which is the
freshness half of the third; the other two have no home until that source
exists.

A mode-by-declared-type matrix covers every combination rather than specifying
the autonomous default alone: only that default resolves a gate from its declared
type, and every other mode asks whatever the type says. `debug` adds breakpoint
pauses to `guided` behaviour rather than replacing it, which is what "unchanged
by this decision" means.

The three enumerations now have one stated relationship. The gap register is
complete; the prerequisite list is the subset of it that must close before the
default moves; the Confirmation table is how acceptance is checked rather than a
duplicate register. Every row of the register carries a prerequisite number or
says it does not block the flip, except the fail-closed row that the flip itself
resolves. The two remaining decision-log obligations are marked non-blocking with
the reason: an opted-out session and a missing core-version field degrade the
audit trail without permitting a resolution the declaration did not authorise.

Each remaining gap names the change that closes it and the check that proves it,
so a deferral is reviewable rather than argued. The declaration's grammar is
referenced rather than restated — `architecture/specs/PDSL.md` fixes the syntax,
this record fixes the model. Mode's lifetime is PDSL's existing `scope session`,
and this record deliberately does not define that boundary: the term is used by
48 declared variables across nine modules, so fixing it here would redefine all
of them rather than only mode.

Scoped to the decision and the obligations it creates. Per-site counts and
measured stop counts are deliberately excluded — they go stale and are not
what is being decided; the reachability walk and the migration inventory are
tracked on the requirement, and the PDSL rule wording on its own issue.

Numeric acceptance thresholds are excluded for the same reason — those are
evaluation policy, not architectural decisions.

`cfs validate` PASS (0 errors, 0 warnings); `make test` 5282 passed;
`make pylint`, `make vulture-ci` clean; `make spec-coverage` all thresholds
met (granularity 0.4614); `make test-coverage` clean.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Co-authored-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
constructorfabric#153)

The validator does not recognise a `TYPE` section header at all, so a gate
risk declaration is completely unchecked: a literal value, a variable, a
value with a trailing `WHEN`, a misspelled header and no header at all all
pass silently. A typo therefore downgrades a gate to undeclared with nothing
reported, which leaves any declared-risk model resting on a lint that has no
opinion about the thing it is meant to enforce.

Add the header and four rules in a new `PDSL700` band:

- `PDSL700` — the value must be one literal enum token, `confirmation`,
  `decision` or `blocking`; an interpolation, a variable or a trailing
  condition is a runtime decision rather than a declaration
- `PDSL701` — at most one `TYPE` per MENU, so the effective risk cannot
  depend on read order
- `PDSL702` — a declaration where nothing reads it: outside a MENU, nested in
  its body, or trailing it
- `PDSL703` — a sub-header that is a near-miss of `TYPE`, so a typo cannot
  silently leave a gate undeclared

An absent `TYPE` stays valid and is treated as `blocking` at runtime, so no
existing menu changes behaviour and the surface migrates gate by gate. To
stop that surface growing, `test_the_untyped_menu_surface_does_not_grow`
freezes today's undeclared menus and fails on a newly added one; the guard is
built from the validator's own block scanner and regexes so it cannot
disagree with the checker it guards.

Nothing consumes the declaration yet, so this is inert on shipped PDSL. That
is enforced rather than asserted: a test pins the whole finding tally across
every validated root, not only the new band, because this change touches
shared parsing.

Two design points worth recording. A declaration is read in a MENU's
*declaration region* — from its header to the first recognised section other
than `TITLE` — rather than by tracking where a MENU ends; deciding the latter
left the menu-numbering checks suppressed for the rest of the block. And a
malformed declaration is recognised by discarding decoration and folding
Unicode per character rather than by listing the forms it can take, since
that list is open-ended. `architecture/specs/PDSL.md` states what is
consequently *not* detected, and that every such case leaves the gate
undeclared and therefore `blocking`.

Review since the first push closed two loose ends. `architecture/specs/PDSL.md`
now names the one detection limit that was left implicit: a name spelled in
lookalikes the map does not carry, at two or more positions, sits outside the
distance-1 radius and is not reported. One such letter is still caught by
distance, and any number of mapped ones fold to `TYPE` and are caught too. The
limit is deliberate — PDSL is authored in ASCII, so widening the rule would
trade a spelling nobody writes for false positives on prose in another script.

And a property-test variant meant to exercise two fenced blocks had left the
second fence open, so its second finding was `PDSL100` about fence syntax
rather than anything about a gate. Both fences are now closed, both menus
carry a gate defect on purpose, and the compared tuple includes the block
index, so the documented sort order is exercised across blocks rather than
only within one.

Review since the last push tightened one documented boundary. The spec said any
lower- or mixed-case candidate whose value is not a gate type is prose, which an
*empty* value literally satisfies -- but `type:` and `Type:` are reported, and
`TYPE:` errors, because a header written with its separator and nothing after it
is an abandoned declaration rather than front matter. The prose rule now says so
explicitly and names all three cases, and
`test_an_abandoned_declaration_is_reported_whatever_its_casing` pins it; removing
the truthiness half of the miscasing guard makes that test fail.

One property fixture built its INVALID branch as an EMIT followed directly by
STOP_TURN. Every example in the spec pairs a prompt with `WAIT user.reply` before
the stop, and a fixture should not model an interaction the user cannot answer,
so the wait is inserted. The arbitrary-text fuzz list is deliberately left
malformed -- proving the validator does not abort on odd input is its purpose.

Review corrections to the spec and the governance guard.

`architecture/specs/PDSL.md` said an undeclared menu is not fail-closed today
and, sixty lines later, that an undetected near-miss leaves the gate undeclared
"and therefore `blocking`". Both cannot hold while shipped paths resolve an
undeclared gate by runtime judgement, so the second is now scoped to the model
the spec records rather than asserted of current behaviour. The same passage
named two such paths where the interaction-mode ADR names three; dispatch's
pre-set rule is now in both, since a spec and the record that governs it must
not disagree.

`test_the_untyped_menu_surface_does_not_grow` compared two sets, so a rename --
one key out, another in -- passed, as it should. So would a genuinely new
untyped menu added to the baseline in the same diff, disguised as that rename:
the warning against it was advisory text a human reads, not something the
assertion enforced. A recorded ceiling on the baseline's size now enforces it. A
rename leaves the count unchanged and still passes; typing a gate lowers it; a
smuggled addition raises it and fails. Verified by injecting a 114th entry,
which fails on the count.

Closes constructorfabric#152

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Co-authored-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
…ers name one file alike (constructorfabric#160)

* fix(change-summary): stop translating newlines inside a path, so both readers name one file alike

Sharing the filesystem codec between the two readers of the changed-file
listing was not enough to make them agree. `text=True` switches on
*universal newlines* as well as decoding, and `subprocess` exposes no way to
turn that off, so the captured reader translated CR inside a filename while
the streamed sweep, decoding raw slices, kept it. Measured on a repository
holding a CR-bearing path:

    text=True path : 'sweep\nname.txt'
    raw-bytes path : 'sweep\rname.txt'

Two consequences, and the second is worse than the first. Deduplication of
the sweep against the diff compares strings, so a file appearing in both was
reported twice and every counter inflated. And a translated name opens
nothing, so a *tracked* file sitting on disk came back as "file no longer
present" -- a false statement about a present file rather than a merely
incomplete listing:

    rows: [('tracked\nname.txt', 'A', 'file no longer present')]

The captured reader now takes bytes and decodes each record with the same
call the pump makes. Nothing in `-z` output needs translating: only NUL
separates records, and a path may contain any byte but NUL and `/`, so
anything translated is corruption. `_git_query` keeps `text=True`, since it
reads refs, shas and timestamps -- values git itself forbids a control
character in.

Five tests. The captured and streamed readers must return identical strings
for CR, CRLF and LF names, since the translation rewrites each differently;
a tracked CR path must not be reported as deleted; and one appearing in both
listings must be a single row. The structural check that pinned the shared
codec now pins the stronger property -- one `text=True` in the module, and
one shared decode call per record reader.

The first version of the deduplication test passed the mutation it was
written to catch: the file was committed after the base ref, so `git diff`
never reported it and only the sweep saw it, which is one row either way.
The base now contains the file, and reverting the fix fails all four.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): skip the control-character path tests on Windows

CR and LF are in the 1-31 range Windows forbids in a file name, so the three
tests that create such a path would fail there rather than skip. CI is
ubuntu-only, so this was latent rather than red, but a contributor running
the suite on Windows would have hit it.

Guarded with the convention this file already uses twice, for a tab and for
POSIX permission bits.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): cover the newline regression on every platform, not only POSIX

The three filesystem tests need a real path containing CR, which Windows
rejects, so they skip there and this regression class had no cover on that
platform at all. Two portable tests close it.

The first drives a real subprocess whose output holds a CR -- any Python
interpreter, no git and no odd file name -- and reads it both ways, so the
translation itself is pinned: bytes give `cr\rname.py`, text mode gives
`cr\nname.py`. That is the corruption the fix avoids, demonstrated rather
than described.

The second runs both readers over identical faked output. A mock cannot
catch a reader switching back to text mode on its own, since patching
`subprocess.run` replaces the code that translates -- so the fake honours
the `text`/`encoding` keywords the way `subprocess` does, decoding and then
translating newlines. With `text=True` restored it returns the translated
string exactly as the real thing would, and the comparison fails on
`'cr\nname.py' != 'cr\rname.py'` rather than on a type error. The first test
is what makes that fake faithful.

My first version of it ignored those keywords and failed the mutation only
because the code did `.split("\0")` on bytes -- a real failure, for the
wrong reason, and it would have stopped catching anything the moment the
type mismatch was resolved some other way.

Signed-off-by: ou <ou@constructor.tech>

---------

Signed-off-by: ou <ou@constructor.tech>
Co-authored-by: ou <ou@constructor.tech>
…nterpretation rules (constructorfabric#162)

* fix(pdsl): carve out declared mode/gate-type resolution from anti-reinterpretation rules

pdsl-execution-card.md and active-workflow-state-law.md correctly forbid
reinterpreting a user's reply as broad permission, but as worded they also
caught Studio auto-proceeding a gate under its own declared TYPE (constructorfabric#153) and
session mode -- neither carve-out existed, so an implementer had no basis to
resolve a gate by its declared type without appearing to violate the law.

- pdsl-execution-card.md: declared mode/gate-type resolution is workflow-owned
  behaviour, not reply-reinterpretation; explicitly excludes runtime-derived
  classifications (e.g. Brave New World's per-menu eligibility check) from the
  carve-out.
- active-workflow-state-law.md: resolving a gate by its declared type is
  workflow-owned, not ad-hoc interpretation; splits "skip" into two
  independently-tested invariants (authorised resolution vs. logged audit
  record), so a logging failure doesn't retroactively make a legal resolution
  illegal; a reply is never a mode reset.
- gates/simple-mode.md: new SimpleModeChangeTrigger unit implementing the
  closed mode-change trigger set the new law rule points at (the literal
  phrase "change mode"), kept in its own unit so it doesn't tip SimpleModeGate
  over its already-allowlisted PDSL600/601 caps.
- tests/test_pdsl_keywords.py: regression test asserting the trigger phrase
  stays disjoint from Brave New World's open-ended activation phrases.

Closes constructorfabric#148

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(pdsl): address review findings on the declared gate-type carve-out

Responds to review on PR constructorfabric#162:

- gates/simple-mode.md: SimpleModeChangeTrigger was declared but unreachable
  -- no active continuation ever routed a mid-workflow message to it. Wires
  it into active-workflow-state-law.md's message-mapping rule as the
  first-checked outcome, since that law is the one thing already guaranteed
  to run on every message all session long. Also adds explicit
  whitespace/punctuation/case-folding normalization to its WHEN clause.
- workflows/brave-new-world.md: BraveNewWorldActivate's open-ended
  semantic-equivalence phrase matching had no stated exclusion for the
  literal "change mode" trigger, so a BNW-eligible session could plausibly
  misroute it as autonomous-mode activation. Adds an explicit NEVER rule
  excluding that phrase.
- pdsl-execution-card.md: adds a NOTES clarification that no core module
  resolves a gate from a declared TYPE today -- this carve-out only removes
  the prohibition against doing so once such resolution exists (per
  ADR-0023's staged rollout), it does not itself implement resolution.
- Cross-references the carve-out between pdsl-execution-card.md (canonical)
  and active-workflow-state-law.md (references it), so the two wordings
  can't drift apart unnoticed; corrects active-workflow-state-law.md's
  mode-persistence rule to name SimpleModeChangeTrigger instead of the
  SimpleModeChoice menu.
- tests/test_pdsl_keywords.py: strengthens the disjointness test to check
  substring/superset collision (not just exact list membership) scoped to
  BraveNewWorldActivate's own block (not a flat file scan), and adds
  regression coverage for the previously-untested new rules in
  active-workflow-state-law.md, pdsl-execution-card.md, and
  SimpleModeChangeTrigger's full matching contract.

Audit-record schema (a separate Minor finding) is left undefined here
deliberately -- ADR-0023 explicitly defers it to the not-yet-built write-path
prerequisite, which is out of scope for this issue.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

---------

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Co-authored-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
… findings that missed main (constructorfabric#135)

* fix(cascade,okf): resolve PR constructorfabric#111 round-3 CodeRabbit findings

- route_tier1/route_query now validate margin_threshold themselves
  (_validate_margin_threshold), rejecting a non-finite or non-positive
  value. commands/cascade.py's _margin_threshold_arg only guards the CLI
  entry point; a direct Python caller of these functions bypassed it
  entirely, and a bad threshold (0, negative, nan, inf) would make the
  row-4 margin comparison fire on virtually any finite margin, defeating
  the "no finite value is yet proven safe" design basis documented in
  cascade.py's own module docstring.
- _concept_file_is_valid now also requires the closing frontmatter
  delimiter, not just the opening one: a concept file truncated right
  after "---\n" still passed the opening-only check, so a genuinely
  unusable file could be reported as "current" and handed to Tier 2 as a
  usable OKF summary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* chore: retrigger CI (kit-download flake on the previous run, unrelated to this change)

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(cascade): validate margin_threshold's type, not just its value

Review findings on PR constructorfabric#135 (ainetx):

- _validate_margin_threshold called math.isfinite() before checking the
  input was even numeric, so a direct Python caller passing a string (or
  any other non-numeric) got an unhandled TypeError instead of the
  documented ValueError. Added an isinstance guard (bool excluded, since
  it subclasses int but isn't a meaningful threshold).
- route_query's invalid-threshold test coverage was a single hard-coded
  value while route_tier1's was a full parametrized matrix -- the two
  share the same validator via the same call path, so a future refactor
  that decoupled them could slip through unnoticed. Mirrored the same
  matrix (now including the non-numeric/bool cases above) onto the
  route_query test.
- The two tests asserted only a substring match on the error message
  (`match="margin_threshold"`), which would still pass if the message
  lost its actual explanation. Tightened both to a full-message regex.
- Added an accept-path test (a large finite threshold must not itself be
  rejected) for both route_tier1 and route_query -- previously only the
  reject path was covered.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(test-cascade): use an immutable tuple for the margin-threshold test matrix

CodeRabbit (RUF012): a mutable list as a class attribute is a real lint
finding (shared mutable default), even though nothing in this test
suite mutates it today. Tuple carries the same values with no
behavioral change.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(cascade): resolve PR constructorfabric#135 round-2 findings on margin_threshold validation

Review findings on PR constructorfabric#135 (ainetx, second pass against the round-1 fix
commits):

- route_tier1 now validates margin_threshold unconditionally as its very
  first statement, before nav_first_match is even computed -- a real
  behavior change with no direct test: previously a query that would
  escalate at row 1 (heading_nav_no_hits) never touched
  margin_threshold at all, so an invalid value there returned the
  escalate result silently. Added a parametrized test covering the full
  _BAD_MARGIN_THRESHOLDS matrix against a row-1 query, asserting
  ValueError now fires there too.
- _BAD_MARGIN_THRESHOLDS didn't separate "wrong sign" from "IEEE negative
  zero specifically", and had no non-finite case beyond +inf. Added
  float("-inf") and -0.0.
- route_tier1's docstring didn't document the new ValueError contract
  introduced by the unconditional _validate_margin_threshold call; added
  a Raises note there and a shorter cross-reference note on route_query's
  docstring, since it calls through.
- The accept-path margin_threshold test asserted only "tier" in result --
  a mutation that broke the row-4 margin comparison, or returned an
  arbitrary tier, would still pass. Replaced it with cases asserting the
  exact tier/reason for both a tiny-but-positive (1e-9) and an enormous
  (1e10) finite threshold against _DIFFUSE_MARGIN_SAMPLE's actual
  measured margin (99.0 for "widget") -- 1e-9 resolves, 1e10 (correctly)
  still escalates, since it exceeds the real margin.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(cascade,okf): resolve PR constructorfabric#135 round-4 findings on margin_threshold validation and OKF concept-file validity

- Extract a shared _check_margin_threshold_value helper in utils/cascade.py
  so _validate_margin_threshold (direct API) and commands/cascade.py's
  _margin_threshold_arg (CLI) enforce one identical finite-and-positive
  policy instead of two independently drifting checks.
- Add a module logger to utils/cascade.py and log a warning with the
  rejected value before _validate_margin_threshold raises, so a direct
  Python caller leaves a structured trace, not just a bare exception.
- Strengthen okf.py's _concept_file_is_valid to parse required frontmatter
  fields (title, description, resource, generated) and require non-blank
  body content, instead of treating opening/closing delimiters alone as
  proof of a genuine, current concept file.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

---------

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Co-authored-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…d why (constructorfabric#149)

* feat(change-summary): add the advisory digest command

`cfs change-summary` composes at most ten lines from the window, the
changed-file linkage and the decision-log selection: the span covered,
what changed with its marker denominator, the requirements served, and
the decisions recorded while the work was done. `--json` carries the data
behind every line.

Advisory is structural, not asserted:

* exit 0 on every path except a usage error (2). A test forces each row
  of the behaviour matrix; a last-resort guard turns an unforeseen
  exception into a stated reason rather than a traceback; and a test
  greps the Makefile and workflows so the command cannot be promoted
  into a gate quietly;
* the ceiling is a ceiling — only lines backed by data are emitted, and
  when it bites the last line says how many were cut. No window and no
  changes are each one line, deliberately;
* every degraded dimension states its reason and its denominator;
* the digest never counts itself: telemetry events are not "why", and
  this command's own invocations are excluded from the payload, so
  consecutive runs are byte-identical although each logs an invocation;
* no absolute path in either rendering; no network; no commit author.

Registered in all five dispatch tables. Spec: a flow, a nine-rule
composition algo, a definition of done, the module row and an acceptance
criterion; TOC refreshed.

Tests: 34 across unit, real-CLI integration, golden fixture with pinned
commit dates, edge, invariant, privacy, fail-safe, determinism and scope
reporting; module at 100% line coverage. Eight mutation checks each fail
only the tests written for them.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): name the population, the check failure, and the rules

Maintainer round on the digest command, alongside the linkage fixes it
now sits on:

* The changes and markers lines named `changed` as their denominator
  while every tally was computed over the entries actually examined, so
  a capped scan read "300 of 1,500" as a breakdown of 1,500. When the
  cap bit, both lines now name the examined population, and the payload
  carries `examined` beside `changed`.
* A project check that *failed* — permission error, unreadable mount —
  was folded into "not inside a Studio project" and logged at debug. It
  now has its own reason carrying the exception type, and is logged at
  warning like the last-resort guard.
* The not-a-file tally from the linkage half is rendered, and the
  unreadable label says "read or parsed" now that a parse failure is
  reported as one.
* `--help` gains an epilog stating the rules that shape the output: the
  window's origin, the scope policy, the examined ceiling (quoted from
  the one constant the report uses), both marker directions, the line
  ceiling and its omission rule, and that nothing is published.
* The no-gate test also scans `*.yaml` workflows and any pre-commit
  configuration, and asserts it scanned at least two files.

The linker is called with the window alone, per its new signature. Six
mutation checks each fail only the tests written for them.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): encodable output, distinct run prefixes, a bound with its anchor

Maintainer round 2 on the digest command, alongside the linkage fixes it
now sits on:

* A changed file with a non-UTF-8 name crashed `--json`: git paths are
  decoded with surrogateescape so they round-trip, but a lone surrogate
  cannot be printed, and the UnicodeEncodeError rose from inside `print`,
  past the last-resort guard. Every string in the payload — the human
  lines included — is now made encodable once, in one place; undecodable
  bytes render as `\xNN` escapes. Tested against a strictly encoding
  output stream, since a StringIO would never have noticed.
* Two runs sharing their first eight characters rendered as identical
  labels. The prefix now grows until the shown ids differ, as git does.
* The window line reads "since <bound> against <ref> @ <sha>", so an
  explicit `--since` composed with `--base` (which the linkage half now
  supports) shows both, and `--help` says the two compose.
* Boundary tests at exactly the cap and one over, for the requirements
  and runs "+N more" arithmetic.

Three mutation checks each fail only the tests written for them.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): escape every lone surrogate, not only the filename kind

`surrogateescape` re-encodes only the surrogates it produced itself
(U+DC80–U+DCFF); any other lone surrogate — a `"\ud800"` that a decision-
log line carries through `json.loads` — made the sanitiser raise, and the
whole digest fell to the last-resort guard for one bad event field. Those
are now escaped as themselves, so the decisions dimension states the
value and the rest of the digest stands. Reverting the fallback fails the
test.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): widen run prefixes over every run in the window, shown or folded

The prefix width was computed over the three runs shown, so a fourth run
folded into "(+N more)" that shared its first eight characters with a
shown one left that label matching two runs in the log, and the digest
gave no sign of it. The width is now computed over every run in the
window: a prefix printed is a prefix unique in the log it points into.

Test: a folded run sharing eight characters with a shown one widens the
shown label to nine; computing the width over the shown runs alone fails
only that test.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): say when events carry no run id, label the unattributed bucket, and leave a trail on the escape fallback

Maintainer re-verification round on the digest command.

- The payload counted events with no run id; the plain digest never said
  so, and the runs line under-reported the breakdown with no sign of it.
  A `decision log: N event(s) carry no run id` line now appears when the
  count is non-zero, beside the skipped, undated and shared lines.
- The unattributed bucket was cut to eight characters like an id and read
  `(unattri`. It is a word, not an identifier, and is rendered whole.
- The fallback for a lone surrogate that `surrogateescape` did not make --
  a corrupt decision-log line -- escaped it silently. It now warns, at
  the level the module's other guards use, so a mangled field has a trail.
- `_is_own_invocation` documents that the exclusion is by kind, not by
  instance: every change-summary invocation is a read of the log, and
  excluding only the current run's would break the determinism the suite
  pins.

Tests: the exit-and-status truth table over the behaviour matrix; a real
overflow at the default ceiling agreeing with the payload's omission
count; two invocations from distinct runs both excluded with the decision
kept; the runless line and the full unattributed label; the exact
two-byte escape surviving JSON; the warning on a non-filename surrogate
and silence on a filename one; the privacy test asserting a digest was
produced before checking what it omits.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): the degraded payload fields carry no path either

The privacy test covered the ordinary digest; the degraded fields -- a
file that could not be read carrying a reason, a shared log carrying the
fact of its override -- are where a path would leak if anywhere. With an
unreadable changed file and an absolute CFS_DECISION_LOG override both in
force, the serialised payload is asserted to contain neither the project
root, the override path, the home directory nor the user, and to show
that both degradations were actually present.

Signed-off-by: ou <ou@constructor.tech>

* docs(change-summary): give the no-changes outcome its own scenario line

The error-scenario list grouped "no changes against the base" with the
outcomes that state a reason and a denominator, while the command prints
one line naming the base and nothing else -- deliberately, as the
description says. The spec now describes the one-line outcome as its own
scenario and says why no zero-file denominator is printed with it.

Signed-off-by: ou <ou@constructor.tech>

* docs(change-summary): a denominator wherever one exists, not for a dimension that is unavailable

The scenario sentence promised a reason and a denominator for every
degraded outcome; an unavailable dimension has nothing to count and
states its reason alone, which is what the command prints. The sentence
now says so.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): derive the omission count independently, across the boundary

The count was pinned at one length. A review read that as an off-by-one and
proposed `len(lines) - LINE_CEILING`, which reports 1 where two source lines
are genuinely absent -- so the digest would understate its own omission at
every overflow, which is the one direction that matters for a command whose
purpose is making what it drops visible.

Parametrized at ceiling-1, ceiling, ceiling+1, ceiling+2 and well past, with
`dropped` counted by asking which input strings are missing from the output
rather than by any formula. Applying the proposed change fails this test at
three of the five lengths.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): discover the gate files instead of listing them, and reject both spellings

The zero-wiring promise was checked against an enumerated set --
`workflows/*.yml`, the Makefile, pre-commit -- and only against the
hyphenated CLI name. Both limits let the failure it exists to catch pass one
directory over: a gate added as a composite action, a nested workflow, or a
`settings.yml` naming required checks was never opened, and
`python -m studio.commands.change_summary` wires the command without ever
writing the hyphen.

Now everything under `.github` at any depth and whatever the extension, plus
the Makefile and any pre-commit config, with both spellings rejected.
Verified against three fixtures the previous version passed: a settings file
naming the command in `required_status_checks`, a nested composite action
using the module path, and an underscore invocation appended to the
Makefile. Each fails the test now; removing it passes.

Two limits stated in the docstring rather than left to be discovered again:
branch protection and rulesets are account-side configuration and not
repository text, so no test here can assert the required-status-check list;
and `pyproject.toml` is excluded by intent, since it declares tool
configuration rather than gate invocations and its coverage and vulture
sections name modules legitimately.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): name the aggregate tally for what it is, and finish the exit truth table

The tally label said "could not be read or parsed", but the counter behind
it holds every reason no marker could be established -- undeterminable
scope and an unexpected scan failure as well as a failed read. So a
scope-policy failure was reported to a reader as a file-access one. Now
"yielded no marker information", which is what the count actually means;
the per-file `reason` in the JSON still names the individual cause, so no
detail is lost, only the false specificity. Pinned by a test that forces a
real scope failure and asserts both halves: the row keeps
`REASON_SCOPE_UNKNOWN`, and the line does not name one cause for all of
them.

The exit truth table asserted its six baseline rows and one usage error, an
unknown flag. An option supplied without its value is the form a person
actually hits and was untested, so `--base`, `--since` and `--root` without
values and an unexpected positional are now rows of the same table, each
asserting exit 2 and status ERROR -- keeping "a usage error is the only
non-zero exit" checked across every way the parser can fail rather than one.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): require the digest to exist before asserting things about it

`assert all(lines)` passes on an empty list, and so does `not any(...)`, so
a regression suppressing the whole human digest satisfied all three
assertions in this test. The non-empty guard now comes first, which is what
gives the other three their force. Verified by returning `[]` from the
composition for this fixture: the test fails, and passes again once
restored.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): sweep every degradation for paths at once, and pin the two exclusion rules where they diverge

The whole-payload privacy sweep combined an unreadable file with a shared
log, but ran on events that were all dated and all attributed, so the
undated and runless fields -- separate reported fields, and as good a place
for a path to surface as any -- were never in force while those assertions
ran. They had only been exercised at unit level against an `EventSelection`,
never through the real serialisation. Both are now in the same sweep and
asserted in force before absence is checked, so the test cannot pass by
having nothing to leak.

Separately, two independent rules drop events and only their agreeing cases
were asserted. `_is_own_invocation` drops by command, so a digest never
reports its own footprint; `_TELEMETRY_EVENTS` drops by kind, so no
invocation or read is a decision whoever issued it. An `invocation` from
another command is where they disagree -- retained among `events` and in
`by_event`, contributing nothing to `decisions` -- and that case is now
pinned. Widening the first rule to drop any invocation, which collapses the
two into one, fails it.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): the ceiling must not sacrifice the log's integrity lines first

The digest emitted the per-run breakdown before the four "decision log:"
lines, and the ceiling cuts from the end -- so the lines admitting the log
could not be read fully were structurally the *first* thing dropped. A
change set large enough to reach ten lines therefore made the digest stop
reporting that three log lines were unreadable in order to keep printing
which runs the readable ones came from. That is the silent omission this
command exists to avoid, arriving through the ordering rather than through
the counting.

The breakdown now goes last. It also reads better: a caveat about the
decision count sits beside that count, and the per-run detail is what a
reader can most afford to lose.

Stated as a priority rather than a guarantee, in the code, the spec and the
test: the omission summary occupies a slot of its own, so even the smallest
overflow costs two lines and a deep enough one still reaches an integrity
line. What covers that case is the omission count plus the JSON, which
carries every one of these counts as a field whatever the human rendering
had room for. An earlier draft of the test claimed the four always fit; the
fixture disproved it, and the claim is gone rather than weakened.

One assertion elsewhere was pinned to a line index rather than its content
and moved with the order; it now matches on the label it was always about.

Signed-off-by: ou <ou@constructor.tech>

* fix(change-summary): stop calling the window's event count "scanned"

The no-decisions line printed the events left after the window filter and
after this command's own invocations were dropped, and labelled that number
"scanned". So a log holding a hundred older entries reported "0 event(s)
scanned" having scanned all hundred -- a denominator that was not one.

The word is wrong, not the number, so the word is what changed: the line now
says how many events fell inside the window, which is what it counts.

The review also asked for `selection.scanned` -- the log's true total -- to
be carried in the payload, and that one is refused with a reason. It counts
this command's own logged invocations, so exporting it makes two consecutive
digests of an unchanged repository differ by one, which is the determinism
`_is_own_invocation` exists to hold. Adding it fails
`test_consecutive_runs_are_byte_identical_although_each_logs_an_invocation`,
which documents exactly that. There is no self-excluded variant to offer
instead: the selection keeps no events from outside the window, so their
invocations cannot be identified and subtracted.

I had made that addition before checking, and the determinism test caught it.
It is now asserted as a deliberate absence, because adding it looks like an
improvement.

Signed-off-by: ou <ou@constructor.tech>

* test(change-summary): partition the whole event population in one fixture, both renderings

Two independent rules filter events -- self-invocations by command,
telemetry by kind -- and each test exercised a slice of the result. So a
regression could retain self events or misclassify a foreign command's
telemetry without any single assertion failing on the *partition* itself.

The existing telemetry test already covered both renderings and held a
telemetry-only run; what it lacked was the one category the two rules
disagree on, a foreign command's invocation, which survives the
self-exclusion and is dropped by the telemetry rule. Rather than add a
fifth overlapping fixture, that case joins this one and the assertions are
extended to the partition: `by_event` must show exactly one surviving
invocation -- the foreign one, not ours -- and neither telemetry-only run
may be named in the breakdown.

Verified as the review asked: mutating either rule alone fails it. Dropping
the self-exclusion retains our own invocation, and emptying
`_TELEMETRY_EVENTS` makes all three events decisions.

Its narrower siblings stay, since they pin properties this one does not:
that the exclusion is by kind rather than by instance, and that the
divergent case holds through the real CLI rather than a hand-built selection.

Signed-off-by: ou <ou@constructor.tech>

---------

Signed-off-by: ou <ou@constructor.tech>
Co-authored-by: ou <ou@constructor.tech>
…ips (constructorfabric#163)

The cf-plan decomposition path stopped twice for one decision. The first gate
asked "proceed with this decomposition?" and its own title announced that the
next question would be how to produce phase files; answering yes wrote plan.toml
and the briefs, then a second gate asked exactly that question. Nothing was
asked of the user in between. Both gates were unconditional emits, so every
cf-plan run paid both.

They are now one gate, PlanProduceChoice, declaring TYPE: decision. Each
production option calls a new PlanWriteBriefPackage unit, so the authorisation
and the write are the same decision and no file is written before the gate
resolves. The revise option resumes in the decomposition unit via the corpus's
resume idiom rather than re-emitting over the same decomposition, and the
decline path now truthfully reports that nothing was written.

Declaring TYPE on the survivor takes the untyped MENU surface from 113 to 111.

Separately, the three prep-gate modules disagreed on when they skip themselves:
write-docs and write-skills auto-skip their explore and brainstorm gates when
the target is unambiguous, and coding had no such rule. coding now carries both,
and gains the ORIGINAL_INTENT precondition its siblings already had, since the
rules decide on that variable.

The two existing auto-skip rules were also incomplete. gates/plan-first.md
states NEVER run without PLAN_FIRST_CONTINUE set by the caller, and nothing in
the shared gates/workflow-prep.md sets it on a caller's behalf; every menu
option that reaches the gate sets it and loads the module itself, but the
auto-skip rules did neither. An auto-skip therefore reached PlanFirstGate with
the variable unset and the module unloaded, so the gate's own no-plan option
continued to an unset unit, silently. All three modules now set and load in the
same clause.

Four guards cover this: entries into PlanFirstGate must set a return unit that
resolves to a defined UNIT and load the module, counting only executable action
sections and including the indirect COMPANION_CONTINUE route; the prep-gate
modules are glob-discovered and must agree on both rules, their notes, the
dispatch target each module's own menu names, and the UNIT each rule sits in,
since target and note alone pin no placement; the brief package cannot be
written outside its writer unit or by an option that declines; and the merged
gate's declared type is pinned. All three read only executable action sections,
so a PURPOSE, TITLE or NOTES line naming a unit or artifact cannot fail the
build, and the PlanFirstGate guard binds per clause -- a menu option or rule
must carry its own assignment and load, while a DO block binds sequentially --
so one valid option cannot vouch for an unbound one beside it. A fifth names the
three paths that auto-resolve a gate by runtime judgement and asserts both halves
of the claim the baseline comment rests on: each path still carries its rule, and
none of them reads a declared type. The second half is the one that goes stale
first, since binding those paths to declared types is what the default flip does. Each was mutation-tested.

Resolving continuation targets against the corpus surfaces two that name no
defined UNIT: CodingDispatch (4 sites) and WriteDocsDispatch (1). Both are
grandfathered in a documented set that may only shrink, as the untyped-MENU
baseline is, because choosing their real targets is a flow decision rather than
a rename. Reported upstream.

PlanWriteBriefPackage specifies its failure path as well as its success path.
The summary is the only signal a downstream consumer has that the package is
complete, so it is emitted only after a complete write, and an ON_ERROR clause
reports the failure verbatim, resets the phase gate and stops the turn rather
than leaving a partially written package to be consumed as a whole one. The
guard likewise reports an unreadable path as a finding about that path instead
of raising, reading through the validator's own reader, with a parametrized test
covering both error shapes that reader normalizes.

Two structural assertions keep the failure path honest, since a consumer runs in
the same option clause as the writer and nothing structural separates them: the
summary must be the writer's last DO action, so no write can follow the signal
that everything is written, and the ON_ERROR clause must terminate rather than
continue into a consumer.

Disclosed, and not addressed here:

- The three prep-gate modules are unreachable from any shipped workflow, so the
  prep-gate half of this change is an alignment fix with no observable effect
  today. It removes no stop that a user currently pays.
- PLAN_FIRST_CONTINUE = CodingDispatch names a unit that does not exist, in four
  clauses, three of them pre-existing. Choosing the real target is a flow
  decision, not a rename.
- The two plan-storage gates, PlanStorageChoice and PlanSaveGateMenu, still give
  different options depending on entry path. Collapsing them redefines
  accepted_plan_active and is left to the change that owns it.

Gates: cfs validate PASS (0 errors, 0 warnings, 237/237); make test 5631 passed;
spec-coverage 90.8% coverage, 0.4609 granularity, both unchanged by this change;
pylint, ruff, vulture-ci, test-coverage clean.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Co-authored-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
…ric#145) (constructorfabric#166)

* feat(ui): make skill-invocation ASCII art default-off (constructorfabric#145)

Gate SkillInvocationArt behind a new [ui].skill_invocation_art_enabled
config flag (default false), so the decorative picture costs nothing
unless a project explicitly opts in. Uses the existing unset-sentinel
STATE pattern (see SimpleModeGate) to resolve the flag once per session
without conflating "not yet resolved" with "resolved false".

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* docs(ui): align skill-invocation-art PURPOSE and schema wording with actual gating (constructorfabric#145)

Address review nits on PR constructorfabric#166: the PURPOSE line still described the
picture as unconditional, and the schema described the flag as
"interactive"-scoped even though no such distinction exists in the
PDSL gate. Both now match actual behavior.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* docs(ui): scope skill-invocation-art PURPOSE to workflow entries too (constructorfabric#145)

CodeRabbit caught that the PURPOSE line said "skill entry" while
WHEN/RULES (and the schema) already cover workflow entries too.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

---------

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Co-authored-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
…a run where it did not (constructorfabric#161)

* fix(cf-ux): let the cf skill execute, and refuse to score a run where it did not

The Claude provider invoked `claude -p "/cf <prompt>"` with no permission
flag. Skill *execution* asks for permission and print mode has nobody to
ask, so the skill was denied, the agent answered the request directly, and
the answer was plausible enough for the shared rubric to pass it. The suite
then reported confidently on an agent that never loaded Studio -- and any
measurement built on it described the fallback path. The codex provider
never had this problem, because it has always passed the equivalent pair
(`--sandbox workspace-write`, `approval_policy="never"`); the asymmetry was
the bug.

Three changes, and the third is the one that keeps this from returning.

`--permission-mode bypassPermissions`, which is safe here: every invocation
runs in a fresh directory under the system temp dir that `_sandbox` wipes in
`finally`, on `atexit` and on SIGTERM/SIGINT/SIGHUP. `CF_UX_SHARED_SANDBOX`
is the exception -- it writes where the caller points it -- and the README
now says so.

Skill loading is detected positively, from the tool-call trace, rather than
by searching the prose: `--output-format stream-json --verbose`, then a
`Skill` tool-use event naming `cf` whose result is not an error. The guard
this replaces tested for "skills failed to load", a string the CLI never
emits, so the one check meant to catch this could not fire -- the real
signature is `<error>Execute skill: cf</error>`, which is now recognised too.

A run that did not load the skill returns a promptfoo **error**, not a
metadata flag. A fallback answer never reaches the grader; it is kept under
`unscored_output` for diagnosis instead. A transcript with no terminal
`result` event is an error as well, since there is then no answer to grade
and no trace to trust. An error says "could not measure", which is a
different thing from "Studio behaved badly", and the two were previously
indistinguishable.

Twelve tests drive the provider with a faked `subprocess.run` and sandbox,
because `claude` is not vendored here and a real invocation needs
credentials. They pin the flag, the positive detection, each way a run can
fail to load the skill, and that one malformed transcript line does not
discard the rest. Removing any of the three fixes fails its own tests.

What these tests do not claim, and what still needs a machine with the CLI:
that `bypassPermissions` makes the skill execute. That is a fact about the
CLI, established in the report by measurement, not something a fake can show.

Signed-off-by: ou <ou@constructor.tech>

* fix(cf-ux): bind the skill verdict to the cf call, and refuse a turn that stopped short

Review on the previous commit found the positive skill check could still say
"ran" for a run that did not run the cf skill. Three ways, all now closed:

- The name was matched by substring over the serialized tool input. The prompt
  is `/cf <request>`, so the argument text carries "cf" on every scenario here
  — a competing skill quoting the user message back counted as this one. Names
  are now compared whole, after dropping a `plugin:` namespace, and only values
  shaped like an identifier are candidates.
- Evidence was aggregated, not bound per call: "some Skill call succeeded" plus
  "some call named cf" was enough, even when those were different calls and the
  cf one errored. `ran` now needs a non-error result for the cf call itself.
- A cf call with no tool_result at all was read as a success. Absence of a
  result is not a non-error result; a truncated trace is refused.

Two more shapes are no longer graded: a terminal result event whose subtype
says the turn stopped short (`error_max_turns`, `error_during_execution`), and
a result event flagged `is_error` — the skill can load and the turn still fail,
leaving `result` holding a fragment. An absent subtype is deliberately not
treated this way, so an unfamiliar shape cannot manufacture failures.

Diagnostics, from the same review: unparseable lines are counted in
`unparsed_lines` rather than dropped in silence (a lost line can be a lost
tool_result, which is what the verdict is read from); `skills_invoked` holds
names with the raw inputs beside it under `skill_call_inputs`; every return
carries `duration_s` and `sandbox`, including both timeout paths; the
missing-result error names the budget ceiling as well as a stream-json
regression, since those look identical and lead to opposite fixes; and a
setup-command deadline no longer reports as the CLI timing out.

18 new tests (30 total), including one driving the real `sandbox()` to show the
tree `bypassPermissions` writes into is gone after a run dies — the claim the
permission flag rests on. Each behaviour above fails its own test when
reverted; 15 mutations, 15 caught.

Signed-off-by: ou <ou@constructor.tech>

* fix(cf-ux): bind the failure marker to cf too, and stop grading a non-text answer

Round 3 of review, including one bug in round 2's own fix.

- `Execute skill:` was matched with no name attached, so `<error>Execute skill:
  superpowers</error>` condemned a successful cf run — the mirror of the
  substring problem, on the failure side, and it fired before any positive
  evidence was weighed. Now bound to the name with a right boundary, so a
  hypothetical `cf-generate` failing does not implicate `cf` either.
- A non-string `result` was handed to the grader as-is: promptfoo would pass
  the rubric a dict and the rubric would score whatever it made of it. Text is
  what this suite grades, so a non-text answer is now reported rather than
  rendered, with a repr kept for diagnosis.
- The stopped-short branch returned before the cost was attached, dropping
  `total_cost_usd` from exactly the runs that spent the most. All four returns
  after the terminal event now carry it.

Diagnostics: the missing-result case adds `events_seen` and `last_event_type`,
which is what distinguishes a budget ceiling from a format regression
programmatically — naming both causes in prose still left triage reading the
tail by hand. A dropped stream line now also warns on stderr with its line
number; a count riding along in a metadata dict is not the same as saying so
where a person will see it, and the house rule is that a swallowed exception
warns (architecture/DESIGN.md).

The README's "every return carries duration_s and sandbox" was false for three
branches that fail before a sandbox exists. Corrected rather than made true —
naming a path that does not exist would be worse.

6 new tests (36 total). 7 mutations for this round, 7 caught; the 22 from
earlier rounds re-run and still caught.

Signed-off-by: ou <ou@constructor.tech>

---------

Signed-off-by: ou <ou@constructor.tech>
Co-authored-by: ou <ou@constructor.tech>
… util (constructorfabric#165)

Four commands — where-used, where-defined, list-ids, list-id-kinds — each
hand-rolled the same loop over `scan_cpt_ids`. Lift it into a shared
`utils/cpt_reference_scan.py` (`scan_records` + the target-filtered `references`
and `definitions`, plus a `graph_for` def<->ref view) and route all four through
it. Behaviour is identical — each command's output and its tests are unchanged.

`graph_for` is the def<->ref view the artifact-quality gap / traceability /
contradiction detectors will consume. It is unused today (whitelisted with a
removal trigger), so this is a genuine dedup of the four commands plus prep for
those later tasks — it does not dedupe or touch artifact-quality code, which has
no traversal yet.

- New `@cpt-algo:cpt-studio-algo-cpt-reference-scan` declared in
  `traceability-validation.md`; `cfs validate` resolves 0 errors.
- Full suite green; the util is unit + property tested at 100% line coverage.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Co-authored-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
…constructorfabric#167)

Three wording corrections to the PDSL spec, all found in review of earlier
changes and none altering behaviour.

The declaration-region rules left a precedence gap. Two bullets both apply to an
over-indented `OPTIONS:` and imply opposite outcomes: one says a line indented
deeper than the menu's other sub-headers is continuation text, the other says any
recognized section other than TITLE or TYPE ends the region. The implementation
resolves it by exempting TITLE, OPTIONS and INVALID from the continuation rule,
so those three keep section status at any indentation while every other header
does not. Verified against the checker: with an over-indented OPTIONS a later
TYPE is not read, and with an over-indented NOTES it is. The spec now states the
exemption, the asymmetry, and the order to resolve them in. A reviewer inferred
the opposite from the wording alone, which is what prompted this.

The keyword table gains the deferred `CONTINUE` variant, so the vocabulary
reference and the execution-semantics prose describe the same grammar.

The declaration-region text also now says that the first sub-header's own
indentation sets the level even when that header is itself over-indented, and
that the exemption governs whether a header is a section rather than whether it
sets the level. Verified against the checker: with TITLE at indent 4, a TYPE at
0 or 4 is read and one at 8 is not.

`CONTINUE <unit> after user.reply` was used in the corpus and specified nowhere.
The vocabulary list gives WAIT plus STOP_TURN as a hard assistant-turn boundary
and CONTINUE as a transfer that is not optional advice, but said nothing about
deferring a transfer past that boundary. Two reviewers consequently read the same
clause opposite ways: one as a dead CONTINUE written after the boundary, the other
as a premature transfer written before the WAIT. The spec now defines the form and
requires it to appear before the WAIT it defers past, since a CONTINUE after the
boundary is unreachable.

It defines that form for a clause carrying a single boundary and states plainly
that four cases are left open: a block with more than one WAIT, two deferred
continuations before one boundary, control leaving the branch first, and a
boundary never reached. Both present uses carry exactly one boundary, so the
corpus settles none of the four, and binding them belongs with an enforced check
rather than a sentence — the way declared gate risk was bound by a lint and a
frozen baseline rather than by prose alone. Recording them as undefined is
narrower than answering them on no evidence, and it stops the next reader
guessing silently.

Two residual numbers in one bullet, which names three auto-resolving paths and
then miscounted them twice: "Two shipped paths do auto-resolve gates, and
neither reads a declaration", and a later sentence saying the grandfathered set
rests on "those two paths". Both now say three. An earlier pass corrected the
second and missed the first, which is why the count is stated here rather than
just fixed.

The declaration-region rule had only one of its two halves under test: a deeper
`OPTIONS:` still ending the region was covered; a deeper `NOTES:` becoming
continuation text was not. The new test covers that half, so the two together
pin both.

Its wording needed one more condition than a first draft carried. A `TYPE:`
after a deeper non-exempt header is read only if that `TYPE:` itself sits at the
learned level -- deeper, it is continuation text by the same rule. The three
fixtures separate the two conditions a first draft ran together, and each uses
an invalid value, because a valid one cannot distinguish a declaration that was
read from one ignored as continuation text: an omitted declaration is legal and
reports nothing either way.

The runtime execution card documented only the immediate `CONTINUE`, while six
places load it as the runtime semantics slice, so an agent reading it would not
know the deferred form or its required placement. It now carries both. Its
NOTES also said no core module declares a gate `TYPE`, which stopped being true
when the duplicate-gate change merged; it names that module now.

No fenced example added, deliberately. Two menus defined inside this document are
pinned by block position in the untyped-MENU baseline, so a new fenced block
would renumber them and fail that guard.

Rebased onto main after the duplicate-gate change merged, which is what makes the
"both present uses" statement true: the second use of the deferred form arrived
with that change, so on the pre-rebase branch only one was discoverable.

The indentation rule is also driven through the CLI, not only the validator.
Every other test for it calls a helper that reaches `validate_source` in-process,
so argument parsing, the exit-code mapping and the JSON rendering were never
exercised for it -- a gap raised on this module once before and extended rather
than closed. One fixture per outcome now goes through `main()`: a declaration
that is read, one ignored as continuation text, and one nothing reads. Breaking
the exit-code mapping fails it, which is the property that makes it worth having.

Gates: cfs validate PASS (0 errors, 0 warnings, 240/240); pdsl validate PASS;
make test 5772 passed; spec-coverage 90.8% coverage, 0.4608 granularity; pylint,
vulture-ci and test-coverage clean.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Co-authored-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
…constructorfabric#161) (constructorfabric#171)

* test(cf-ux): pin the skill-name matcher's false-positive trade

Review follow-up to constructorfabric#161, which merged before this landed.

`_invoked_names` compares every identifier-shaped value in a `Skill` tool
input, because which key holds the skill name is not part of any stable
contract. Prose beside the name is excluded by shape, and that case had a
test — but a single bare token in an unrelated field is not, so a rival
skill invoked as `{"command": "superpowers:brainstorming", "mode": "cf"}`
reads as this skill running. Nothing pinned that.

Pinned rather than closed, with the reasoning in the docstring. Narrowing to
a fixed set of name keys would close this hole and open a worse one: guess
the key wrong and every run errors, because the name would never be found
where it actually lives. A rare false pass beats a certain false failure
here, and `skill_call_inputs` carries the raw input so the verdict stays
inspectable. If the key is ever pinned down, that test is where the trade
gets renegotiated.

Second test covers the other side: the comparison stays whole-value in every
field, so `mode: "cf-generate"` still does not match.

Two mutations, two caught. Narrowing the matcher to named keys fails the
first test and nothing else — which is what made this worth a test.

Signed-off-by: ou <ou@constructor.tech>

* fix(cf-ux): find a nested skill name, and say so when the match is ambiguous

Review on the pinned trade found the code did not implement the argument
made for it.

The trade is recall over precision: because the name's key is unknown, every
identifier-shaped value is a candidate, accepting a rare false pass to avoid
a certain false failure. But the scan stopped at the top level, so an input
nesting its identifier one level down — {"options": {"skill": "cf"}} — found
nothing and every run would error. That is the failure the trade exists to
avoid, sitting inside its own implementation. The scan now descends through
dicts and lists.

The other half of the review: "inspectable" only mitigates a false positive
if someone inspects, and nothing surfaced these. A `ran` verdict resting on
one of several candidate identifiers in the matched call is the shape a false
pass takes, so it now reports itself — `skill_match_ambiguous` in the
metadata plus a stderr warning naming the other candidate — instead of
waiting for someone to diff `skill_call_inputs` after the fact.

Five test gaps from the same review, all real:

- the false-positive match combined with an errored tool_result (the loose
  match buys a name, not a verdict)
- a namespaced value in an unrelated field, which reaches the same false
  positive through the separator-stripping path
- the positive test never asserted an answer was actually delivered, which
  is the whole point of the false positive
- the negative test never pinned `skills_invoked`, so silently dropping one
  candidate would have passed
- no test distinguished an unambiguous match from an ambiguous one

README documents the accepted trade, both mitigations, and which test pins
it — it previously listed only the false positives that were closed.

7 mutations, 7 caught. Reverting to the top-level-only scan fails the new
nested test and nothing else.

Signed-off-by: ou <ou@constructor.tech>

* fix(cf-ux): report the other candidates instead of judging them, and pin the walk

Four review findings, all valid.

The ambiguity flag claimed more than the signal supports. It fired whenever a
matched call's input named a second identifier, which cannot distinguish a
wrong-field match from a correct call that merely carries one — telling those
apart needs the very knowledge whose absence created the trade. So it is now
`skill_match_other_candidates`, a list naming what was observed, rather than a
boolean asserting a suspicion. Documented as the over-approximation it is: a
genuine `{"command": "cf", "mode": "auto"}` is listed too, and the run is still
scored.

That list reaches a warning a person reads, and it was neither deduplicated nor
ordered — the traversal is a stack, so a value appearing twice was repeated and
the rest came out in an implementation-detail order. Two runs of the same
transcript could produce different prose for the same finding. `_invoked_names`
now returns its names sorted and deduplicated.

Dropping the `isinstance(payload, dict)` guard was a real boundary change made
in passing: a bare string or list input is now scanned rather than refused.
Kept, because it follows from the same reasoning as the depth walk — an input
shape that cannot be ruled out must not go unscanned, or the name is never found
and every run errors — but now stated in the docstring, the README, and a test,
instead of being a side effect nobody declared.

The "any depth" claim was pinned only at depth one. Parametrized over two
levels, a list of dicts, a dict inside a list inside a dict, and nested lists.

6 mutations, 6 caught. The precise one: traversal that works at depth one but
stops below it fails four of the five depth cases and leaves `one-level`
passing, which is what makes the parametrization worth having.

Signed-off-by: ou <ou@constructor.tech>

* test(cf-ux): pin skill_state on the negative cases, not just the error text

The negative tests asserted the absence of output, a detail substring, and
sometimes the parsed names — never the state label itself. That label is what
downstream reads, and it was free to be wrong.

Demonstrated rather than assumed: mislabelling the none-named branch "absent"
while leaving its detail text correct passed all 52 tests. The error string
still read sensibly, because it interpolates whatever state it was handed.

`skill_state` is now asserted on every negative case, one per branch of the
ladder. Mutating each of the five labels in turn — including the "absent"
branch in the other direction — is caught, each by the tests that exercise
that branch.

Signed-off-by: ou <ou@constructor.tech>

---------

Signed-off-by: ou <ou@constructor.tech>
Co-authored-by: ou <ou@constructor.tech>
… question dialog (constructorfabric#184)

* feat(ui): make skill-invocation ASCII art default-off (constructorfabric#145)

Gate SkillInvocationArt behind a new [ui].skill_invocation_art_enabled
config flag (default false), so the decorative picture costs nothing
unless a project explicitly opts in. Uses the existing unset-sentinel
STATE pattern (see SimpleModeGate) to resolve the flag once per session
without conflating "not yet resolved" with "resolved false".

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* docs(ui): align skill-invocation-art PURPOSE and schema wording with actual gating (constructorfabric#145)

Address review nits on PR constructorfabric#166: the PURPOSE line still described the
picture as unconditional, and the schema described the flag as
"interactive"-scoped even though no such distinction exists in the
PDSL gate. Both now match actual behavior.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* docs(ui): scope skill-invocation-art PURPOSE to workflow entries too (constructorfabric#145)

CodeRabbit caught that the PURPOSE line said "skill entry" while
WHEN/RULES (and the schema) already cover workflow entries too.

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* feat(pdsl): route blocking EMIT_MENU gates through a harness's native question dialog

Studio's blocking menus render as prose, indistinguishable from a finished
turn. Adds a per-target ask-tool binding (Claude -> AskUserQuestion) threaded
through generated shims via _follow_protocol_lines, and a shape-fit-aware
routing rule in pdsl-execution-card.md: native dialog when bound and the menu
fits (<=4 fixed options), description-based fallback otherwise, text fallback
when the menu can't be represented as fixed choices. Covers all existing
EMIT_MENU sites without touching any of them.

Refs constructorfabric#142

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(pdsl): close review-found gaps in native-dialog EMIT_MENU routing

Addresses review feedback on the constructorfabric#142 native-dialog change:
- Pre-upgrade generated shims (no ask_tool_name/ask_tool_description lines
  at all) are recognized as pure generated stubs again, so legacy-cleanup
  and auto-regeneration keep working across upgrades.
- A never-bound ask_tool_name (e.g. workflows entered via
  WorkflowBootstrapRouterPrelude, which never loads required-bootstrap.md)
  is now treated identically to an explicit `unset`, closing an undefined
  third state in the routing rule.
- The routing rule now states explicitly it applies to EMIT_MENU paired
  with WAIT/STOP_TURN, that a native invocation is itself that turn
  boundary, and that an out-of-band answer/cancellation/dismissal/error
  from the native tool routes to the menu's own INVALID handler instead of
  silently advancing past the gate.
- Added a regression test tying required-bootstrap.md's ask-tool handoff
  rule to the exact variable names agents.py emits.

Refs constructorfabric#142

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(pdsl): grant AskUserQuestion in allowed-tools, tighten native-dialog mapping

Second round of review-driven fixes on constructorfabric#142:
- Every Claude template that instructs `ask_tool_name = "AskUserQuestion"`
  now also grants AskUserQuestion in its `allowed-tools:` frontmatter line
  (via _CLAUDE_ASK_TOOL_NAME, not a second hardcoded literal). Without this
  the generated shim told the assistant to call a tool it wasn't permitted
  to use, making the whole feature a no-op for the one target with a real
  binding.
- Added test coverage for _KIT_WORKFLOW_SKILL_TEMPLATES['claude'], a
  separate production call site the existing test didn't reach.
- Tightened the native-dialog mapping rule: TITLE fills the tool's single
  question/header field (one EMIT_MENU per invocation, never batched), and
  each OPTIONS entry's number/alias is retained as its canonical identity
  so the returned selection resumes the right branch regardless of label
  rendering/truncation.
- Documented which of the four shared-bucket targets are verified-compatible
  today (none; reserved for a future harness) vs. Claude (confirmed).

Refs constructorfabric#142

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

* fix(pdsl): make the native-dialog field mapping a concrete abstract contract

Third round of ainetx review feedback on constructorfabric#142: the previous "TITLE fills
its question/header field" wording was still informal prose with no named
request shape, and the two field names ("question" and "header") read as
interchangeable when they're actually distinct fields on real tools.

Replaced with a fixed abstract contract that's independent of any one
tool's literal field names (a single prompt string, an ordered list of
options each with a display label, display description, and a retained
canonical identity), plus a concrete worked example for the one tool
actually bound today (Claude's AskUserQuestion: one questions entry,
question/header set from the prompt, options[].label/description from
each option).

Refs constructorfabric#142

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>

---------

Signed-off-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
Co-authored-by: TECK KEAT WILSON <yeow.teck.keat@constructor.tech>
…structorfabric#168)

Nothing writes a decision-log entry when a workflow resolves a gate without
asking, so an autonomous resolution leaves no trace. This adds the event and the
write path a chat gate uses to produce one.

`record_gate` writes `event: "gate"` with the subtype in `payload.kind`, so a
reader filtering on the event name sees all five. It reuses the existing writer
rather than adding a second one, which is what gives it the redaction, the file
locking, the rotation and the schema for free — and the never-raises contract
that keeps instrumentation from changing what a command does.

`GateRuling` carries the frozen resolution shape, `decision_key` -> `value` ->
`provenance` -> `status`, plus the ruling's own reason and cost-if-wrong. Those
names are the plan's, deliberately: the shape the kit owner froze requires the
ledger to share the plan's vocabulary rather than become a second format to look
in, and renaming after a log exists is expensive. Every field defaults to the
literal `unspecified`, because an omitted value has to be visible — a missing key
and a key meaning "not specified" must not be distinguishable only by absence.

`cfs gate-log` is the write path. PDSL reaches Python by running a subcommand, and
commands are already what record events, so a gate that auto-resolves runs this.
It refuses a ruling that proceeded without a human and states no cost-if-wrong,
refuses an unnamed gate, and constrains the declared type, provenance and status
to their closed sets. It exits zero for every failure that is only the logging's:
a gate's authority comes from its declared type, so a full disk or a user's
opt-out must not stop Studio deciding anything for itself.

Four causes are reported distinctly rather than two, because "this is not a Studio
project" was previously reported as a write failure that never happened.

What these fields do not yet buy is written where it is claimed. They were
introduced as the anchor for a later audit comparing a resolution against the
gate's source as it was; they cannot do that alone, since the engine version is a
hand-edited literal last moved in June with 55 commits to the prompt modules
since, and the record holds no source digest or rev. Pinning an event to its
source belongs with the change that builds the audit.

Reviewed twice, and both passes were necessary. A five-finder adversarial pass
found a privacy leak, a crash path and an audit-erasure vector; walking the
playbook's own A/B/C checklist found an encoding hole, a guard divergence and an
undefined precedence. Neither found what the other did.

The three that mattered:

- capping ran before redaction, and the truncation marker landed where the
  `$HOME` substitution's boundary lookahead had to match, so an absolute home
  path reached the log. Redaction now runs first, and the marker counts against
  the cap rather than being appended past it.
- `is_enabled()` reaches `Path.home()`, which raises `RuntimeError` rather than
  `OSError` when `$HOME` is unset and the uid has no passwd entry. The writer
  degraded correctly; the command died reporting the outcome.
- four author-controlled fields were uncapped, so ~500 KB per event drove the
  5 MiB rotation: ten calls made a real record unreachable and twenty destroyed
  it. Every field is capped now, identity fields included.

`read_events` also reads the rotated segment, which its oldest-first contract
always implied: one rotation used to put half the history on disk and out of reach
of the module's own reader. Tolerable for telemetry, not for an audit trail.

A third round, this one by the PR's own bots, found three more.

- Sonar put `read_events` at a cognitive complexity of 19 against a limit of 15,
  and at zero on main: the two-segment read introduced it. The three
  near-identical filter guards are one rule, now stated once in `_matches`.
- `--kind auto-proceeded --status absent` recorded a resolution that never
  happened. A kind meaning the workflow resolved it now refuses a status meaning
  nobody was asked.
- the two segments were read without the writer's lock, so a rotation between the
  two reads moved the pre-rotation live events into the segment already read and
  they appeared in neither. Both reads now happen under that lock.

An unreadable opt-out sentinel was also reported as `logging-off`, which conflates
"off" with "unknown". `logging_state()` returns the third state; `is_enabled()`
keeps its bool contract for the writers that only need to know whether to write.

Taking both segments as one snapshot under a lock is not what the step it landed
in says it does, so it is declared as its own.

A fourth round found that two of the fixes above were the narrow case of a wider
one, which is the more useful finding.

Redaction and capping guarded the persisted record and neither of the other two
places the same author-controlled text comes out: the command echoes the gate it
was given back to its caller, and this module warns about off-list values. Both
carried the raw argument, so a gate name holding an absolute home path lost the
username on the way in and kept it on the way out. `capped_text` is the record's
own transform in public form, and all three sinks use it.

Validation had the same shape. `declared_type` was checked against its closed set
because the CLI refused a paraphrase while a library caller could write one --
but `provenance` and `status` were declared closed and never verified, so half
the ruling was still looser through the library than through argparse. One rule
now covers all four vocabularies, warning rather than refusing, with the
`unspecified` default exempt.

Two further holes in the never-raises contract:

- `read_events` raised `UnicodeDecodeError` on a log with one bad byte. That is a
  `ValueError`, so it passed straight through the `OSError` guard, contradicting
  this module's stated tolerance and losing the whole trail rather than the
  damaged line. Segments decode with `errors="replace"`; the damaged line then
  fails `json.loads` and is dropped, as a malformed line already was.
- `_gate_types` caught only `ImportError`, and it runs outside the guard around
  the payload build, so anything else raised at import time escaped `record_gate`.
- the guard around the payload build reproduced the defect it was written to fix.
  It called `str()` on the exception it had caught, which is the same act of trust
  one level up, so an exception with a hostile `__str__` raised straight out of the
  handler meant to contain it. `_describe` falls back to the class name, which
  needs nothing from the object.

The tests that were asserting these properties were not testing them. The JSON
reason was pinned by calling the formatter with a dict built in the test, so
`logging-off` could have been emitted for an unwritable log with every assertion
passing; each of the four causes is now driven from its real condition. The cap
and the redaction were asserted through the two obviously author-controlled
fields; all eight are asserted individually, and uncapping one fails only its own
case. `GateRuling`'s `frozen=True` had nothing pinning it.

A fifth round found the sink argument holds one step further than it was taken.
`_describe`, added in the round above to stop the recovery path raising, redacted
its text and neither capped it nor neutralised surrogates -- so the fix for
"every sink gets the same transform" was itself the one sink that did not. It
uses `_capped` now, like the record, the echo and the warnings.

Two reporting gaps, both where the output channel decided what a reader learned:

- a refusal decided before the writer is reached says nothing about logging, so
  `empty-gate` printed one identical line whether logging was on, off or
  undeterminable, while the JSON carried the difference. The human path states it.
  The alternative -- dropping the key from the payload for those reasons -- would
  have reinstated the varying key sets that made callers raise `KeyError`, so the
  reason for choosing the other option is recorded next to the code.
- a rotated segment that exists and will not read was logged at debug, which is
  half an audit trail missing at a level nobody enables. It warns and names the
  file. Absence stays quiet, because absence is normal.

The lock and its fallbacks were asserted by reading the code, which is not
evidence, and that was the substance of the review rather than its severity. A
test now holds the writer's lock from a second descriptor and pins that the read
waits, then completes once it is freed; removing the `flock` fails it. Both
degradations -- no `fcntl`, and a lock that cannot be opened -- are entered by
tests, and breaking either fails only its own. One assertion of `!= 0` became the
exact refusal code, which the sibling test one screen down already pinned.

A sixth round settled the open design questions on the review rather than the
defects, so these are rulings, not corrections.

An autonomous ruling must now name the decision it answered as well as what being
wrong costs, and neither requirement can be defeated by how the sentinel is
spelled. `.strip() == UNSPECIFIED` compared case-sensitively against the lowercase
literal, so `Unspecified` -- the constant's own display form, capitalised -- was
visible text that satisfied the blank check and missed the sentinel one: the guard
that makes an autonomous resolution auditable was defeated by a shift key. A kind
that involved a human still requires neither, because the requirement is about
proceeding without being asked.

Reading the rotated segment unconditionally was the opposite failure to not
reading it at all. Nothing on disk distinguishes a `.1` this log rotated into from
one left behind when an operator cleared the live log, so a cleared log read as
continuous history including the events they meant to be gone. A rotation now
opens the new live segment with a `rotate` event naming its predecessor, and a
backup is read only when the live segment's *first* event claims it.

That has a cost worth stating: a log rotated before this change is indistinguishable
from a cleared one, so its backup is excluded. The safe direction is a short trail
rather than a fabricated one, but it is not a free fix -- four tests built a `.1`
by hand and were asserting the old contract, one of them the very fix that made
the rotated segment readable. So the exclusion warns rather than happening
silently: only the reader knows which case they have.

Two coherence warnings, and deliberately not the cross-product that was suggested.
`plan-resolved` names the plan as its source, so another provenance contradicts
the kind; a status of `resolved` with no value resolved to nothing. Every other
combination stays unchallenged, because refusing shapes nobody has shown to be
wrong would make instrumentation decide what a caller may believe -- and
`auto-proceeded` can legitimately proceed on policy, a recommendation or the plan.

Declining two of the six: a `write-failed` that distinguished a fresh failure from
the latched no-retry state changes nothing any reader acts on, and requiring a
written `why` adds a refusal without adding a safety property, which the stated
cost already carries.

The rotation link is declared as its own step. It is new algorithm with a new
event name rather than more of the append it sits beside, and granularity had
fallen to 0.4598 against a floor of 0.46 -- a real gate failure, not a rounding
one.

A seventh round found the rotation link and its own test both weaker than the
round that added them claimed.

The claim check read the first event `parse_events` yielded, and that function
skips what will not parse -- so a malformed or injected first line followed by a
`rotate` event was accepted, and the guard against joining a stale segment could
be stepped over by one unparseable byte. It parses the first *physical* nonblank
line now: a claim that is not the very first thing in the file is not a claim.

The off-list and coherence warnings ran *before* the guard that makes this
function never raise. They compare caller-supplied values and stringify the
off-list ones, so a hostile `__eq__` propagated out of `record_gate` -- the checks
meant to make a record trustworthy were the ones that could break the contract.
This is the same boundary mistake as the lazy gate-type import and the recovery
path before it, one layer further in; everything a caller's object can influence
now sits inside the one handler.

The test for the unreadable-segment warning was passing without testing anything.
It appended the rotation link *after* the live event, so the backup was excluded
and the branch never ran -- and because the rotation event was itself in the
events it read, the guard clause meant to allow for root never matched, so the
assertion never executed at all. The link is written first now, root is an
explicit skip rather than a silent condition, and downgrading the warning fails
it. No other conditional assertion of that shape exists in either file.

Two smaller ones. A `logging_state` case read the real `~/.cf-studio/decisions.off`,
so a maintainer who had opted out on their own machine failed it for a reason
unrelated to the code; it is isolated before the first assertion. A class-level key
set is a `frozenset`, as this codebase's own constants already are.

Two of the new tests were weaker than what they claimed to protect.

The immutability test caught `Exception` and accepted any message containing
"frozen", so misspelling the field it assigned would have raised `AttributeError`
and passed -- a test that could only fail for the one reason it was not looking
for. It names `dataclasses.FrozenInstanceError` now, and flipping `frozen=True`
fails it.

The rotation test asserted two independent claims on one line, so a failure could
not say which half broke: that the newest gate survived, and that at least one
older one came from the backup. They are separate assertions.

Neither is a defect in shipped behaviour and both were found by the static
analyser rather than by the pass that was supposed to look for them, which is the
second time in this change that reading a gate's verdict was mistaken for running
its checks.

An eighth round, and the Major in it is a defect this repo had already fixed
once. `_read_segments_locked` called `fcntl.flock(LOCK_EX)` with no bound, so a
process holding that lock could hang `read_events`, `summarize` and the command's
own reporting path with no way out. `atomic_io.with_file_lock` exists precisely
because that happened before — constructorfabric#136, round-4 review, also Major, where an
always-blocking path could hang a whole `cfs retrieve` call — and it carries the
bounded poll loop written for it, POSIX `flock` having no native timeout. Writing
a second lock call one module over reintroduced the defect the helper prevents.

It now waits through that helper with a five-second bound and, on timeout, reads
unlocked with a warning. That is the fourth degradation in this reader and it
points the same way as the other three: a log that cannot be snapshotted is still
evidence, so the snapshot is what gets given up, never the trail.

The test for it runs the read on a thread with a bounded join, because an
unbounded reader does not fail a test — it hangs it, and a hung test is a worse
signal than a red one. With the bound removed it now reports in ten seconds
instead of stopping the suite.

Two smaller ones from the same review. `_describe`'s inner handler returned the
exception's class name with no log line, so a failure inside the failure path was
invisible in the one place a reader has nothing else to go on; it says so now.
`Exception` stays the right width there — `KeyboardInterrupt`, `GeneratorExit`
and `SystemExit` derive from `BaseException` and were never caught. And the
core-infra bullet still said only `why` and `cost_if_wrong` were capped, which
stopped being true when every field was capped to close the audit-erasure hole;
the sentence my own change falsified is corrected.

The bounded wait is declared as its own step. It is behaviour no existing step
described, and granularity had fallen to 0.4599 against a floor of 0.46 — a real
gate failure, not a rounding one.

A ninth round, and five of its eleven findings were defects introduced by the
eighth. That is the pattern now: the checklist is run against the change and never
against the repairs, which is where the defects moved once the first pass got good.

The Major is a name. `_write_rotation_link` serialised `backup.name` straight into
JSON, and a filesystem name arrives through surrogateescape, so a raw byte becomes
a lone surrogate `json.dumps` cannot encode. That raises `UnicodeEncodeError` --
a `ValueError`, straight past the `OSError` guard. The damage is worse than a
failed write: `os.replace` has already rotated the file by then, so the backup is
left permanently unclaimed and unreadable by the very check the link exists to
satisfy. The name is capped first now, which neutralises the surrogate, and the
guard catches `ValueError`.

`core_version` is no longer an argument. Only tests ever passed one, and a handed-in
value landed in the record indistinguishable from one the engine reported, so an
auditor reading that field could not tell which they had. The engine is read here or
not at all; a test that needs a fixed version patches the reader.

The five from the previous round: a new constant inserted between `_GATE_TEXT_CAP`
and the comment explaining it, orphaning the rationale; a test that raised the
capture level to DEBUG and then asserted nothing about what was captured; two lazy
imports sharing one `try`, so a missing lock helper was reported as a platform
without file locking; `os.geteuid()` with no guard for a platform that has no such
call; and a module path resolved from the working directory, so a test passed only
when pytest ran from the repository root.

The rest: the command has four refusals and the checklist described one; three of
the four not-recorded causes asserted `reason` alone, where the contract a caller
reads is the whole `{recorded, logging_enabled, reason}` triple; the cap was only
ever tested at three times its bound, which cannot distinguish a correct cap from
one off by one; and `decision_key`, `value` and `core_version` had no `$HOME`
redaction test of their own.

One more, found by running the suite rather than the file: ten `caplog` scopes
captured on the root logger, so they passed alone and failed under `-n 6` at the
mercy of whatever else in 5,900 tests had touched logging. They name this module's
logger now, and three consecutive full runs are clean.

Gates: cfs validate PASS (0 errors, 0 warnings, 240/240); make test 5901 passed;
test-coverage 97%; spec-coverage 90.8% coverage, 0.4600 granularity; self-check,
check-versions, validate-kits and the enforcement-gate corpus pass; pylint and
vulture-ci clean.

Signed-off-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
Co-authored-by: Sanjeev Solanki <sanjeev.solanki@constructor.tech>
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 88634423-49e4-43d7-82a9-c8e0b37e1b44

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@code-ranker-app

Copy link
Copy Markdown
Contributor

code-ranker report for this PR (built on fork): https://reports.code-ranker.com/6NLXu6yth5hahduCZY-szQ/

@sonarqubecloud

Copy link
Copy Markdown

@Oleg67

Oleg67 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Closing — not needed as a change. Recording the finding it was opened on, since it is still useful for cutting the tag: release/v1.7.0 points at e3a283fd, byte-identical to the v1.6.2 tag, and main is 82 commits ahead of it with none behind. The branch is a strict ancestor, so it can simply be fast-forwarded to main (309003df, green on all 20 checks) with no PR, cherry-pick or conflict. Two notes that still apply: #150 is open against main and so is not in that range, and per ADR-0021 no local version bump is needed before tagging.

@Oleg67 Oleg67 closed this Sep 13, 2026
@Oleg67
Oleg67 deleted the chore/release-v1.7.0-sync branch September 13, 2026 22:31
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.

6 participants