Repository navigation
v2.2.28 — attribution reads the executor's exit code, and two published verdicts are corrected (2026-09-19)
Latestv2.2.28 — attribution reads the executor's exit code, and two published verdicts are corrected (2026-09-19)
-
Cold-start assets now read their facts instead of memorising them, and the tick's timeout
budget got a gate (AGENTS.md+.qoder/rules/ig-marker-triage.md+
src/infinigrow/engine/tick.py+tests/test_tick_timeout_budget.py+ the handoff redaction
plan). The repo's own onboarding docs pinned a HEAD tag and a "no open work" verdict — and they
were already stale on arrival (they saidv2.2.25; HEAD was two releases ahead). Anything that
moves every commit is now a command to run (git describe --tags,git status --short,
infinigrow version --check), not a number copied into prose. A new constant
MAX_EXECUTOR_CALLS_PER_TICK(= 2: the tick call plus the org-session call) feeds a budget gate:
executor_timeout_s × callsmust stay under the scheduled task's-ExecutionTimeLimit, or
Windows kills a run mid-flight and leaves a half-written ledger — a relation the local
IG_EXECUTOR_TIMEOUT_S=600(~20 min against a 30 min wall) makes real but nothing watched. The
gate reads the config default, not the machine env, so it is deterministic. Separately, the
handoff notes carry hundreds of machine-specific absolute paths; they are not edited here
(changing another session's handoff assets is out of scope) — a redaction plan is filed as a
pending decision instead. -
The observation surface became a live reading, and the trigger it documents has fired
(src/infinigrow/engine/subject.py+engine/tick.py+tests/test_subject_composition.py
+ bothgrowth-subject.md). The doc promised that the day evidence files crowded the
observation surface, a "movable" criterion would be designed first. That day is measured, not
guessed:observation_compositiongroups the 20-slot file window by top-level directory and the
tick trace now prints it, so "who is eating the slots" is a computed reading instead of a manual
read of a trace file. At tick 745 the live subject readsapp18 /journal2 (app evidence
files, ever-newest by mtime and deliberately never rotated, crowd the growing journal out). A
copy-based comparison confirms the cause: pruningapp/to its 4 newest files flips the same
window tojournal16 /app4 — it is app's mtime ordering, not the size 20. Conclusion:
needs a decision, not a bigger constant. The observation caps (10 / 20) are untouched this
round; widening them would let still more evidence files crowd outjournal/without adding one
accountable quantity.tests/test_subject_composition.pypins grouping, that the archived subtree
never counts, and the reverse case where app squeezes journal out of the window entirely. A latent
locale bug surfaced on the way:tests/test_release_tooling.pyran the extraction tool as a
subprocess and decoded its output with the machine locale, so the em dash in a CHANGELOG heading
crashed the Windows GBK reader thread (stdoutcame backNone); the call now pinsencoding="utf-8". -
The write surface got direct tests, the weak assertions became real, and five dead
definitions were retired (tests/test_store_gates.py+tests/test_rules.py+
tests/test_version.py+tests/test_release_tooling.py+.github/workflows/ci.yml+
src/infinigrow/rules/static_scan.py+core/paths.py/engine/sprout_queue.py/
ledger/rotation.py+ bothsuperseded.md+ bothmarkers.md).ledger/store.pyis the
only place allowed to touch disk, yet its eight write gates had no test of their own — they
were only covered indirectly when the engine happened to pass through them, so a broken
marker gate or a clobbered exclusive-create could sit unnoticed.tests/test_store_gates.py
pins each gate (append/guard, bad-line tolerance surfaced not hidden,require_within
rejecting escapes and symlink hops,create_exclusivelosing the race without clobbering,
write_work_file's marker gate refusing a shrink, atomic replace leaving no.part,
move_filewriting the archive before unlinking the source). Three assertions that had
drifted weak are now strong: every rule must actually PASS on the clean repo (not just
"return without raising"); the current version must appear as a real## v<version>section
heading with the top section the newest (a mere cross-reference no longer counts); the
release tool is checked by exit code (rc 0 present, rc 1 missing tag, rc 2 bad usage),
not just by an exception's message. A new check enforces every git tag has a CHANGELOG
section — which needs CI to fetch tags, soci.ymladdsfetch-depth: 0(a shallow
checkout lists no tags and would make the check vacuous). The R2 prompt↔code rule no longer
skips when the prompts are empty: empty/missing prompt text is now itself a failure (the old
prompt_text and ...guard let an empty prompts dir pass silently), with a reverse case in
selftestand a floor test pinningMIN_SYNC_TERMSat 17 terms ⊆ the coverage table. Five
never-called definitions (ENV_SUBJECT_ROOT,all_dirs,to_records,ledger_sizes,
total_archived_lines) — measured 2026-09-19 to have zero callers repo-wide — were retired
through the superseded four-step (a new rowS10, old rows kept,scan+pytestgreen).
No limit constant moved. -
S9's retired exemption stopped masquerading as live in the Chinese comments
(src/infinigrow/cli.py+engine/sprout_queue.py+engine/domain_saturation.py+
tests/test_sprout_queue.py+ bothsuperseded.md). The S9 retirement guard watched only
the English identifierlong_task, so five comments could still describe the exemption as
current behaviour in Chinese —cli.py("长任务芽豁免连领上限"), the queue docstring
("连领上限 3 拍(长任务芽豁免)"), and three indomain_saturation.py("…且非长任务") —
right under a superseded row that says there is no exemption. Measured 2026-09-19: the word
长任务 appeared nowhere in the mechanism itself, only in prose, and the code has no such
condition. The five comments now say plainly there is no exemption; the return guard was
widened to reject the Chinese name 长任务 anywhere insrc//prompts/(not just the English
usage), with a reverse case proving both spellings are caught while a backtick prose mention
oflong_taskas history stays allowed. Bothsuperseded.mdS9 rows record the widened
guard. No constant or behaviour changed — this is wording, not a reopening of K16/S9. -
The read side of the CLI entered CI, and so did Python 3.13 (
.github/workflows/ci.yml+
tests/test_cli_surface.py+ both READMEs +CONTRIBUTING.md). Until now CI exercised only the
write path (dry-run/tick/gardener/org-status);status,redemption --json,rotate,
rotate --searchandversion --checkhad never run on any CI, even though the docs publish them
as the reading surface — androtate --search, the documented answer to "moved but still
findable", had zero cases. The new step reuses the cold-start temp state root (${{ runner.temp }})
so it runs against ledgers that have content, on both ubuntu and windows;tests/test_cli_surface.py
pins the hit and miss directions of--search(hit must reportfile:line, miss must report 0 and
not crash), the exit-code contract ofversion --checkoffline (behind → 3, equal → 0), clean
stderr on the success path, and that the Python versions the docs list are exactly the CI matrix.
3.13 joins the matrix on measurement, not hope: this machine's interpreter is 3.13.7 and the
whole gate battery (pytest / ruff / scan R1–R10 / selftest / prompt-sync / privacy / abs-path) is
green on it. End-to-end on a copy of the live state root (the live one untouched):rotate --max-bytes 4000moved 1,290 + 3,462 + 1,233 lines intoarchive/, thenrotate --search
reported 20 hits in the archived files (rc=0) and 0 hits for an absent needle (rc=0); the config
source line printeddefaults、callerwithIG_EXECUTOR/IG_EXECUTOR_TIMEOUT_Scleared. -
The project privacy layer is now provable on CI instead of permanently skipped
(tests/data/privacy-deny-sample.txt+tests/data/privacy-sentinel/leaky-example.md+
tests/test_privacy_deny_layer.py+.github/workflows/ci.yml+ bothprivacy.mdfiles).
The real deny list is machine-specific and stays out of the repository — that is by design and was
not changed — but as a consequencetest_repo_is_free_of_project_deny_hitsskipped on every CI
run, so nothing anywhere proved that--denystill bites; a broken loader would have read as
green. The fix is not to delete the skip (it is genuine environment adaptation): a committed
deny sample made of invented terms, plus a sentinel carrying the payload it must catch, now prove
both directions on ubuntu and windows — deny given → the three rules report hits and exit 1;
payload removed → zero hits and exit 0 — and the sentinel is itself checked to contribute no
generic-layer hits, so shipping it cannot dirty the repo-wide gate. -
Bilingual parity moved from headings to body text, and the marker numbering got a registry
(tests/test_docs_bilingual.py+docs/markers.md+docs/zh/markers.md+
tools/check_markers.py+ the four English sections that were empty). The old check compared
only the heading skeleton, so a section could exist in English and be almost hollow: measured
today,growth-subject.md§4 was 18 Chinese lines against 3 English ones, §7 11 against 5, and
running.md§1/§2 13 against 3 and 16 against 5 — which is exactly how the N48 series, the
"19 characters" measurement,SUBJECT_DIR_LIMITand the K12/A17 timeout relation ended up
readable in one language only. The new checks are positional and mechanical: every section with
≥8 Chinese lines must have at least half that body in English, and number-carrying anchors must
appear in both books (measured, not copied: the brief's "mechanism.md is 240 lines short /
tick.md 189" no longer holds — the English mechanism book is 398 lines against 377, and there is
nodocs/tick.mdat all, so those two figures are corrected here rather than repeated). The
registry answers a second gap: ids likeK15orN48-2were cited but never defined anywhere.
Every id referenced indocs/now has a row (id | proposition | where it is enforced | status),
with three rulings stated honestly: K15 is registered as undecided (the ordering key is not
touched), G7 as one id, two meanings (the capability-library ETA and rotation), and
H8/O5 as orphans — H8 duplicates Q13/A10 and O5 has zero references repo-wide, so
neither may be cited as if it were an in-force decision.tools/check_markers.pyprints the same
diff the tests enforce. No limit constant moved. -
Configuration defaults now have one source, and three numeric gates became readable
(src/infinigrow/core/config.py+tests/test_config_single_source.py+ bothmechanism.md
+prompts/org-session.md+ both READMEs).Settingsused to spell its 24 defaults out twice —
once in_FIELDS, once in the class body — so editing one silently separated the runtime default
from the table humans read (and the table is the published wording). The class body now derives
from the table, and the test proves the derivation rather than restating it: it fails if a literal
default reappears. The other half of the gap was invisible thresholds:DEFAULT_ZERO_GAP(10
rows),TRACE_GATE_FILES(the "used this tick" gate read "the last few traces" — a number that
existed only in code) andreview_frozen(max_relight=…)(never mentioned anywhere outside the
source, includingprompts/, although it is the queue the session competes for). Each now carries
its own number in both originals, inprompts/org-session.md, and in a case that interpolates the
constant — change the constant without the docs and it goes red, in either language. Behaviour
cases cover four knobs rather than their parsing: cooldown minutes flip the same ledger from
"cooling" to "gap backfill",tick_minutesreaches the scheduler as seconds and rejects
non-positive values loudly,executor_timeout_sreally kills a slow executor, and
rotate_keep_filesdecides how many traces stay while the excess moves (never deletes).
No threshold value changed (docs/versioning.md: this is provenance, not mechanism). -
tools/split_monolith.pyis registered as parked rather than newly tested (CONTRIBUTING.md).
It is analysis-only and its--srcinput — the v1 monolith — is not in this repository, so a
behavioural case would mean committing a fabricated monolith and testing the fixture; CI keeps
covering its--helpsyntax, and the note records what to add (--table) if a real input appears. -
Failure attribution gained a second executor-side test (
engine/reconcile.py
executor_tick_failures+cli.pyredemption+ bothmechanism.mdfiles +
tests/test_redemption_report.py). The "dropped on the executor side" bucket used to recognise
only the literal "output was not parsed" marker in the trace, which the adapter writes when
parsing fails. When the executor exits non-zero it writes no such marker, so that failure was
filed against the subject: at tick 723state/executor.jsonlhasrc=1/usage=unknownwhile
the outcome row for the same tick isverifiable=true,redeemed=false— the age rule called
it "genuinely not done". The bucket now also fires when every tick-class call of that tick
exited non-zero or timed out, with the disqualifiers kept strict (onerc=0attempt in the same
tick, or only org-session calls, is not an excuse). Measured on the live ledgers at tick 730:
723 moves buckets, 499/521 stay put, 676/679 keep the marker route, and the numerator and
denominator are untouched (318 sampled / 311 redeemed / 7 failed / 363 unverifiable). -
"Proposal went stale" is now stated as what it is — a mechanical proxy, pending proof: age
≥ 30 ticks proves the premise was old, never that the proposal's content expired, and the bucket
is only reached once both executor-side tests fail to hold. The wording was already hedged in the
Chinese original; the English table now carries the same grade instead of a bare "proxy". -
Two published verdicts in the evidence section were re-run and are publicly corrected
(bothmechanism.mdfiles +tests/test_evidence_snapshot.py+
tests/data/redemption-tick-00731.json). v2.2.27 quoted a tick-624 snapshot as if it were a
conclusion; the ledgers only grow, so by tick 731 the readings are 682 led rows / 319 checkable
samples / 312 redeemed / 363 unverifiable (53% of leads) and the two claims failed a live
re-run: (1) thelib*origin was reported as no samples, yet the bucket holds
8 checkable samples since tick 654 (654, 656–662) — and n=8 buys a sample count and its ticks,
not a 1.00 verdict; (2) the misses were reported as 4 rows with 0 "genuinely not done",
yet there are 7, splitting 3 / 2 / 2 under the attribution above. The "no case to extrapolate"
discipline is kept and extended: it binds the n=8 bucket exactly as it bound the 0-case one.
Every figure in the section is now checked against oneredemption --jsonsnapshot by a test, so
corrupting a number in the docs turns CI red; the README's test-size figure moved to ≈6,600
because the new cases are 236 lines.