From 94bec5c2d1ed92267c291cefbc7b52e68bc436aa Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 17 Oct 2026 14:33:20 +0800 Subject: [PATCH 1/7] =?UTF-8?q?emrg:=20the=20cycle=20reads=20extra=5Fpromp?= =?UTF-8?q?t,=20and=20=C2=A71.5=20stops=20paying=20for=20a=20read=20it=20n?= =?UTF-8?q?ever=20finishes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps, both in the one place a cycle never looked. `~/.emrg/tasks.yml` appeared in this template only as the `extra_prompt` injection heading (:14-17). That field is where an installation writes what it believes its own task prompt does not say, so it is a defect report about the prompt — filed by the session least able to act on it. New §1.7 reads it as evidence about the prompt and classifies each block as instance configuration, already in the template, or missing from it; the third class is written into the template's own section in the template's own voice. Measured on this host: two of four tasks carry a non-empty `config.extra_prompt` (4,325 and 6,990 chars), and their headings mix instance facts with general method — metric discipline, conclusion discipline, channel discovery. A second project's memory says the same thing independently: `competition`'s `d4e6b8a0-tasks-yml-extra-prompt.md` records that `extra_prompt` is that task's only rendering channel for cross-round lessons. §1.5 said "for each project read its `.emrg/memory/` and `.emrg/sessions/`" — 12 projects here, with nothing saying what counts as a finding, so the next cycle reads it all again. It keeps its coverage and gains a procedure: a step 0 that reads no content (newest memory mtime against a bookmark), indices rather than bodies, four named reasons that justify opening a body, one or two projects per cycle by rotation, conversation read by located range instead of whole, and the bookmark kept as one entry updated in place. Dissatisfaction outranks the rotation, so R8 item 1 is not delayed by a schedule. §2.3 now names prompt drift among the sources it marks, so an empty reading has to be stated as "none" rather than omitted. ⛔ §1.7 forbids quoting a block classified as instance configuration anywhere public — that field is where a host keeps paths, accounts and credentials — and forbids editing `tasks.yml`, whose dumper keeps no comments. Closes #2342 --- emrg/server/evolution_prompt.md | 67 ++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 22bcaabc..d57e7c34 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -122,11 +122,38 @@ Read what others landed, why, and whether it needs follow-up. Use `FETCH_HEAD`, #### 1.5 Memory and conversations across projects +**Coverage is not negotiable; depth is.** Every cycle asks about **every** project whether anything changed — step 0 reads no content, and it is the step that is never skipped. How far into a project the cycle then goes is decided by evidence. Nothing here shrinks §1: every step of §1–§4 is still executed, and R10's rule — a cycle saves rounds by asking for several things at once, never by asking for less — is not relaxed by a word of it. + ```bash cat ~/.emrg/projects.yml +# step 0, per project: what under its memory is newer than its bookmark time? +find /.emrg -newermt '' \ + \( -path '*/memory/*.md' -o -name 'MEMORY.md' \) 2>/dev/null | head +``` + +**0. Find what changed.** For each project, compare the newest modification time under `.emrg/memory/` and `.emrg/sessions/*/memory/` against the time recorded for that project in the bookmark below. A project with nothing newer is skipped — the normal outcome, not a failure. A project with no bookmark line has never been visited, and is read. + +**1. Read indices, not bodies.** For a project that changed, read its `MEMORY.md`. An index line is a claim about a detail file; the detail file is not opened on the strength of the line alone. The evolution session's own `MEMORY.md` is already embedded in this prompt (R9) — never read it again as a "project index". + +**2. Open a body only when its index line gives one of these reasons.** The list is what makes this step decidable, and what gives the reading an output: + +- **the host disagreeing with work already done, in their own words** (「不对」 / 「算了」 / "that is not what I asked for", or a requirement restated because the last attempt missed it) — R8 item 1, and **exempt from the rotation below**; +- **a task that has invented a rule its own prompt should have carried** — §1.7 is the sharpest case; a memory line reading "the prompt did not say X" is the same signal from another file; +- **one workaround appearing in two unrelated projects** — a mechanism is missing, not a local mistake; +- **an index far larger than its content justifies** — state is being kept in memory because the prompt does not hold it. Found by counting, without reading a word: `awk 'length($0)>512' MEMORY.md | wc -l` against R9's per-line limit. + +**3. Read conversation by location, never whole.** A session history runs to tens of megabytes. Locate the passage, then read the range: + +```bash +grep -n "" /history_*.jsonl | head +sed -n ',p' /history_YYYYMMDD.jsonl ``` -For each project read its `.emrg/memory/` and `.emrg/sessions/`: does its memory carry feedback about EMRG itself, does a session show dissatisfaction ("wrong", "different approach", "forget it"), and are two different projects hitting the same problem? +**Rotate.** One or two projects per cycle, in this order: changed since the bookmark; related to this cycle's priority (R8); least recently visited. Every project is reached within a week, and no cycle pays to read all of them. **Dissatisfaction outranks the rotation**: a project whose memory or history shows the host disagreeing with work already done is read in the cycle that finds it. + +**Other projects' memory is read-only for this cycle.** It is their state, written by their own sessions; the cycle reads it to find work on EMRG itself, never to correct, reorganize or add to it. + +**Keep the bookmark — as one entry.** One memory entry at project scope, one line per project: the newest modification time read, and the cycle id that read it. **Update it in place every cycle; never append a second entry**, or the bookmark becomes the growing file this section exists to avoid. A project with no line yet has never been visited, which is what makes the first rotation reach every project. Without the bookmark the rotation has no clock, and step 0 cannot answer "what changed". #### 1.6 Comparable tools @@ -134,6 +161,42 @@ For each project read its `.emrg/memory/` and `.emrg/sessions/`: does its memory > When `gh` is unauthenticated or the network is restricted this source may be skipped — but this cycle's own records, the community queue and master are read every cycle. +#### 1.7 Task configuration and prompt drift + +A task's `extra_prompt` in `~/.emrg/tasks.yml` is this installation's supplement to that task's own prompt. Read it as **evidence about the prompt**, not as configuration to preserve: a general method written there is a gap in the task's prompt template, filed by the installation because the installation had no other channel. + +```bash +python3 - <<'PY' +import yaml, os +d = yaml.safe_load(open(os.path.expanduser("~/.emrg/tasks.yml"), encoding="utf-8")) +for t in d: + ep = (t.get("config") or {}).get("extra_prompt") or "" + print(f"{t.get('name')}: {len(ep)} chars") +PY +``` + +`extra_prompt` lives under a task record's `config:` key — a block written at the record's top level is never rendered, because the reader and the writer once disagreed about the field's position (issue #2110). + +**Classify the blocks of one task's `extra_prompt`**, rotating tasks the way §1.5 rotates projects: a full pass over every non-empty block every cycle pays to re-read text that has not changed. + +| Class | What it looks like | What happens | +|---|---|---| +| **Instance configuration** | a path, a port, a local account, a piece of private data, an authorization boundary that differs per host | it belongs where it is — leave it | +| **Already in the template** | the same rule, stated twice, in one version or two | the template governs; the duplicate is the installation's to remove | +| **Missing from the template** | a general method with no counterpart in the task's prompt template | the template has a gap — this is the cycle's work | + +⛔ **A block classified as instance configuration is never quoted** — not into a PR body, not into a cycle record, not into a rant. This field is exactly where an installation keeps paths, account names and credentials, and a public PR is the last place they may reappear. The PR quotes the general rule it adds, and refers to a private block by its heading only. + +**For the third class, write the rule into the template itself**, as a PR under the normal review path: + +- Write it **into the section that already owns that subject** — a rule is not appended at the end of the file, and it does not appear twice. +- **Rewrite it in the template's own voice and structure.** Do not paste the `extra_prompt` text, and do not carry its history across: no dates, no "this used to be true", no migration note, no numbering that points at another document. +- Where the `extra_prompt` and the template **contradict** each other, decide which is right from the source (the platform's own rules, the project's own state), state the decision in the PR, and make the other side agree. A contradiction left standing means every round reads two instructions and picks one. +- ⛔ **Never edit `tasks.yml`.** It is the host's file, and it is rewritten through `_TaskTableDumper` (`emrg/server/scheduler.py`), a `SafeDumper` subclass that preserves `|` blocks for readability and **no comments** — a YAML serialization has nowhere to keep them. The cycle's output is the template change, plus a report to the host naming the blocks that are now redundant and safe to delete. +- **Order matters: template first, deletion afterwards.** Removing an `extra_prompt` block before its rule exists in the template loses the rule in between, and nothing records that it was lost. + +Two ways this source goes quiet, and both are legitimate: an installation whose blocks are all instance configuration (nothing to do), and a task whose template genuinely delegates that subject to the installation. + --- ### 2. Think @@ -159,7 +222,7 @@ For each project read its `.emrg/memory/` and `.emrg/sessions/`: does its memory #### 2.3 Decide this cycle's work -The priority order is R8. State the decision with the readings that produced it, naming each source and marking the empty ones "none": open PRs and their vote counts, open issues, rants, own-PR feedback, the last Build Release runs, new master commits, and any obvious code defect. +The priority order is R8. State the decision with the readings that produced it, naming each source and marking the empty ones "none": open PRs and their vote counts, open issues, rants, own-PR feedback, the last Build Release runs, new master commits, any obvious code defect, and the §1.7 prompt-drift reading of the task this cycle rotated to — how many `extra_prompt` blocks turned out to be general method, and which template sections they point at. "None" is a legitimate answer there and must be said, not omitted. **Open PRs awaiting review are not "nothing to evolve": reviewing and approving is the work.** Only when every source is genuinely empty — no open PR, no open issue, no rant, no new master commit — is the conclusion "nothing to evolve". From 5c8389e127b77a1c7b91eb8183b0d6f9f5148892 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 17 Oct 2026 16:36:35 +0800 Subject: [PATCH 2/7] emrg: the index-too-big count comes from the guard, not from a byte-measuring awk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §1.5 reason "an index far larger than its content justifies" printed `awk 'length($0)>512' MEMORY.md | wc -l` against R9's per-line bound. That command measures **bytes**, so on a host whose awk does — macOS awk does — every CJK row longer than ~170 characters is reported as over a bound measured in characters. Measured on this host over the eight project indexes: the awk form reports 66, 33 and 3 over-long rows for `emrg`, `competition` and `pku-paper-3d`, and the true count is **0** in all three (their longest rows are 512, 511 and 418 characters). So the command hands a cycle 102 reasons to open a body on three projects that have nothing to open — the exact inversion of what the funnel exists to do, and the same trap this project recorded when a 512-character index bound was read as 65 over-long lines. The reading now goes through the producer R9 already owns: `scripts/check-memory-index.py `, which prints lines, characters and the longest row, and names the subject it read. Verified in both directions — it reports "over 512: 0, longest 511 chars" for the competition index (where awk said 33), and "over 512: 1, row at line 2 is 608 chars" for a synthetic index carrying one 600-character row. The prohibition is stated beside it, because a later reader reaching for awk is reaching for the same wrong number. --- emrg/server/evolution_prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index d57e7c34..14c32823 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -140,7 +140,7 @@ find /.emrg -newermt '' \ - **the host disagreeing with work already done, in their own words** (「不对」 / 「算了」 / "that is not what I asked for", or a requirement restated because the last attempt missed it) — R8 item 1, and **exempt from the rotation below**; - **a task that has invented a rule its own prompt should have carried** — §1.7 is the sharpest case; a memory line reading "the prompt did not say X" is the same signal from another file; - **one workaround appearing in two unrelated projects** — a mechanism is missing, not a local mistake; -- **an index far larger than its content justifies** — state is being kept in memory because the prompt does not hold it. Found by counting, without reading a word: `awk 'length($0)>512' MEMORY.md | wc -l` against R9's per-line limit. +- **an index far larger than its content justifies** — state is being kept in memory because the prompt does not hold it. Found by counting, without reading a word, through the same producer R9's compaction uses: `uv run --no-sync python3 scripts/check-memory-index.py /.emrg/memory/MEMORY.md` prints the three numbers R9 sets — lines, characters, and the longest row — and names the subject it read. Do not count rows with `awk 'length($0)>512'`: an `awk` that measures bytes rather than characters reports a CJK index as far over a bound it is nowhere near, and the number it prints is the one this branch exists to avoid acting on. **3. Read conversation by location, never whole.** A session history runs to tens of megabytes. Locate the passage, then read the range: From 521d6ab5c821c19b97d2d1ee979f9fbc6ce345d4 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 17 Oct 2026 16:41:39 +0800 Subject: [PATCH 3/7] emrg: the two new template readings are pinned, and a byte-measuring count cannot return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1.5's "is this index far larger than its content justifies" reason now names the producer R9's own compaction uses, `scripts/check-memory-index.py`, in place of an `awk` row count. Prose alone would not hold that: this project has recorded the byte-vs-character trap four times (`8e4b2a17` §7 among them), and the sentence that carried it was itself shipped once. So the rules this branch adds are pinned, in the shape `test_evolution_prompt_issue_rule.py` already uses for the sibling rule: - §1.5 states the funnel — the step that reads no content and is never skipped, "indices, not bodies", the rotation, dissatisfaction exempt from it, conversation read by located range, the bookmark as one entry never appended to, and other projects' memory read-only here. - §1.7 states the drift reading and its two prohibitions: an instance-configuration block is never quoted, and `tasks.yml` is never edited. - §2.3 names the prompt-drift reading among the sources it must mark, so an empty answer has to be said rather than omitted. - No line in §1.5 may mention `awk` outside a prohibition, with the predicate itself driven in both directions so the pin cannot pass by accepting everything or nothing. Each pin is driven against the mutation it exists for, and read back: reverting the reading to awk fails the presence pin, adding an awk instruction beside the guard fails the new pin by name, and deleting §1.7 fails both of its pins rather than passing vacuously — `_section` answers "" for a missing heading, so every caller asserts the section first. --- tests/test_evolution_prompt_prompt_drift.py | 203 ++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tests/test_evolution_prompt_prompt_drift.py diff --git a/tests/test_evolution_prompt_prompt_drift.py b/tests/test_evolution_prompt_prompt_drift.py new file mode 100644 index 00000000..5e364148 --- /dev/null +++ b/tests/test_evolution_prompt_prompt_drift.py @@ -0,0 +1,203 @@ +"""The shipped evolution template must read `extra_prompt`, and must not pay for §1.5 unbounded. + +Why this file exists +-------------------- +Host rant 2026-10-17T11:18:41.769853+08:00 (`submit_rant`, project `emrg`), taken on as +issue #2342. Two gaps, both in the one place a cycle never looked: + +* `~/.emrg/tasks.yml` appeared in the template only as the `extra_prompt` injection heading. + That field is where an installation writes what it believes its own task prompt does not + say — a defect report about the prompt, filed by the session least able to act on it. + Measured on the host that filed the rant: two of four tasks carry a non-empty + `config.extra_prompt` (4,325 and 6,990 chars), mixing instance facts with general method. +* §1.5 told the cycle to read every project's `.emrg/memory/` and `.emrg/sessions/`, with + nothing saying what counts as a finding, so the next cycle read it all again. + +Both are prompt items, so both are pinned here rather than left as prose a later edit can +drop in silence — the same shape as `tests/test_evolution_prompt_issue_rule.py`, which pins +the sibling rule the host gave on 2026-09-26. + +The second pin is the one that had to be measured +------------------------------------------------- +The new §1.5 answers "is this index far larger than its content justifies?" by counting. +Written first as `awk 'length($0)>512' MEMORY.md | wc -l`, that reading is **wrong on this +project's own host**: macOS `awk` measures *bytes*, while R9's 512 bound is *characters*, so +every CJK row past ~170 characters is reported as over. Measured over the eight project +indexes here, the awk form reports 66, 33 and 3 over-long rows for `emrg`, `competition` and +`pku-paper-3d`, and the true count is **0** in all three (longest rows 512, 511 and 418 +characters). A cycle obeying that sentence would open 102 bodies on three projects that have +none to open — the funnel inverted. The reading now names `scripts/check-memory-index.py`, +the producer R9's own compaction uses, and the prohibition is pinned beside it because this +trap has already been recorded four times in this project's memory (`8e4b2a17` §7 among them). + +Named limits +------------ +* This pins the *presence* of each rule, not obedience to it: no test shows that a cycle + rotated rather than read everything, or that an `extra_prompt` block was classified at all. +* The awk pin reads the template's text. It cannot prove a cycle never types an `awk` of its + own; it proves the template does not *instruct* one. +* It does not check that `scripts/check-memory-index.py` exists — a template sentence is not + the tool. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +TEMPLATE = REPO_ROOT / "emrg" / "server" / "evolution_prompt.md" + +#: The section that must gain the funnel, its neighbours (for the order pin), and the +#: short phrases it must carry. Phrases rather than sentences: a re-wrap of the paragraph +#: must not fail the guard, while a deletion of the rule must. +FUNNEL = "#### 1.5 Memory and conversations across projects" +FUNNEL_AFTER = "#### 1.4 master" +FUNNEL_BEFORE = "#### 1.6 Comparable tools" +FUNNEL_TERMS: tuple[str, ...] = ( + "step 0 reads no content", # the cheap pass that is never skipped + "it is the step that is never skipped", # ... and that it is not optional + "Read indices, not bodies", # depth decided by evidence + "exempt from the rotation", # R8 item 1 is not scheduled + "Read conversation by location, never whole", # a history is not read end to end + "One memory entry at project scope", # the bookmark, as one entry + "never append a second entry", # ... or it becomes the file it avoids + "Other projects' memory is read-only for this cycle", + # The reading that answers "is this index too big", and the one this file exists for: + # the guard, not a byte-measuring count. + "scripts/check-memory-index.py", + "measures bytes rather than characters", +) + +#: The new prompt-drift section, the rule it carries, and the two prohibitions that make it +#: safe to act on: a host keeps paths, accounts and credentials in `extra_prompt`, and the +#: task table is serialized without comments. +DRIFT = "#### 1.7 Task configuration and prompt drift" +DRIFT_AFTER = "#### 1.6 Comparable tools" +DRIFT_BEFORE = "### 2. Think" +DRIFT_TERMS: tuple[str, ...] = ( + "evidence about the prompt", # how the field is read + "Instance configuration", # class 1 + "Already in the template", # class 2 + "Missing from the template", # class 3 — the cycle's work + "A block classified as instance configuration is never quoted", + "Never edit `tasks.yml`", # the host's file + "template first, deletion afterwards", # order, so no rule is lost between +) + +#: The decision section must name the new source, or the reading produces nothing. +DECISION = "#### 2.3 Decide this cycle's work" +DECISION_TERM = "prompt-drift reading" + +#: What a prohibition reads like. Small and explicit, because the pin below asks it of every +#: line that mentions `awk` and both directions of the answer are exercised by +#: `test_the_prohibition_predicate_discriminates`. +_NEGATIONS = ("do not", "never", "not ") + + +def _section(text: str, heading: str) -> str: + """The template's `heading` block, up to the next Markdown heading of its level. + + Split on `^#{3,4} ` rather than a bare `#`, because the template's shell blocks carry + `#` comments — splitting on those would cut a section off at its own example. + + A heading that is absent answers `""`, and every caller checks that before reading a + phrase out of it: an empty section satisfies a negative pin vacuously, which is the + reading this family has shipped before. + """ + start = text.find(heading) + if start < 0: + return "" + rest = text[start + len(heading):] + level = len(heading) - len(heading.lstrip("#")) + marker = "\n" + "#" * level + " " + cut = rest.find(marker) + return rest[:cut] if cut >= 0 else rest + + +def _template() -> str: + return TEMPLATE.read_text(encoding="utf-8") + + +def _is_prohibition(line: str) -> bool: + """Whether `line` reads as an instruction *not* to do the thing it mentions.""" + lowered = line.lower() + return any(marker in lowered for marker in _NEGATIONS) + + +def test_the_funnel_replaces_the_unbounded_read() -> None: + """§1.5 keeps its coverage and states the procedure that makes it affordable.""" + text = _template() + section = _section(text, FUNNEL) + assert section, f"{FUNNEL!r} is missing from the template" + missing = [term for term in FUNNEL_TERMS if term not in section] + assert not missing, f"§1.5 no longer states: {missing}" + + +def test_the_funnel_sits_between_1_4_and_1_6() -> None: + """The section is a reading of §1, in the place a cycle reaches it.""" + text = _template() + at = text.find(FUNNEL) + assert at > 0, f"{FUNNEL!r} is missing from the template" + assert text.find(FUNNEL_AFTER) < at < text.find(FUNNEL_BEFORE), ( + "§1.5 must sit between §1.4 and §1.6" + ) + + +def test_no_line_in_the_funnel_instructs_a_byte_measuring_count() -> None: + """The one wrong reading this section shipped, and every line that could reintroduce it. + + The template may *mention* `awk` only to forbid it. Measured: the instruction form reports + 66/33/3 over-long rows for three project indexes whose true count is 0. + """ + section = _section(_template(), FUNNEL) + assert section, f"{FUNNEL!r} is missing from the template" + offenders = [ + line.strip() + for line in section.splitlines() + if "awk" in line.lower() and not _is_prohibition(line) + ] + assert not offenders, ( + "§1.5 mentions awk outside a prohibition, which reads as an instruction to count rows " + f"in bytes: {offenders}" + ) + + +def test_the_prohibition_predicate_discriminates() -> None: + """Both directions of the pin above, so it cannot pass by matching everything or nothing. + + A guard that accepted every line would never fire; one that rejected every line would + fire on the correct text. Each arm states the answer it must give. + """ + instruction = "Found by counting: `awk 'length($0)>512' MEMORY.md | wc -l`." + prohibition = ( + "Do not count rows with `awk 'length($0)>512'`: an `awk` that measures bytes " + "rather than characters reports a CJK index as far over a bound it is nowhere near." + ) + assert not _is_prohibition(instruction), "an instruction must not read as a prohibition" + assert _is_prohibition(prohibition), "the shipped prohibition must be recognised" + + +def test_the_drift_section_states_the_rule_and_its_two_prohibitions() -> None: + """§1.7 classifies a block and says what may never be done with one.""" + text = _template() + section = _section(text, DRIFT) + assert section, f"{DRIFT!r} is missing from the template" + missing = [term for term in DRIFT_TERMS if term not in section] + assert not missing, f"§1.7 no longer states: {missing}" + + +def test_the_drift_section_sits_between_1_6_and_think() -> None: + """A reading of §1 belongs before §2 decides, not appended after the loop.""" + text = _template() + at = text.find(DRIFT) + assert at > 0, f"{DRIFT!r} is missing from the template" + assert text.find(DRIFT_AFTER) < at < text.find(DRIFT_BEFORE), ( + "§1.7 must sit between §1.6 and §2" + ) + + +def test_the_decision_names_the_new_source() -> None: + """An unread source is not a stated "none" — §2.3 has to be able to mark it empty.""" + section = _section(_template(), DECISION) + assert section, f"{DECISION!r} is missing from the template" + assert DECISION_TERM in section, "§2.3 must name the prompt-drift reading among its sources" From b50cfbd286762637e5afdc822c82ab673027ff8c Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 17 Oct 2026 19:02:00 +0800 Subject: [PATCH 4/7] emrg: the step-0 line parses and finds, and extra_prompt is precedence not exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The veto on #2343 (cycle cyc20261017-184427) measured two defects in the text this branch added, and both were re-measured here before being changed: - the step-0 placeholder read ``, whose apostrophe closes the single quote around it: bash -n on the block exits 2. It is now one token, ``. Checking that it *answers* turned up a worse trap in the same line: on this host `-newermt '2026-10-17T16:00:00'` matched 0 files where `-newermt '2026-10-17 16:00:00'` matched 9 over one project's memory, so the ISO T form parses and then reports nothing changed for every project for ever. The format is now stated, with that measurement beside it. - §1.7 said a top-level extra_prompt is never rendered. Running `_template_task_context` answers the top-level block when `config:` carries none, and answers config plus a warning when both are present, so the sentence would have made a cycle skip a block that is in the prompt. It now states the precedence rule, which keeps #2110's hazard visible. The §1.7 python block also carries the runner the rest of the template uses. --- emrg/server/evolution_prompt.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 14c32823..7ec72a4a 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -127,10 +127,12 @@ Read what others landed, why, and whether it needs follow-up. Use `FETCH_HEAD`, ```bash cat ~/.emrg/projects.yml # step 0, per project: what under its memory is newer than its bookmark time? -find /.emrg -newermt '' \ +find /.emrg -newermt '' \ \( -path '*/memory/*.md' -o -name 'MEMORY.md' \) 2>/dev/null | head ``` +`` is **one token**, and it is written `%Y-%m-%d %H:%M:%S` — a **space between the date and the time, not `T`**. Both halves are measured readings, not style. A placeholder carrying an apostrophe closes the single quote it sits inside, and the block then stops parsing at the line it is meant to open (`bash -n` on that form exits 2, before anyone learns which project failed). The ISO `T` form is worse than a parse error because it *parses*: measured on this host over one project's memory, `-newermt '2026-10-17T16:00:00'` answered **0** files where `-newermt '2026-10-17 16:00:00'` answered **9** — a step 0 that reports nothing changed, for every project, for ever. So if this line answers zero for a project you know changed, suspect the value before the project. + **0. Find what changed.** For each project, compare the newest modification time under `.emrg/memory/` and `.emrg/sessions/*/memory/` against the time recorded for that project in the bookmark below. A project with nothing newer is skipped — the normal outcome, not a failure. A project with no bookmark line has never been visited, and is read. **1. Read indices, not bodies.** For a project that changed, read its `MEMORY.md`. An index line is a claim about a detail file; the detail file is not opened on the strength of the line alone. The evolution session's own `MEMORY.md` is already embedded in this prompt (R9) — never read it again as a "project index". @@ -166,7 +168,7 @@ sed -n ',p' /history_YYYYMMDD.jsonl A task's `extra_prompt` in `~/.emrg/tasks.yml` is this installation's supplement to that task's own prompt. Read it as **evidence about the prompt**, not as configuration to preserve: a general method written there is a gap in the task's prompt template, filed by the installation because the installation had no other channel. ```bash -python3 - <<'PY' +uv run --no-sync python3 - <<'PY' import yaml, os d = yaml.safe_load(open(os.path.expanduser("~/.emrg/tasks.yml"), encoding="utf-8")) for t in d: @@ -175,7 +177,7 @@ for t in d: PY ``` -`extra_prompt` lives under a task record's `config:` key — a block written at the record's top level is never rendered, because the reader and the writer once disagreed about the field's position (issue #2110). +`config.extra_prompt` is canonical, and the rule is **precedence, not exclusion**. A block under `config:` is what renders. A block written at the record's **top level** renders too, when `config:` carries none. A record carrying both loses the top-level copy, and `_template_task_context` names that copy in a warning rather than dropping it in silence — measured on this checkout: config-only answers the config block, top-level-only answers the top-level block, both answer the config block **and** a warning. So a block is never invisible for being at the top level; what is lost is the second copy, never the only one. Issue #2110 is why the position matters at all — the reader and the writer once disagreed about where the field lives. **Classify the blocks of one task's `extra_prompt`**, rotating tasks the way §1.5 rotates projects: a full pass over every non-empty block every cycle pays to re-read text that has not changed. From 76cf63adafb9626d96f6d4f639ceebc7ab116409 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 17 Oct 2026 19:02:00 +0800 Subject: [PATCH 5/7] emrg: the template's shell blocks are parsed and one of them is run, so a quoted placeholder cannot ship again Every fenced bash block is a command a cycle pastes, and nothing read them: the step-0 block shipped a syntactically broken line under two green CI legs. Three arms, each driven against the mutation it exists for - revert the placeholder to the quoted form (fails, one test), revert the precedence sentence to 'never rendered' (fails, one test), and make the step-0 predicate match nothing (the behavioural arm fails while every other arm stays green). --- tests/test_evolution_prompt_prompt_drift.py | 144 ++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/tests/test_evolution_prompt_prompt_drift.py b/tests/test_evolution_prompt_prompt_drift.py index 5e364148..dda96f1c 100644 --- a/tests/test_evolution_prompt_prompt_drift.py +++ b/tests/test_evolution_prompt_prompt_drift.py @@ -42,8 +42,15 @@ from __future__ import annotations +import re +import shutil +import subprocess +import time +import tempfile from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parent.parent TEMPLATE = REPO_ROOT / "emrg" / "server" / "evolution_prompt.md" @@ -66,6 +73,10 @@ # the guard, not a byte-measuring count. "scripts/check-memory-index.py", "measures bytes rather than characters", + # The veto on 2026-10-17 measured this one: the placeholder as first written carried an + # apostrophe, so the block a cycle pastes did not parse. + "one token", + "space between the date and the time, not `T`", # measured: the T form answers 0 ) #: The new prompt-drift section, the rule it carries, and the two prohibitions that make it @@ -82,6 +93,12 @@ "A block classified as instance configuration is never quoted", "Never edit `tasks.yml`", # the host's file "template first, deletion afterwards", # order, so no rule is lost between + # The veto on 2026-10-17 ran `_template_task_context`: a top-level block *does* render + # when `config:` carries none, so "never rendered" would have made a cycle skip a block + # that is in the prompt. + "precedence, not exclusion", + "when `config:` carries none", + "names that copy in a warning", ) #: The decision section must name the new source, or the reading produces nothing. @@ -201,3 +218,130 @@ def test_the_decision_names_the_new_source() -> None: section = _section(_template(), DECISION) assert section, f"{DECISION!r} is missing from the template" assert DECISION_TERM in section, "§2.3 must name the prompt-drift reading among its sources" + + +#: Every fenced shell block in the template is a command a cycle is expected to paste. +_BASH_BLOCK = re.compile(r"```bash\n(.*?)```", re.S) + +#: A placeholder is one angle-bracketed token. The pin below is that it never carries a +#: quote character: substituted inside the quotes a block already has, one apostrophe ends +#: the quoting and the rest of the block becomes a syntax error. +_PLACEHOLDER = re.compile(r"<[^<>\n]*>") + + +def _bash_blocks() -> list[str]: + blocks = _BASH_BLOCK.findall(_template()) + assert blocks, "the template carries no ```bash block, so this reading has no subject" + return blocks + + +def test_no_placeholder_carries_a_quote() -> None: + """Measured 2026-10-17: the step-0 block shipped a placeholder reading ``. The apostrophe closed the single quote around it, and `bash -n` on the + block exited 2 — so the one line §1.5 calls "the step that is never skipped" was the one + line that could not run as printed. A whole value is substituted for a placeholder, never + a fragment of one, so the placeholder itself needs no quoting characters. + """ + offenders = [ + (index, found) + for index, block in enumerate(_bash_blocks(), 1) + for found in _PLACEHOLDER.findall(block) + if "'" in found or '"' in found + ] + assert not offenders, ( + "a placeholder carries a quote character, which breaks the quoting of the block it " + f"sits in: {offenders}" + ) + + +def test_the_placeholder_rule_discriminates() -> None: + """Both directions: the quoted form must be reported, the fixed form must not be.""" + broken = "find /.emrg -newermt '' -print" + fixed = "find /.emrg -newermt '' -print" + assert [m for m in _PLACEHOLDER.findall(broken) if "'" in m], ( + "the shipped defect must be recognised as a quoted placeholder" + ) + assert not [m for m in _PLACEHOLDER.findall(fixed) if "'" in m], ( + "the corrected placeholder must not be reported" + ) + + +def test_every_shell_block_parses() -> None: + """`bash -n` over every block, placeholders neutralised. + + Neutralising is what makes the reading about the block rather than about the + placeholder's text: `` and `` are not shell syntax, and neither is a value a + cycle would substitute. The named limit is that a neutralised placeholder no longer + tests its quoting — that is the pin above, on the text as shipped. + """ + if shutil.which("bash") is None: # pragma: no cover - a POSIX-less host + pytest.skip("no bash on this host, so the blocks' syntax cannot be parsed here") + failures = [] + for index, block in enumerate(_bash_blocks(), 1): + source = _PLACEHOLDER.sub("X", block) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "block.sh" + path.write_text(source, encoding="utf-8") + done = subprocess.run( + ["bash", "-n", str(path)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if done.returncode != 0: + first = (done.stderr.strip().splitlines() or ["(no stderr)"])[0] + failures.append((index, block.strip().splitlines()[0][:70], first)) + assert not failures, f"the template prints a block that does not parse: {failures}" + + + +def test_the_step_zero_line_finds_a_file_that_changed(tmp_path: Path) -> None: + """The block as shipped, substituted and run, on a tree whose answer is known. + + A block can parse and still answer a constant, which is the reading neither pin above + gives. This arm substitutes a value of the shape the template prescribes and asserts the + file it should find is in the output, so a predicate that has stopped discriminating is + caught even though `bash -n` would still pass. + + Named limits, both of them the reason this is not the whole guard: + * the **format** is pinned by the text term in `FUNNEL_TERMS`, not here - this arm always + supplies a space-separated value, so it cannot fail because a cycle was told to use + the `T` form. What that costs was measured 2026-10-17 on this host: over one project's + memory, `-newermt '2026-10-17T16:00:00'` answered 0 files where the space form answered + 9, a step 0 that reports nothing changed for every project for ever. + * it proves the shipped line answers on the host that runs it. A BSD `find` mis-parses + the `T` form and a GNU one may not, so no single arm fixes the format everywhere. + """ + if shutil.which("bash") is None: # pragma: no cover - a POSIX-less host + pytest.skip("no bash on this host, so the block cannot be run here") + + block = next((b for b in _bash_blocks() if "-newermt" in b), "") + assert block, "the template no longer carries a -newermt block for step 0" + + memory = tmp_path / "proj" / ".emrg" / "memory" + memory.mkdir(parents=True) + saved = memory / "MEMORY.md" + saved.write_text("# an index\n", encoding="utf-8") + + bookmark = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 3600)) + # The block's first line cats the host's project list; that is a reading of this host, + # not part of the question this arm asks, so it stays out of the script. + script = "\n".join( + line for line in block.splitlines() if not line.strip().startswith("cat ") + ) + script = script.replace("", str(tmp_path / "proj")).replace( + "", bookmark + ) + + done = subprocess.run( + ["bash", "-c", script], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + assert str(saved) in done.stdout, ( + "the shipped step-0 line did not find a file modified an hour after the bookmark " + f"time; stdout={done.stdout!r} stderr={done.stderr!r}" + ) From 43724e00819f262846fe4a02f788aa369715b944 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 17 Oct 2026 19:07:22 +0800 Subject: [PATCH 6/7] emrg: the install tree's leftovers are named, and the premise they were skipped on is corrected The tool skipped every install-tree file this checkout no longer tracks and stated why: its absence is a version difference rather than an edit, 'and it is removed by the upgrade either way'. The second half is false on this host. Measured 2026-10-17: the tree reports 0.3.16 and still carries emrg/gui/renderer/js/ - 11 files deleted from the source on 2026-08-27 and absent from that tag's own tree - so an upgrade copies a release in and removes nothing. What that silence cost, the same day: the host filed three rants auditing that dead code, with line numbers, as if it were live. The behaviour two of them ask for is already in the React renderer; the third names two real gaps that are their own issues. So the leftovers are printed with a count, up to ten paths, and a remedy, and they stay out of the exit code - a leftover is not content no commit holds, and a verdict that fires on packaging output (py.typed is the measured one) stops being read. Interpreter caches are counted and not listed, so the section does not edit what it found. Closes #2346 --- scripts/check-install-drift.py | 46 +++++++++++++++- tests/test_check_install_drift.py | 91 +++++++++++++++++++++++++++---- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/scripts/check-install-drift.py b/scripts/check-install-drift.py index 0e199534..b2d77349 100644 --- a/scripts/check-install-drift.py +++ b/scripts/check-install-drift.py @@ -97,8 +97,14 @@ Named limits ------------ * Only files the checkout **tracks** are checked: a file the repository has moved or - deleted since the install was built is skipped (and counted), because its absence is a - version difference rather than an edit, and it is removed by the upgrade either way. + deleted since the install was built is not drift by content, because its absence is a + version difference rather than an edit. It is **not** removed by the upgrade, though, and + this docstring said it was until the claim was measured: on 2026-10-17 a tree reporting + `0.3.16` still carried `emrg/gui/renderer/js/` - 11 files deleted from the source on + 2026-08-27 and absent from that tag's own tree. So they are **named as leftovers**, with a + count and their paths, and they stay out of the exit code: the verdict is about content no + commit holds, and a leftover is a different fact - a stale tree that a reader (a cycle, an + audit, a session) can still be answered from. * The membership test is over objects reachable from the refs a release is built from, so content that was committed and then orphaned - a deleted branch, a rewritten commit - reads as an edit. That is a false positive in the strict sense and the safe direction: @@ -421,14 +427,48 @@ def main(argv: list[str] | None = None) -> int: ) return 2 + caches = [path for path in skipped if "__pycache__/" in path] + leftover = [path for path in skipped if "__pycache__/" not in path] + if leftover: + # The limit this tool used to state as a rationale ("its absence is a version + # difference rather than an edit, and it is removed by the upgrade either way") was + # measured false on 2026-10-17: a tree reporting `0.3.16` still carried + # `emrg/gui/renderer/js/` — 11 files deleted from the source on 2026-08-27 and + # absent from that tag's own tree. An upgrade copies a release in and removes + # nothing, so what it leaves behind is exactly the class a reader of this tree keeps + # finding, and silence about it is the defect. + shown = leftover[:10] + print( + f"leftovers: {len(leftover)} file(s) under {install_dir} are paths this checkout " + "no longer tracks. An install/upgrade copies a release in and removes nothing, so " + "a file a later release dropped stays here and anything that reads this tree can " + "still read it." + ) + for relative in shown: + print(f" {relative}") + if len(leftover) > len(shown): + print(f" ... and {len(leftover) - len(shown)} more") + if caches: + print( + f" ({len(caches)} interpreter cache file(s) under __pycache__ are not " + "listed: no reader executes them as source)" + ) + print( + " remedy: reinstall from the release, or delete these, so no reader (a cycle, " + "an audit, a session) is answering a question about code no build ships. An " + "upgrade alone will not do it." + ) + if not drifted: print( f"every file the install tree shares with this checkout holds content the " f"refs a release is built from still reach ({checked} file(s) checked, " - f"{len(skipped)} skipped as untracked here)" + f"{len(leftover)} named as leftovers above, " + f"{len(caches)} interpreter cache file(s) not listed)" ) return 0 + for relative, size, note in drifted: print(f"{relative} ({size} bytes) - {note}") print(f"{len(drifted)} file(s) hold content no shipped commit has; {checked} checked") diff --git a/tests/test_check_install_drift.py b/tests/test_check_install_drift.py index 3c7749a6..f18d494e 100644 --- a/tests/test_check_install_drift.py +++ b/tests/test_check_install_drift.py @@ -300,7 +300,7 @@ def test_a_clean_install_tree_answers_zero(install_dir: Path, checkout: Path) -> """The positive half: a tree holding exactly the released bytes is not drift.""" proc = _run(install_dir, checkout) assert proc.returncode == 0, f"a clean install tree was reported as drift: {proc.stdout}" - assert "2 file(s) checked, 0 skipped" in proc.stdout, ( + assert "2 file(s) checked, 0 named as leftovers" in proc.stdout, ( f"the count of what was checked is what keeps a green verdict from being a " f"reading over nothing: {proc.stdout!r}" ) @@ -349,15 +349,17 @@ def test_the_verdict_flips_on_a_single_added_byte( ) -def test_an_install_file_the_checkout_does_not_track_is_skipped_not_reported( +def test_an_install_file_the_checkout_does_not_track_is_named_as_a_leftover( install_dir: Path, checkout: Path ) -> None: - """A file only the install has is a version difference, not an edit. - - Packaging writes files this repository does not track (`py.typed` is the measured - one), and the repository also moves files between releases; reporting either as an - in-place edit would make the tool noise, which is how a reading stops being read. It - is counted and named as skipped rather than silently dropped. + """A file only the install has is a version difference, not an edit - and it is named. + + Packaging writes files this repository does not track (`py.typed` is the measured one), + and the repository also moves files between releases, so neither may turn the verdict + red: a reading that fires on those stops being read. What they may not be is *silent*, + which is what this test asserted until 2026-10-17: the host's tree carried a whole + renderer directory the source had deleted on 2026-08-27, and three host rants of that + day audited it as live code. """ (install_dir / "packaging_only.txt").write_text("not in the repository\n", encoding="utf-8") @@ -365,10 +367,13 @@ def test_an_install_file_the_checkout_does_not_track_is_skipped_not_reported( assert proc.returncode == 0, ( f"an untracked install file was reported as drift: {proc.stdout!r}" ) - assert "packaging_only.txt" not in proc.stdout.split("could not measure")[0] - assert "1 skipped" in proc.stdout, ( - f"the skipped file is not counted, so a reader cannot tell it was considered: " - f"{proc.stdout!r}" + assert "leftovers: 1 file(s)" in proc.stdout, proc.stdout + assert "packaging_only.txt" in proc.stdout, ( + f"a file only the install has was not named, so a reader cannot tell it was " + f"considered: {proc.stdout!r}" + ) + assert "file(s) hold content no shipped commit has" not in proc.stdout, ( + f"a version difference must not read as an in-place edit: {proc.stdout!r}" ) @@ -652,3 +657,65 @@ def test_the_module_does_not_inherit_the_hosts_git_config(tmp_path: Path) -> Non "stopped agreeing with their assertions:\n" f"{proc.stdout[-4000:]}\n{proc.stderr[-2000:]}" ) + + +def test_a_file_the_checkout_no_longer_tracks_is_named_not_silently_skipped( + install_dir: Path, checkout: Path +) -> None: + """The class the tool used to count and never name. + + Its docstring justified the skip with "it is removed by the upgrade either way", and + that premise is false: measured 2026-10-17, this host's tree reports `0.3.16` and still + carries 11 files from a renderer directory the source deleted on 2026-08-27. Three host + rants of the same day were audits of exactly that dead code, which is what silence at + this line costs. + """ + stale = install_dir / "emrg" / "gui" / "renderer" / "js" / "chat.js" + stale.parent.mkdir(parents=True) + stale.write_text("// a file no build ships\n", encoding="utf-8") + + done = _run(install_dir, checkout) + + assert done.returncode == 0, ( + "a leftover is not drift by content, so it must not turn the verdict red: " + f"{done.stdout}{done.stderr}" + ) + assert "leftovers: 1 file(s)" in done.stdout, done.stdout + assert "emrg/gui/renderer/js/chat.js" in done.stdout, done.stdout + assert "remedy:" in done.stdout, "the reading must name an action a host can take" + + +def test_a_tree_without_leftovers_does_not_print_the_section( + install_dir: Path, checkout: Path +) -> None: + """The other direction: the section appears because of a file, not always.""" + done = _run(install_dir, checkout) + + assert done.returncode == 0, done.stdout + done.stderr + assert "leftovers:" not in done.stdout, done.stdout + assert "0 named as leftovers above" in done.stdout, ( + "the clean line must still state the count, or a zero and a silence read alike: " + f"{done.stdout}" + ) + + +def test_interpreter_caches_are_counted_but_not_listed( + install_dir: Path, checkout: Path +) -> None: + """A `.pyc` under `__pycache__` is not source a reader reasons about. + + Listed instead of dropped: the count says how many were left out of the list, so the + section is not silently editing what it found. + """ + cache = install_dir / "emrg" / "gui" / "renderer" / "js" / "__pycache__" / "chat.pyc" + cache.parent.mkdir(parents=True) + cache.write_bytes(b"\x00compiled\x00") + + done = _run(install_dir, checkout) + + assert done.returncode == 0, done.stdout + done.stderr + assert "leftovers:" not in done.stdout, ( + "an interpreter cache alone must not open the leftover section: " + done.stdout + ) + assert "1 interpreter cache file(s) not listed" in done.stdout, done.stdout + assert "chat.pyc" not in done.stdout, done.stdout From a9deca5823e9f18b14d5ea7893ffbc13ea3344f1 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 17 Oct 2026 19:14:13 +0800 Subject: [PATCH 7/7] emrg: the shell arms ask the shell to run, so the WSL launcher cannot stand in for bash --- tests/test_evolution_prompt_prompt_drift.py | 48 ++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/tests/test_evolution_prompt_prompt_drift.py b/tests/test_evolution_prompt_prompt_drift.py index dda96f1c..5a9a99ce 100644 --- a/tests/test_evolution_prompt_prompt_drift.py +++ b/tests/test_evolution_prompt_prompt_drift.py @@ -45,6 +45,7 @@ import re import shutil import subprocess +import sys import time import tempfile from pathlib import Path @@ -235,6 +236,46 @@ def _bash_blocks() -> list[str]: return blocks +def _a_shell_runs(program: str) -> bool: + """Whether `program` is a shell that can actually run a script on this host. + + `shutil.which("bash") is None` answers a different question, and on the `windows-2025` + leg it answers it **yes**: a bare `bash` resolves to the WSL launcher + `C:\\Windows\\System32\\bash.exe` — `CreateProcess` searches System32 before `PATH` — which + exists, cannot run a script, and answers *"Windows Subsystem for Linux has no installed + distributions."* with exit 1. Both arms below were red there for exactly that reason + (CI run `39027157421`, `test-windows`, 2026-10-17), and it is reproducible on any host by + putting a stub of that launcher first on `PATH`: the `which` gate sees a shell, the arms + then fail on a program that answers nothing. + + So the gate asks the shell to run one command and reads what comes back. + """ + if shutil.which(program) is None: + return False + try: + done = subprocess.run( + [program, "-c", "echo ok"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return False + return done.returncode == 0 and done.stdout.strip() == "ok" + + +#: The gate both shell arms carry. It names the platform as well as probing, because a probe +#: alone says nothing about *which leg* it is about, and the family guard +#: (`tests/test_posix_arms_are_gated.py`) reads a gate off a condition rather than off a skip. +_needs_a_shell = pytest.mark.skipif( + sys.platform == "win32" or not _a_shell_runs("bash"), + reason="bash on the Windows runner is WSL's launcher, not the shell these blocks are " + "written for, and where no shell answers the blocks cannot be parsed or run", +) + + def test_no_placeholder_carries_a_quote() -> None: """Measured 2026-10-17: the step-0 block shipped a placeholder reading ``. The apostrophe closed the single quote around it, and `bash -n` on the @@ -266,6 +307,7 @@ def test_the_placeholder_rule_discriminates() -> None: ) +@_needs_a_shell def test_every_shell_block_parses() -> None: """`bash -n` over every block, placeholders neutralised. @@ -274,8 +316,6 @@ def test_every_shell_block_parses() -> None: cycle would substitute. The named limit is that a neutralised placeholder no longer tests its quoting — that is the pin above, on the text as shipped. """ - if shutil.which("bash") is None: # pragma: no cover - a POSIX-less host - pytest.skip("no bash on this host, so the blocks' syntax cannot be parsed here") failures = [] for index, block in enumerate(_bash_blocks(), 1): source = _PLACEHOLDER.sub("X", block) @@ -296,6 +336,7 @@ def test_every_shell_block_parses() -> None: +@_needs_a_shell def test_the_step_zero_line_finds_a_file_that_changed(tmp_path: Path) -> None: """The block as shipped, substituted and run, on a tree whose answer is known. @@ -313,9 +354,6 @@ def test_the_step_zero_line_finds_a_file_that_changed(tmp_path: Path) -> None: * it proves the shipped line answers on the host that runs it. A BSD `find` mis-parses the `T` form and a GNU one may not, so no single arm fixes the format everywhere. """ - if shutil.which("bash") is None: # pragma: no cover - a POSIX-less host - pytest.skip("no bash on this host, so the block cannot be run here") - block = next((b for b in _bash_blocks() if "-newermt" in b), "") assert block, "the template no longer carries a -newermt block for step 0"