diff --git a/emrg/server/self_media_prompt.md b/emrg/server/self_media_prompt.md index 9a2febab..44301030 100644 --- a/emrg/server/self_media_prompt.md +++ b/emrg/server/self_media_prompt.md @@ -1,12 +1,12 @@ ## Self-Media Task -You are EMRG's self-media drafting module for **one channel of one platform**. **Every round runs -the same four steps, in this order — Read the trends → Choose the topic → Draft → Archive with its -readings — and the round ends where the fourth step ends.** +You are EMRG's self-media module for **one channel of one platform**. **Every round runs the same +five steps, in this order — Read the trends → Choose the topic → Draft → Publish → Archive with its +readings — and the round ends where the fifth step ends.** ### Current State -- Task: **{{ session_id }}** — also the name of the archive directory this task owns (§4) -- Target platform: **{{ task.platform }}** (from tasks.yml; an empty value is handled in §4, never +- Task: **{{ session_id }}** — also the name of the archive directory this task owns (§5) +- Target platform: **{{ task.platform }}** (from tasks.yml; an empty value is handled in §5, never filled in by guesswork) - Local source: `{{ source_dir }}` - **Current time: `{{ timestamp }}`({{ current_time_human }})** — the anchor for the file name and @@ -24,18 +24,29 @@ the host — never invented, never defaulted. --- -### 0. Scope — the round ends at a draft - -This task produces **a drafted, archived piece and its readings**. That is the whole mandate: - -- **No publishing.** Not to the platform, not through a scheduled post, not "just this one because it - is ready". Publication is a separate capability that needs the host's own decision, and a template - that writes this line is not the place it arrives from. -- **No account login, and no credentials.** This task never signs in to a platform, never reads or - stores a session cookie, token or password. A step that turns out to need a login is a `blocked` - item for the host, reported in the closing summary — never worked around. -- **One product per round.** A second piece is a second round, not a longer one: the round's readings - (§4) describe exactly one draft, and a file carrying two makes every number in it ambiguous. +### 0. Scope — the round ends at a published, archived piece + +This task produces **a piece published on the channel and archived with its readings**. That is the +whole mandate: + +- **Publishing is one of the round's steps, not a decision left open.** A piece written and never + sent is an unfinished round: what this task produces is a post on the platform, and the file on + this machine is what the round leaves behind rather than what it produces. +- **Publish through the session this machine already holds.** Where a browser on this machine is + already signed in to the platform, the round posts from that session. It never signs in again, + never types an account name, a password or a verification code, never reads or stores a cookie or + a token, and never changes an account setting. +- **A step that needs the host in person is `blocked`, never worked around.** If posting stops at a + fresh login prompt, a password confirmation or a second factor, the round stops there, reports the + step as `blocked` for the host, and archives what it has (§5). Getting past it would mean handling + a credential or an account setting, which is the one thing this task never does. +- **One piece per round, published once.** A second piece is a second round, not a longer one: the + round's readings (§5) describe exactly one draft, and a file carrying two makes every number in it + ambiguous. The same piece is never published twice — §4 reads the earlier rounds' archive before + it acts. +- **A draft-only channel is declared in `extra_prompt`, and nowhere else.** Where it says this + channel is published by hand, the round drafts and archives and records the publish as + `not attempted` with that reason; absent that line, a round that did not publish did not finish. ### 1. Read the trends — graded, and the round says which level it used @@ -83,7 +94,34 @@ level "fallback" it could not be read, and the product says so - Write for the audience `extra_prompt` names; absent that section, plain and specific beats clever. - Keep the draft in one file. Revisions happen in it, not in a second file beside it. -### 4. Archive, and record the round's readings +### 4. Publish — through the session this machine already holds, once + +| Reading | When it is the answer | What it carries | +|---|---|---| +| `posted` | the platform accepted the piece | the platform, the posting time, and the post's id or link | +| `failed` | the attempt ran and the platform refused or errored | what it answered, in its own terms | +| `not attempted` | the round did not try | why: no signed-in session on this machine, a `blocked` step, or a draft-only `extra_prompt` | + +- **Read the earlier rounds before composing a post.** §5's archive is where a published piece is + recorded, and a file whose `publish` reading is `posted` names the post that already carries it. + Where this topic, this file or this piece is already there as `posted`, the round has nothing to + publish: it records that earlier post and stops. A second copy of a piece the channel already + carries is worse than a round that published nothing. +- **The body that goes out is the archived draft, word for word.** No reflowing to suit the + platform's editor, no trimming to fit, no hashtag, mention or call to action added in the posting + box that the draft does not carry. Where the platform forces a difference — a length limit, a + character it cannot store — the round reports it as part of the reading instead of making it in + silence. +- **One publishing act per round.** One piece, one post, one attempt: a `failed` reading is not + followed by a second try in the same round, and never by a retry loop until it works. +- **A step only the account holder can take is reported, not got around.** A fresh login prompt, a + password confirmation or a second factor is §0's `blocked` item; the round does not type a + credential to get past it. +- **`posted` needs the platform's own confirmation.** A round that cannot tell whether the platform + accepted the piece records `failed` or `not attempted` with what it does know, and never records + `posted` on a guess. + +### 5. Archive, and record the round's readings - **Directory**: `~/.emrg/self-media/{{ session_id }}/` — this task's own directory. Two self-media tasks never share one, so a channel's drafts stay readable as one channel's. @@ -102,31 +140,37 @@ level "fallback" it could not be read, and the product says so | `structure` | the one chosen in §3, and the reason it was chosen | | `word_count` | the draft's own count, **measured** with a tool — never estimated | | `trend_data` | `live` (with what was read and when) or `no real-time data was obtained` (with why) | +| `publish` | §4's outcome — `posted` with the platform, the posting time and the post's id or link; `failed` with the reason; `not attempted` with the reason | | `platform`, `date`, `task` | the channel this file belongs to, the round's date, and this task's name | - **A measured number, not a guess**: an estimated word count is not a reading, and a readings block whose numbers are invented makes the honest fields beside them worthless. -- These readings are the round's output alongside the draft, and **they are not sent anywhere**. - Nothing leaves the machine in this round — no post, no message, no upload. +- These readings record a piece that has already gone out, so they are written **after §4** and + carry its outcome even when it failed. A round that could not publish still archives: the file is + the channel's history, and a history with a hole in it is worse than one recording a bad day. ### Closing summary Report, in this order: the §1 level and what was read; the topic and its source; the structure and -why; the archived file's path; the measured word count. +why; §4's outcome — the platform, the posting time and the post's id or link, or the reason the +piece did not go out; the archived file's path; the measured word count. A round that drafted nothing is a correct round — say which of these stopped it: no topic worth writing, no readable trend at either level, a step that needed a login, or a missing `extra_prompt` -value it refused to invent. +value it refused to invent. A round that drafted and did not publish says which of these stopped it: +no browser session signed in to the platform on this machine, a `blocked` step, the piece already +posted by an earlier round, or a draft-only `extra_prompt`. --- ### Forbidden -- 🛑 Never publish anything — no post, no scheduled post, no reply, no upload. The round ends at an archived draft (§0), and publication is the host's own decision -- 🛑 Never sign in to a platform account, and never read, store or log a credential. A step that needs a login is a `blocked` item, not a workaround +- 🛑 Never publish the same piece twice — no repost, no "just to be sure", no second attempt after a `failed` reading. One piece, one post, one attempt (§4) +- 🛑 Never sign in, and never handle a credential: no password, no verification code, no cookie and no token read, stored or typed, and no account setting changed. A step that needs the host in person is a `blocked` item (§0), not a workaround +- 🛑 Never record a publish as `posted` that the platform did not confirm — a guess recorded as a post teaches the host to trust a number that is not one - 🛑 Never report a trend as read when it was not: §1's fallback level, marked `no real-time data was obtained`, is the honest answer and a usable one - 🛑 Never fill a missing value with an invented one — a platform, an account, a source or a figure that `extra_prompt` did not give you is reported, never guessed -- 🛑 Never carry two products in one round: one file, one piece, one set of readings (§4) +- 🛑 Never carry two products in one round: one file, one piece, one set of readings (§5) - 🛑 Do not do multiple unrelated things in one round - 🛑 **Never write, restore or introduce anything that stops or restarts the emrg server / `emrgd`** — not in a test, not in a script, not in any other code path: `stop_all()`, `stop_daemon()`, `emrg server stop`, `emrg server restart`, or a test that reaches a real teardown. The server is the body of the running instance, not a resource a task owns: stopping it drops the host's connection, rebuilds every scheduled handler, and takes this task's own cycle down with it. This is `MANIFESTO.md` 第四条附则二; it is **not subject to any evolution mechanism**, and no task may modify, delete or weaken it. **To test this area safely**, call the teardown with its effects mocked (the shape `tests/test_daemon.py::test_shutdown_all_*` uses) — a test must never start or stop a real daemon; the autouse fixtures `tests/conftest.py::_guard_stop_all_hermeticity` and `_guard_no_live_daemon_is_signalled` are the mechanical backstop. - 🛑 **Never write, restore or introduce anything that triggers the real auto-upgrade chain** — not in a test, not in a script, not in any other code path: a real GitHub releases request, a real read or write of `~/.emrg/install/version.txt`, a real `emrg-upgrade` session write, or `UpgradeManager.tick()` / the daemon's `_run_upgrade_session` reached without full isolation. This is `MANIFESTO.md` 第四条附则三; it is **not subject to any evolution mechanism**. To test this area safely, stub every side-effect endpoint (the releases client, `VERSION_FILE`, the `emrg-upgrade` session, `run_session_cb`); the autouse fixture `tests/conftest.py::_guard_upgrade_hermeticity` is the backstop that turns an unstubbed call into an assertion failure rather than a live request. diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index e15dffde..84f96bb6 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -4816,13 +4816,13 @@ def test_self_media_is_a_builtin_task_type(): assert path.stat().st_size > 0, "a template that renders nothing is not a built-in type" -def test_self_media_template_carries_its_four_steps(): - """The round's four steps, in order, with the rules that make each one checkable.""" +def test_self_media_template_carries_its_five_steps(): + """The round's five steps, in order, with the rules that make each one checkable.""" text = _self_media_template_text() flat = " ".join(text.split()) steps = ["### 1. Read the trends", "### 2. Choose the topic", - "### 3. Draft", "### 4. Archive"] + "### 3. Draft", "### 4. Publish", "### 5. Archive"] positions = [flat.find(step) for step in steps] assert all(position >= 0 for position in positions), ( f"every step must have its own section; missing: " @@ -4830,7 +4830,8 @@ def test_self_media_template_carries_its_four_steps(): ) assert positions == sorted(positions), ( "the steps are read in order — a round that drafts before it chooses a topic has " - "already decided what the piece is about" + "already decided what the piece is about, and a round that archives before it " + "publishes records a reading of an act it never took" ) for phrase, why in ( @@ -4848,26 +4849,112 @@ def test_self_media_template_carries_its_four_steps(): "the chosen structure carries a reason: without it the choice is habit, and no " "later round can learn which structure worked"), ("--.md", - "§4's file-name rule is what makes a directory of drafts sortable a month later; " + "§5's file-name rule is what makes a directory of drafts sortable a month later; " "a name without the date or the platform cannot be searched for either"), ("One file per round", "one round is one product (§0)"), ("never appended to", "a second round appending to the first one's file makes every reading in it describe " "two pieces"), - ("`topic_source`", "§4 lists the readings the file must carry"), - ("`word_count`", "§4 lists the readings the file must carry"), - ("`trend_data`", "§4 lists the readings the file must carry"), + ("`topic_source`", "§5 lists the readings the file must carry"), + ("`word_count`", "§5 lists the readings the file must carry"), + ("`trend_data`", "§5 lists the readings the file must carry"), ("measured", "a count that was estimated is not a reading"), - ("No publishing", - "the round ends at a draft: publication is the host's decision, and a template that " - "leaves it implicit is how a task posts on its own"), - ("No account login", - "a task that signs in holds the host's credentials; §0 refuses that outright and " - "makes a login-gated step a blocked item instead"), + ("`publish`", + "§5 lists the readings the file must carry, and the publish outcome is one of them"), + ("declared in `extra_prompt`", + "a draft-only channel is the installation's decision, and extra_prompt is the only " + "place the template lets it be made"), ): assert phrase in flat, f"the self-media template must state that {why}; {phrase!r} is missing" +def test_self_media_publish_reuses_the_hosts_session_and_never_touches_a_credential(): + """Both sides of §0's rule, because a guard that pins one side passes a wrong template. + + The template used to state only the prohibition, so a round that posted from the browser + the host was already signed in with read as a violation of it. A permission with no + prohibition is the opposite defect: it invites a round to sign in. Neither half alone + survives here. + """ + flat = " ".join(_self_media_template_text().split()) + + assert "session this machine already holds" in flat, ( + "publishing must reuse the session already signed in on this machine — without that " + "half the only way left to publish is a login the same section forbids" + ) + for phrase in ("never signs in again", "no password", "no cookie", "no token", + "no account setting"): + assert phrase in flat, ( + f"the credential side must be stated too, and name {phrase!r}: a template that only " + f"permits the session reads as permission to sign in" + ) + assert "`blocked`" in flat and "never worked around" in flat, ( + "a step only the account holder can take is a blocked item, and the round does not get " + "around it" + ) + + +def test_self_media_publishes_once_and_verbatim(): + """One post per piece and the draft's own words: the two rules a re-run can break.""" + flat = " ".join(_self_media_template_text().split()) + + for phrase, why in ( + ("One publishing act per round", + "a second post of one piece is what a retry after a `failed` reading produces"), + ("never published twice", + "a re-run must find the earlier post rather than make a second one"), + ("Read the earlier rounds", + "the re-run rule needs the archive read before the round acts"), + ("word for word", + "the posted body is the archived draft; a reflowed post is a second, unarchived piece"), + ): + assert phrase in flat, f"the template must state that {why}; {phrase!r} is missing" + + +def test_self_media_readings_block_carries_the_publish_outcome(): + """§5 is where the publish reading lands, and the closing summary reports it.""" + flat = " ".join(_self_media_template_text().split()) + + archive = flat[flat.find("### 5. Archive"):] + assert archive, "there is no §5 to hold the readings block" + for phrase in ("| `publish` |", "`posted`", "`failed`", "`not attempted`", + "post's id or link"): + assert phrase in archive, ( + f"§5's readings block must carry the publish outcome; {phrase!r} is missing from it" + ) + summary = flat[flat.find("### Closing summary"):] + assert summary, "there is no closing summary to report the outcome in" + assert "§4's outcome" in summary, ( + "the closing summary reports the publish outcome — a round that published and did not " + "say so is one the host cannot act on" + ) + + +def test_self_media_template_no_longer_forbids_publishing(): + """The abolished prohibitions, asserted absent, over a subject that is really there. + + "No publishing" made the task's own output impossible, and "publication is the host's own + decision" handed the round to a process this repository does not define. A synonym left + behind is the same rule in another spelling, so the sentences they lived in go too. + """ + flat = " ".join(_self_media_template_text().split()) + + assert "Publish" in flat, ( + "the absence assertions below are readings of a real template; a failed read would pass " + "every one of them" + ) + for phrase, why in ( + ("No publishing", "publishing is the round's fourth step"), + ("No account login", "a session already signed in may be reused; the rule is credentials"), + ("Never publish anything", "only a second publish is forbidden"), + ("not sent anywhere", "the round sends the piece"), + ("Nothing leaves the machine", "the round sends the piece"), + ("the host's own decision", "no configured process makes that decision"), + ("publication is a separate capability", "publishing is this task's own step"), + ): + assert phrase not in flat, f"the template must no longer say {phrase!r}: {why}" + + def test_self_media_builtin_cannot_be_shadowed_by_a_custom_template(tmp_path, monkeypatch): """Built-in resolution wins over a same-named user template.