mercury-agent 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/container/Dockerfile +25 -0
  2. package/container/build.sh +23 -3
  3. package/docs/behavior-layers.md +25 -14
  4. package/docs/configuration.md +139 -11
  5. package/docs/container-lifecycle.md +151 -1
  6. package/docs/context-architecture.md +6 -2
  7. package/docs/extensions.md +9 -1
  8. package/docs/goals/football-reporter-profile/decisions.md +79 -0
  9. package/docs/goals/release-gate/decisions.md +53 -7
  10. package/docs/goals/release-gate/roadmap.md +8 -8
  11. package/docs/live-testing.md +22 -0
  12. package/docs/pending-verification.md +178 -0
  13. package/docs/permissions.md +1 -1
  14. package/docs/profile-guide.md +28 -8
  15. package/examples/extensions/archive/backends/local.ts +6 -3
  16. package/examples/extensions/archive/queue.ts +51 -10
  17. package/examples/extensions/feed-watch/config.ts +42 -2
  18. package/examples/extensions/feed-watch/digest.ts +4 -7
  19. package/examples/extensions/feed-watch/items.ts +18 -7
  20. package/examples/extensions/feed-watch/match.ts +125 -9
  21. package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
  22. package/examples/extensions/feed-watch/watch.ts +39 -5
  23. package/examples/extensions/gws/index.ts +30 -3
  24. package/examples/extensions/longview/hook.ts +72 -7
  25. package/examples/extensions/longview/index.ts +2 -0
  26. package/examples/extensions/morning/index.ts +13 -11
  27. package/examples/extensions/napkin/index.ts +12 -3
  28. package/examples/extensions/napkin/pi-spawn.ts +5 -1
  29. package/examples/extensions/overview/index.ts +20 -0
  30. package/examples/extensions/overview/skill/SKILL.md +9 -1
  31. package/examples/extensions/pinchtab/index.ts +36 -7
  32. package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
  33. package/examples/profiles/_template/AGENTS.md +6 -1
  34. package/examples/profiles/football-reporter/AGENTS.md +16 -4
  35. package/examples/profiles/football-reporter/README.md +1 -1
  36. package/examples/profiles/football-reporter/config.yaml +42 -6
  37. package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
  38. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
  39. package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
  40. package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
  41. package/examples/profiles/football-reporter/standard.json +45 -8
  42. package/package.json +7 -6
  43. package/resources/skills/tasks/SKILL.md +17 -1
  44. package/resources/templates/AGENTS.md +4 -3
  45. package/resources/templates/mercury.example.yaml +13 -4
  46. package/src/adapters/whatsapp-ingress.ts +15 -3
  47. package/src/agent/container-entry.ts +299 -29
  48. package/src/agent/container-env.ts +26 -8
  49. package/src/agent/container-runner.ts +497 -76
  50. package/src/agent/image-contract.ts +77 -0
  51. package/src/agent/image-manifest.ts +180 -0
  52. package/src/agent/image-refresh.ts +318 -0
  53. package/src/cli/mercury.ts +225 -28
  54. package/src/cli/mrctl-http.ts +5 -0
  55. package/src/cli/mrctl.ts +57 -5
  56. package/src/cli/service-unit.ts +109 -0
  57. package/src/config-file.ts +22 -1
  58. package/src/config.ts +105 -14
  59. package/src/core/api.ts +11 -2
  60. package/src/core/commands.ts +11 -4
  61. package/src/core/connection-health.ts +377 -0
  62. package/src/core/direct-send.ts +235 -14
  63. package/src/core/exec.ts +10 -0
  64. package/src/core/history-window.ts +142 -0
  65. package/src/core/model-command.ts +130 -0
  66. package/src/core/operator-alerts.ts +326 -23
  67. package/src/core/permissions.ts +28 -0
  68. package/src/core/profiles.ts +32 -16
  69. package/src/core/reply-context.ts +31 -0
  70. package/src/core/routes/config-builtin.ts +11 -0
  71. package/src/core/routes/console.ts +98 -16
  72. package/src/core/routes/dashboard.ts +93 -10
  73. package/src/core/routes/model.ts +26 -3
  74. package/src/core/routes/send.ts +1 -1
  75. package/src/core/routes/tasks.ts +74 -0
  76. package/src/core/runtime.ts +138 -19
  77. package/src/core/system-messages.ts +33 -0
  78. package/src/core/task-scheduler.ts +155 -7
  79. package/src/extensions/image-builder.ts +1 -1
  80. package/src/extensions/installer.ts +72 -17
  81. package/src/extensions/load-project.ts +74 -0
  82. package/src/extensions/loader.ts +50 -9
  83. package/src/host-version.ts +32 -0
  84. package/src/main.ts +42 -21
  85. package/src/preflight/checks/credential.ts +300 -0
  86. package/src/preflight/checks/docker.ts +149 -0
  87. package/src/preflight/checks/extensions.ts +90 -0
  88. package/src/preflight/checks/host-deps.ts +247 -0
  89. package/src/preflight/checks/image-contract.ts +228 -0
  90. package/src/preflight/checks/roundtrip.ts +424 -0
  91. package/src/preflight/checks/sandbox.ts +159 -0
  92. package/src/preflight/deps.ts +107 -0
  93. package/src/preflight/probe-container.ts +156 -0
  94. package/src/preflight/report.ts +177 -0
  95. package/src/preflight/run.ts +223 -0
  96. package/src/server.ts +55 -14
  97. package/src/storage/db.ts +41 -2
  98. package/src/storage/models-json.ts +110 -0
  99. package/src/text/reporter-lint.ts +167 -23
  100. package/src/types.ts +22 -0
@@ -616,3 +616,82 @@
616
616
  - **Used by:** football-reporter-profile roadmap (M2.2 row),
617
617
  `docs/bugs/model-switch-disables-fallback.md` (the reset-verb constraint),
618
618
  `docs/ideas/scheduled-task-model-leg-policy.md`
619
+
620
+ ## D-023: The club markers are the owner's 2026-09-03 set, a marker may be two emoji, and Maccabi Tel Aviv is standing coverage (decided 2026-09-03)
621
+ - **Category:** editorial standard / process (applies [D-020](#d-020-live-tuning-is-adopted-into-the-repo-not-reverted--and-scans-carry-a-fixed-club-emoji-decided-2026-08-31) a second time)
622
+ - **Decided:** (1) The scan markers are now מנצ'סטר יונייטד 👹, ברצלונה ❤️💙,
623
+ ריאל מדריד 🤍, הפועל תל אביב ❤️, בית"ר ירושלים 💛🖤, מכבי חיפה 💚 and
624
+ מכבי תל אביב 💛💙 — seven clubs, chosen by the owner in the football chat on
625
+ 2026-09-03 between 12:35 and 12:55 IDT (messages #4210–#4223) and written by
626
+ the bot into the live `AGENTS.md` and `MEMORY.md` at 12:55. The repo adopts
627
+ the live text verbatim, as D-020 says; the diff was exactly the two owner
628
+ changes and the file contradicts itself nowhere. (2) A marker is the **whole
629
+ emoji run that opens the line**, compared as one unit and without variation
630
+ selectors. That is what keeps a bare ❤️ (Hapoel) from passing as half of
631
+ ❤️💙 (Barcelona), and what keeps a phone that drops or adds U+FE0F from
632
+ turning a correct post into a finding. (3) Maccabi Tel Aviv joins the
633
+ standing coverage (`## תפקיד`) with a seeded notebook, the same shape as the
634
+ five seeded on 2026-08-21; the live space already had one, opened by napkin
635
+ on 2026-08-31, so `apply` writes nothing there.
636
+ - **Alternatives considered:**
637
+ - *Revert the box with `apply`.* Rejected by D-020: the edit is the owner's,
638
+ dated, and the first post under it went out at 14:04 IDT the same day.
639
+ - *Keep single-emoji markers and ask the owner to pick seven singles.*
640
+ Rejected: the owner chose pairs deliberately ("תן גם רצפים של אמוג'י") and
641
+ the lint is the thing that has to follow the standard, not the reverse.
642
+ - *Compare markers with their selectors.* Rejected: the bot wrote ❤ with
643
+ U+FE0F and 🖤 without in the same line, and nothing guarantees the next
644
+ model or phone does the same. Two fixtures pin the normalisation in both
645
+ directions (`scan-marker-without-selector`, `scan-marker-with-extra-selector`)
646
+ because a mutation that removed it passed every other fixture.
647
+ - *Add Maccabi Tel Aviv as a ninth article research topic.* Not done: the
648
+ article checklist already names מכבי ת"א under item 5 and the standard's
649
+ coverage line is the authority for what the space covers; if the club's
650
+ league news stops reaching the article once its European run is over,
651
+ that is a task-prompt change on its own evidence.
652
+ - **Reasoning:** `space-profile check` reported the drift within the hour and
653
+ an `apply` would have written the 2026-08-31 emoji back over the owner's
654
+ decision — the same shape as the `feed-watch.terms` clobber of 2026-08-31.
655
+ The lint allowlist compared one emoji at a time, so every two-emoji marker
656
+ would have been reported twice (not allowed, and two emoji on one line) the
657
+ moment the fixtures caught up with the standard. The invisible selector is
658
+ the CLAUDE.md carrier trap in a new coat: it is spelled as an escape in
659
+ `standard.json`, the fixtures and the tests, and a consistency test fails if
660
+ the literal character ever appears in the JSON.
661
+ - **Revisit if:** the owner changes a marker again from chat — then the
662
+ repo-side steps are the same four files (`AGENTS.md`, `standard.json`,
663
+ `cases.json`, the seeded `MEMORY.md` list) and this entry's date moves; or
664
+ the lint ever needs a marker of three emoji, which `leadingEmoji` already
665
+ accepts but no fixture pins.
666
+ - **Used by:** football-reporter profile (`AGENTS.md` § תפקיד and § פורמט,
667
+ `standard.json`, `config.yaml` `workspace_seed`), `src/text/reporter-lint.ts`
668
+ (`leadingEmoji`, `emojiKey`), M4.1 regression map (F7c), `MEMORY.md` seed
669
+
670
+ ## D-024: `feed-watch.max_per_hour` is 3, with the 15-minute batch kept (decided 2026-09-03)
671
+ - **Category:** run budget / process (applies [D-020](#d-020-live-tuning-is-adopted-into-the-repo-not-reverted--and-scans-carry-a-fixed-club-emoji-decided-2026-08-31) a third time)
672
+ - **Decided:** The scan cap is **3 runs per hour**, not the 1 set on
673
+ 2026-08-31. The owner set it from the dashboard on 2026-09-03 at 01:19 IDT
674
+ and confirmed it in writing the same afternoon ("I want max_per_hour=3").
675
+ `batch_minutes` stays at 15, so at most four batches an hour can open and
676
+ the cap only bites on a burst. The repo adopts the value; `apply` no longer
677
+ reverts it and the sync's profile check goes clean.
678
+ - **Alternatives considered:**
679
+ - *Keep 1/hour and treat the dashboard write as an experiment.* Rejected: it
680
+ had been live for a day, the owner was asked and chose 3, and D-020 says the
681
+ repo follows the box, not the reverse.
682
+ - *Raise the cap and shrink the batch back to 4 minutes.* Not done: the
683
+ 15-minute batch is what merges several outlets carrying one story into one
684
+ run, and that is the cheaper half of the 2026-08-31 change. The cap and the
685
+ batch are separate levers; only the cap moved.
686
+ - **Reasoning:** the owner's; no reason was given and none is recorded here.
687
+ What the numbers say: the cost law is runs × prefix, the 2026-08-31 cut was
688
+ aimed at the ~70 % of runs answering NO_UPDATE, and the 15-minute batch is
689
+ the half of that change that merges one story's outlets into one run. Spend
690
+ under 3/hour with the 15-minute batch on 2026-09-03 was $4.92 by 14:04 IDT
691
+ for 18 runs (10 posted), in line with the two days before, so the cap was
692
+ not the binding factor that day.
693
+ - **Revisit if:** a day passes 30 runs or the NO_UPDATE share climbs back
694
+ above half — then the batch, not the cap, is the first lever; or the real
695
+ group is linked and the owner wants a quieter feed.
696
+ - **Used by:** football-reporter profile (`config.yaml`), profile README,
697
+ `space-profile check` on every sync
@@ -111,10 +111,42 @@
111
111
  then it is a Mercury feature, planned as one.
112
112
  - **Used by:** smoke-runner, release-gate-rules
113
113
 
114
- ## G-007: Whether Mercury publishes its own agent image — OPEN, owner's call
114
+ ## G-007: Mercury publishes its own agent image; visibility stays private for now
115
115
  - **Category:** architecture / distribution
116
- - **Status:** **Open.** R0.3 is blocked on it; R0.1, R0.2 and R0.5 are not.
117
- - **Question:** Mercury's release workflow publishes the npm package and
116
+ - **Status:** **Decided 2026-09-06/07 by the owner in two parts.** *Publish:*
117
+ yes — `release.yml` builds and pushes the image (R0.3, landed).
118
+ *Visibility:* **private, for now** — the owner's pushback on 2026-09-07
119
+ was right that nothing forces public: both consoles already pull private
120
+ GHCR images with a PAT, so private is proven, and a package inherits the
121
+ repo's private visibility on first push, so private is also the
122
+ zero-action outcome. The earlier "public" wording here was written from
123
+ the first half of the answer before the second was given; it is amended,
124
+ and the record of both branches is kept below.
125
+ - **What this commits to:** `release.yml` pushes
126
+ `ghcr.io/avishai-tsabari/mercury-agent:<version>` and `:latest` after
127
+ npm publish (R0.3). The package is NOT flipped to public. A third-party
128
+ install path therefore still does not exist, and the README must say so
129
+ honestly rather than imply one — that is the follow-up private incurs.
130
+ R0.4 may pin the default tag to `:${pkg.version}` once per-version tags
131
+ are being pushed; a pull token stays a requirement of every box, as it
132
+ is today.
133
+ - **What public would buy, recorded for the revisit:** not disclosure — the
134
+ image is built from a tarball anyone can already `npm pack`, and the moat
135
+ is the profiles, in neither artifact. It buys the deletion of a
136
+ credential class from the owner's own fleet: no PAT base64-embedded in
137
+ cloud-init userdata (readable back from the Hetzner metadata service),
138
+ no plaintext copy in the node `.env` for Watchtower, no root-0600 token
139
+ file kept on every box for its lifetime, no manual per-box rotation
140
+ runbook, and no "PAT expired silently, box looks healthy until first
141
+ use" failure class — each of which the two consoles document about
142
+ themselves. Against that: a support surface outsiders can use and will
143
+ expect to keep working, and the end of "the agent image is a private
144
+ product" as a positioning stance.
145
+ - **Do regardless of visibility:** the PAT in `mercury-cloud-console`'s
146
+ userdata and node `.env` reads every package on the account. A dedicated
147
+ machine user with `read:packages` on this one package shrinks the blast
148
+ radius; the fleet's own 2026-08-13 note already recommends it.
149
+ - **Question (as it stood):** Mercury's release workflow publishes the npm package and
118
150
  nothing else. The agent image every fleet box runs is built and pushed by
119
151
  `agent-image.yml` in **tagula-agents**, from Mercury's published npm
120
152
  tarball. Mercury's own `ghcr.io/avishai-tsabari/mercury-agent` is pushed
@@ -136,17 +168,31 @@
136
168
  - **The two answers, and what each commits to:**
137
169
  - **Public.** One publishing path, third-party installs work, R0.4's
138
170
  version pin becomes safe, `agent-image.yml`'s version-linking half
139
- retires. Commits to publishing a built artifact of a private repo —
140
- the same reason the fleet image is private — so it is an open-core
141
- boundary decision, not a CI one.
171
+ retires. It is still an open-core boundary decision rather than a CI
172
+ one — but the disclosure half of that objection does not survive the
173
+ evidence. **Verified 2026-09-06, unauthenticated:** `npm pack
174
+ mercury-agent@0.18.2` returns a 378-entry tarball carrying all of
175
+ `src/`, `container/Dockerfile` and every source its `COPY` list names.
176
+ The image is built *from that tarball*, so publishing it discloses
177
+ nothing npm already publishes to anyone who asks. The moat is the
178
+ profiles, and they are in neither artifact. What public does commit to
179
+ is a **support surface**: an install path outsiders can use, and will
180
+ therefore expect to keep working.
142
181
  - **Private (or no change).** Nothing outside the fleet can install
143
182
  Mercury. Then say so: `docs/container-lifecycle.md:225` currently
144
183
  claims "Images are published on each release", which is false today
145
184
  either way, and the README's install steps imply an independent
146
185
  install that does not exist.
186
+ - **Reasoning (publish half):** one publishing path beats two, and the
187
+ path belongs with the artifact it publishes. **(visibility half):** the
188
+ cost private carries is one the fleet already pays for its own image, so
189
+ keeping it costs nothing new today; public is reversible with one click
190
+ later, and the moment to decide it with full information is when
191
+ `agent-image.yml` retires — that is when the pull token could actually
192
+ become deletable.
147
193
  - **Not in scope of this decision:** the fleet's idle check, database
148
194
  snapshot, rollback and stagger. Those are topology and stay in
149
195
  tagula-agents whatever is decided here (G-006).
150
- - **Revisit if:** a second operator appears, or Mercury is open-sourced.
196
+ - **Revisit if:** `agent-image.yml` in tagula-agents retires (decide visibility then, with the token's future known); a second operator appears; or Mercury is open-sourced.
151
197
  - **Used by:** mercury-publishes-image (R0.3), image-refresh-and-visibility (R0.4)
152
198
 
@@ -1,7 +1,7 @@
1
1
  # Roadmap: Release Gate
2
2
 
3
3
  **Goal**: [release-gate](goal.md)
4
- **Last updated**: 2026-09-06 (R0 added: the image supply side R1.1(c) reads from, and nobody owned)
4
+ **Last updated**: 2026-09-07 (G-007 amended: publish yes, private for now. R0.1–R0.3 Mercury side landed; lint drift cleared, release path green)
5
5
 
6
6
  > Order is by leverage against the live risk, not by size: R1 protects the
7
7
  > customer box within a session or two and depends on nothing; R2 is the
@@ -32,10 +32,10 @@
32
32
 
33
33
  | ID | Story | Slug | Depends on | Status |
34
34
  |----|-------|------|------------|--------|
35
- | R0.1 | **The image describes itself.** `container/Dockerfile` + `container/build.sh` stamp, at build time, (a) `org.opencontainers.image.version` and a mercury-namespaced version label taken from `package.json`, not hand-typed, and (b) the **`COPY`-source manifest** R1.1(c) checks against — generated from the Dockerfile's own `COPY` list so the two cannot drift. `build.sh` tags `:<version>` as well as `:latest`. Labels are inherited by the derived `mercury-agent-ext-*` image with no builder change (verified on the tagula box 2026-09-05: derived and base both report `0.18.0-beta.0`), so the host-side check works through the image actually executing | image-self-describing | — | backlog — **blocks R1.1(c)** |
36
- | R0.2 | **The build-integrity gate moves into this repo.** `agent-image.yml` in tagula-agents verifies Mercury's artifact — every `COPY` source present in the tarball, `bun build container-entry.ts` resolves inside the image — and that check is what would have caught 0.17.0. It belongs in `release.yml` (or `bun run check`) so every consumer building the image gets it, not only this one fleet. The fleet keeps its copy or drops it; either is fine once this exists | build-integrity-in-mercury | — | backlog |
37
- | R0.3 | **Mercury publishes its own image.** A job in `release.yml`, after `publish-npm`, that installs the published tarball, builds, verifies (R0.2) and pushes `:<version>` + `:latest` with the R0.1 labels. Needs `packages: write`, which no job has today. **Gated on the G-007 decision** (public or private): the answer changes whether this closes the third-party install path or only tidies the internal one. Makes `agent-image.yml`'s *version-linking* half redundant; its nightly updater, idle check, snapshot, rollback and stagger stay where they are | mercury-publishes-image | R0.1, R0.2, G-007 | backlog — **decision first** |
38
- | R0.4 | **A floating tag stops meaning "frozen at first pull".** `ensureImage()` returns the moment `docker image inspect` succeeds, so the default `:latest` is never refreshed and a host upgraded by npm keeps yesterday's container silently — the trap the owner hit on 2026-09-05. Either pin the default to `:${pkg.version}` (safe **only** after R0.3, or every fresh install fails on a tag nobody published) or re-pull a floating tag on a version change. Either way boot logs the resolved image ref, digest and version label so drift is visible without asking | image-refresh-and-visibility | R0.3 | backlog |
35
+ | R0.1 | **The image describes itself.** `container/Dockerfile` + `container/build.sh` stamp, at build time, (a) `org.opencontainers.image.version` and a mercury-namespaced version label taken from `package.json`, not hand-typed, and (b) the **`COPY`-source manifest** R1.1(c) checks against — generated from the Dockerfile's own `COPY` list so the two cannot drift. `build.sh` tags `:<version>` as well as `:latest`. Labels are inherited by the derived `mercury-agent-ext-*` image with no builder change (verified on the tagula box 2026-09-05: derived and base both report `0.18.0-beta.0`), so the host-side check works through the image actually executing | image-self-describing | — | **landed 2026-09-06** — `src/agent/image-manifest.ts` generates the manifest inside the build from the Dockerfile that is building, `container/Dockerfile` stamps `org.opencontainers.image.version` + `com.mercury.version` from an `ARG` and writes `/app/image-manifest.json`, `build.sh` reads the version from package.json and tags `:<version>` + `:latest`. Parser and the host-side `diffImageManifest` are unit-tested; **the build itself is unverified** — four checks in `docs/pending-verification.md` |
36
+ | R0.2 | **The build-integrity gate moves into this repo.** `agent-image.yml` in tagula-agents verifies Mercury's artifact — every `COPY` source present in the tarball, `bun build container-entry.ts` resolves inside the image — and that check is what would have caught 0.17.0. It belongs in `release.yml` (or `bun run check`) so every consumer building the image gets it, not only this one fleet. The fleet keeps its copy or drops it; either is fine once this exists | build-integrity-in-mercury | — | **landed 2026-09-06** — split by what it needs: `scripts/check-image-sources.ts` runs in `bun run check` with no Docker and proves the source contract (every COPY source exists, is inside the npm `files` allowlist, and every local module reachable from a copied `.ts` is itself copied — verified to reproduce the 0.17.0 miss by deleting the `sanitize-text.ts` COPY); `smoke:image` gained the artifact half from `agent-image.yml` — toolchains, the pi binary, the version label, and `bun build` resolving `container-entry.ts` and `mrctl.ts` inside the image |
37
+ | R0.3 | **Mercury publishes its own image.** A job in `release.yml`, after `publish-npm`, that installs the published tarball, builds, verifies (R0.2) and pushes `:<version>` + `:latest` with the R0.1 labels. Needs `packages: write`, which no job has today. **G-007 is decided: publish yes, visibility private for now** (2026-09-07), so this tidies the internal path; the third-party install path stays closed until visibility is revisited. Makes `agent-image.yml`'s *version-linking* half redundant; its nightly updater, idle check, snapshot, rollback and stagger stay where they are. **Move that workflow rather than rewriting it** — it already builds from the published tarball, which is the shape wanted here — and carry three traps across with it, all read out of it on 2026-09-06: (i) it derives the image name from `GITHUB_REPOSITORY`, which under this repo yields a different path than the fleet's, so set the name explicitly instead of inheriting it; (ii) a GHCR package takes the repo's visibility on first push, so a *public* answer to G-007 needs the package flipped by hand after the first run — it cannot be expressed in the YAML; (iii) the job runs **after** `publish-npm`, so a failed image push leaves npm ahead of the image, which is the 0.17.0 ordering inverted — keep boxes following the image (R0.4/R0.5) and make the job required so the gap is loud rather than latent | mercury-publishes-image | R0.1, R0.2 | **landed 2026-09-06 (Mercury side)** — `release.yml` gained a `publish-image` job with `packages: write`: builds from the published tarball, verifies with `smoke:image`, pushes `:<version>` and (stable only) `:latest`, image name set explicitly so it is not derived from `GITHUB_REPOSITORY`. Inert until the next `v*` tag. **One thing it cannot do itself**: retire `agent-image.yml` in tagula-agents — the owner's call. The package stays private (G-007), which is what it inherits by default, so no visibility step is pending |
38
+ | R0.4 | **A floating tag stops meaning "frozen at first pull".** `ensureImage()` returns the moment `docker image inspect` succeeds, so the default `:latest` is never refreshed and a host upgraded by npm keeps yesterday's container silently — the trap the owner hit on 2026-09-05. Either pin the default to `:${pkg.version}` (safe **only** after R0.3, or every fresh install fails on a tag nobody published) or re-pull a floating tag on a version change. Either way boot logs the resolved image ref, digest and version label so drift is visible without asking. Filed as `docs/bugs/ensure-image-never-refreshes-a-floating-tag.md`; take `docs/bugs/health-reports-stale-env-version.md` with it — `/health` reports `MERCURY_VERSION` from the environment and never the installed `package.json`, so the host half of "what is actually running here" lies for the same reason the image half does, and fixing one without the other leaves the console's version badge still wrong | image-refresh-and-visibility | R0.3 | backlog |
39
39
  | R0.5 | **`mercury upgrade` owns both halves.** `upgradeAction` contains no Docker call at all: it stops the service, npm-installs and restarts, leaving the image behind. It should pull the matching image first and refuse when that tag does not exist — image first, host second, the rule the fleet updater already follows and the one 0.17.0 broke | upgrade-pulls-image | R0.1 | backlog |
40
40
 
41
41
  ### Milestone R1: Preflight
@@ -45,7 +45,7 @@
45
45
 
46
46
  | ID | Story | Slug | Depends on | Status |
47
47
  |----|-------|------|------------|--------|
48
- | R1.1 | `mercury preflight [--json] [--space <id>]`: one command that runs, on the production launch shape and the pulled image, (a) the bwrap probe and the runsc host-uds probe (folded from `probeBwrapSandbox` / `probeRunscHostUds`, one implementation, boot calls the same function); (b) an `mrctl whoami` round trip from inside a container; (c) the **host-vs-image contract** — the image carries every file the host version's `container/Dockerfile` `COPY` list names, checked by a manifest baked at build time, and the image's recorded mercury version matches the host's; (d) the model chain's first leg resolves a credential *the way the runtime resolves it* (the `modelProvider` default vs `models.json` gateway case); (e) host deps — `pi` on `PATH` when any host-side pi spawn is configured, timezone not UTC-by-accident when any active task has `timezone:null`, swap present on ≤4 GB; (f) an extension load report with the reason for every skip (the silent `gws` case). Exit non-zero on any *blocked* verdict, zero with warnings otherwise; JSON report with per-check verdict, evidence and remedy. Runs in shadow mode too | preflight | — | backlog — **first** |
48
+ | R1.1 | `mercury preflight [--json] [--space <id>]`: one command that runs, on the production launch shape and the pulled image, (a) the bwrap probe and the runsc host-uds probe (folded from `probeBwrapSandbox` / `probeRunscHostUds`, one implementation, boot calls the same function); (b) an `mrctl whoami` round trip from inside a container; (c) the **host-vs-image contract** — the image carries every file the host version's `container/Dockerfile` `COPY` list names, checked by a manifest baked at build time, and the image's recorded mercury version matches the host's; (d) the model chain's first leg resolves a credential *the way the runtime resolves it* (the `modelProvider` default vs `models.json` gateway case); (e) host deps — `pi` on `PATH` when any host-side pi spawn is configured, timezone not UTC-by-accident when any active task has `timezone:null`, swap present on ≤4 GB; (f) an extension load report with the reason for every skip (the silent `gws` case). Exit non-zero on any *blocked* verdict, zero with warnings otherwise; JSON report with per-check verdict, evidence and remedy. Runs in shadow mode too | preflight | — | in-progress — **first** |
49
49
  | R1.2 | `mercury doctor` calls `loadConfig` and delegates its environment checks to R1.1 so there is one implementation; boot logs the same report once; a *blocked* verdict at boot refuses spawns with the diagnosis (already the case for the two probes — generalised) | doctor-delegates-to-preflight | R1.1 | backlog |
50
50
 
51
51
  **Checkpoint:** on the tagula box (with the owner's approval and in a cron
@@ -123,7 +123,7 @@ manual check in `pending-verification.md` is replaced by the reports.
123
123
  ```
124
124
  R0.1 ─┬─ R1.1(c)
125
125
  ├─ R0.5
126
- └─ R0.3 ◄─ G-007 (decision) ─ R0.4
126
+ └─ R0.3 ─ R0.4 (G-007: publish yes, private for now)
127
127
  R0.2 ─── R0.3
128
128
  R1.1 ─┬─ R1.2
129
129
  ├─ R2.1 ─ R2.2
@@ -135,7 +135,7 @@ rehearsal-bench M2.2 (function half) ─► R3.2
135
135
  ```
136
136
 
137
137
  R0.1 is the true first story: it is small, depends on nothing, and R1.1(c)
138
- cannot be built without it. R0.3 waits on a decision, not on code.
138
+ cannot be built without it. R0.3 no longer waits on anything but R0.1 and R0.2.
139
139
  R1.1 otherwise first, alone. R2 and R4 can run beside R3.1 in separate worktrees.
140
140
  R3.2 is the join with the bench. R5 last.
141
141
 
@@ -243,6 +243,22 @@ restarting, applying, deleting — whoever runs them, agent or owner.
243
243
 
244
244
  ## 7. Until the bench exists — what to do today
245
245
 
246
+ - **`mercury preflight` is today's gate.** One command, run from
247
+ `~/whatsapp-bot`, that proves this box can run a turn: both boot probes on
248
+ demand, an `mrctl` round trip in a container on the production launch shape,
249
+ the host-vs-image contract, the first model leg's credential, host deps and
250
+ the extension load report. Exit 1 on any blocker, 0 with warnings. It makes
251
+ no model call and costs no tokens, writes nothing but its report, and never
252
+ sends to a person — so it is safe on the live box at any hour. Run it after
253
+ every deploy, before declaring one done, and read the JSON report rather
254
+ than the terminal. The check-by-check table — what each id proves and
255
+ which verdict class it can return — is in `docs/container-lifecycle.md`
256
+ § "`mercury preflight`". It needs no configuration of its own: since
257
+ 2026-09-06 the round trip falls back to header identity when no
258
+ `MERCURY_CALLER_TOKEN_KEY` is set, and names the mode it used in the
259
+ evidence (`callerAuth`) — a `headers` pass proves transport, `API_SECRET`
260
+ and identity but not the caller-token leg, so set the key when you want
261
+ that leg covered too.
246
262
  - **Send-free, admin-only, DM-only:** `mercury chat -s <space>` /
247
263
  `POST /chat` runs the real pipeline and returns the reply as JSON; it never
248
264
  sends. It seeds the caller as admin (`routes/chat.ts:241`) and forces
@@ -327,6 +343,12 @@ The doctrine still holds, whoever implements it:
327
343
  connection open`; once a day, `SELECT MAX(created_at) FROM token_usage`
328
344
  — a bot that has not billed a run in a day is a bot nobody is talking
329
345
  to or a bot that is down, and only a person can tell which.
346
+ 6. **The evidence has a file.** `mercury preflight` writes
347
+ `<dataDir>/preflight/<timestamp>.json` and a `latest.json` beside it. That
348
+ file, not the terminal scrollback, is what a deploy attaches — a gate whose
349
+ evidence was not persisted did not pass. It answers "can this box run a
350
+ turn", never "is the customer's scenario still working": that second
351
+ question is the functional harness's, and it is still open.
330
352
 
331
353
  ## 9. Known gaps (kept honest)
332
354
 
@@ -9,6 +9,89 @@ once every line under it is ticked.
9
9
 
10
10
  ---
11
11
 
12
+ ## history window cap 2026-09-03 — deploy-gated re-measure
13
+
14
+ `context.history_max_chars` (default 2,000) clips older messages in the
15
+ `context`-mode window before they reach the container. The projection against
16
+ the live corpus says football's window drops 30.8% on average and 38% at the
17
+ tail; the point of these lines is that the *continuity* did not pay for it.
18
+ The cap itself, its config keys and the dashboard row are host-side — a
19
+ restart deploys them. The 400 KB backstop reader that came with the same
20
+ commit is **not**: `3884506` also edits `src/agent/container-entry.ts`
21
+ (`history_budget_dropped_turns`), which `container/Dockerfile` bakes into the
22
+ agent image, so that half needs a base rebuild
23
+ (`docs/debug/major/2026-09-03-history-window-dominates-the-run-prefix.md`).
24
+ Corrected on top of `dev` @ `7b7bc5f` — S1 of
25
+ `docs/notes/2026-09-06-audit-fix-plan.md`; S4 ticks against this text.
26
+
27
+ - [ ] After the restart, `task_runs.prompt_prefix_tokens` for `football-friends`
28
+ over a full day is below the 2026-09-03 baseline (21 runs, avg 33,343,
29
+ max 53,273). Compare the same query, not a trace.
30
+ - [ ] `mercury service logs` carries `History window: older messages clipped`
31
+ with a non-zero `savedChars` — the operator's half of the reader. If it
32
+ never appears, the cap is not reaching the call site.
33
+ *Not yet seen, and not yet expected: the line fires only when a history
34
+ row exceeds the per-message cap (2,000 chars). The first run on the new
35
+ image (task 436, 2026-09-06 13:33 IDT, `mercury-agent-ext:a5a60d5b20c9`)
36
+ had only short scan posts in its window — `prompt_prefix_tokens` 27,510
37
+ against the 33,343 baseline average. Re-check after the next 09:00
38
+ article is inside the window.*
39
+ - [ ] The dashboard's Context panel for `football-friends` shows a
40
+ `context.history_max_chars` row with the effective value, and setting it
41
+ there changes the next run's window (the key is per-space; a row the
42
+ runtime ignored would be the same invisible limit this fix removes).
43
+ - [ ] A run whose window contains a clipped post shows the
44
+ `[truncated: N of M characters omitted from this older message]` line
45
+ inside `<history>` in that run's trace, and **not** inside
46
+ `<reply_anchor>`.
47
+ - [ ] Continuity did not regress: ask the bot in the football group about
48
+ something it published the *previous* evening (not the newest turn) and
49
+ confirm it still knows what it posted. This is the check the bug doc
50
+ warned about — shrinking the window blind trades a cost problem for a
51
+ memory problem.
52
+ - [ ] A swipe-reply to a long bot post still quotes the post in full; the
53
+ anchor is never clipped.
54
+ - [x] The derived image carries the backstop reader:
55
+ `docker run --rm --entrypoint grep mercury-agent-ext:<hash> -c
56
+ history_budget_dropped_turns /app/src/agent/container-entry.ts` prints
57
+ `1`. Grep the image, never the repo — a restart alone leaves this at `0`.
58
+ *Verified 2026-09-06 13:10 IDT (S4): `dev` `631c3b5` synced, base rebuilt
59
+ 13:00–13:05, derived image `mercury-agent-ext:a5a60d5b20c9` built by the
60
+ service at 13:10; the grep printed `1` inside it (and `2` for
61
+ `shouldOverridePiSystemPrompt`, `2` for `EPISODE_MIN_STEM_CHARS`).*
62
+
63
+ ## club markers 2026-09-03 (D-023) — repo adopts the owner's chat edit; deploy-gated
64
+
65
+ The owner changed the seven club markers and added Maccabi Tel Aviv to the
66
+ standing coverage from the football chat on 2026-09-03 (12:35–12:55 IDT); the
67
+ bot wrote the live `AGENTS.md` and `MEMORY.md` itself, so **nothing changes on
68
+ the box at deploy** — the deploy only stops `apply` from reverting it.
69
+
70
+ - [x] After the merge is deployed: `space-profile check` against `dev` reports
71
+ **no** `AGENTS.md` drift. The only expected line is
72
+ `feed-watch.max_per_hour` (live `3`, set from the dashboard 2026-09-03
73
+ 01:19 IDT; profile `1`) — the owner's call, not this change's.
74
+ *Verified 2026-09-03 14:50 IDT: `dev` merged locally as `6aff14f`, synced
75
+ to the box; the sync's own check printed exactly one difference, the
76
+ `max_per_hour` line. Base image rebuilt (the deploy also carried
77
+ `e8b50e4`/`b9b5c94`, which touch baked files), derived image
78
+ `mercury-agent-ext:eac63950bd82` verified by grep at 14:55.*
79
+ *Post-`0e0a4e0` check, 2026-09-06 12:59 IDT (S4, `dev` `631c3b5`): the
80
+ sync's check printed exactly the two drifts the women's-football fix
81
+ introduced (`AGENTS.md` coverage line, `feed-watch.exclude` five terms)
82
+ and nothing else — `max_per_hour` no longer drifts since the profile
83
+ adopted `3` (D-024). `space-profile apply` at 13:10 wrote both; check
84
+ after apply: clean. Seed addendum (`52eaf67`) copied by hand into the
85
+ live `distill.md`; `diff` against the repo seed is empty.*
86
+ - [ ] The next scan post that names Barcelona or Beitar opens with the pair
87
+ (❤️💙 / 💛🖤) as one marker, and the harness (`bun run check` offline
88
+ lint) reports no `emoji-allowlist` on it. First live post under the new
89
+ set: message #4228, 2026-09-03 14:04 IDT, 👹 for United — already clean.
90
+ - [ ] A Maccabi Tel Aviv item in a scan opens with 💛💙. None has posted yet
91
+ (the club's last item, message #4226 at 13:43 IDT, was a Real Madrid one
92
+ that posted with no marker at all — a separate, pre-existing miss).
93
+
94
+
12
95
  ## Verification pass, 2026-08-31 — what the box could answer on its own
13
96
 
14
97
  Everything below was read from the live box (DB, journal, traces, images,
@@ -820,3 +903,98 @@ Host-side only: deploy = service restart, **no image rebuild** (`container-runne
820
903
  Host-side only: deploy = service restart, **no image rebuild** (`handler.ts` and `debounce.ts` are not baked).
821
904
 
822
905
  - [ ] MANUAL-VERIFY(2026-09-03): **A reply lands in the chat it was asked in.** On the VPS (`tagula-assistant`, space `tomer-ohana` has three linked groups), restart the service, then as one caller address the bot in group A with something slow, and while it is running address it in group B. Expect one reply in each group, each answering that group's own message. In `journalctl --user -u mercury.service`, the `Debounce: message queued behind an in-flight flush` line must now carry the thread in `key=` (`space:caller:thread`), and each `WhatsApp outbound chatJid=` must match the group its question came from.
906
+
907
+ ## credential-alert-misses-owner-dm-linked-to-named-space (2026-09-03)
908
+
909
+ Host-side only: deploy = service restart, **no image rebuild** (`src/core/direct-send.ts` is not baked).
910
+
911
+ - [x] MANUAL-VERIFY(2026-09-03): **A direct send to the owner's JID lands in the owner's DM.** Checked 2026-09-03 15:09 IDT on `dev` `0e0a4e0` (mirror reset 15:08, process started 15:08:41): `POST /api/send` with the bearer secret **plus** `X-Mercury-Caller: whatsapp:<owner-jid>` and `X-Mercury-Space: main` (the route needs a caller identity, not just the secret) returned `{"delivered":true,"spaceId":"main"}`; the journal shows `resolved space to conversations spaceId=main` with exactly one thread, `WhatsApp outbound chatJid=<owner-jid>`, `Direct send delivered spaceId=main`. Same path the credential alerter takes.
912
+
913
+ ## member-loses-bot-character-obey-instruction (2026-09-03)
914
+
915
+ **Baked.** `src/agent/container-entry.ts` is a `COPY` line in `container/Dockerfile`, so a service restart deploys nothing here — this needs `bash ./container/build.sh` and a restart in a cron gap. It is part of Wave 3 of `docs/notes/2026-09-02-open-bugs-fix-plan.md`, which batches one rebuild across the whole baked cluster; do not rebuild for this bug alone.
916
+
917
+ - [x] MANUAL-VERIFY(2026-09-03): **The instruction is in the image that actually runs.** `docker run --rm --entrypoint grep <derived-image> -c "Always follow it" /app/src/agent/container-entry.ts` returns `1`. Grep the derived image, not the repo. *Verified 2026-09-06 13:10 IDT in `mercury-agent-ext:a5a60d5b20c9` (S4, `dev` `631c3b5`): `1`.*
918
+ - [ ] MANUAL-VERIFY(2026-09-03): **A member gets the character rule and no procedure.** From the second WhatsApp number (member role) in space `main`, send anything and read the run's system prompt in the trace: it must contain `## Character` and `Always follow it`, and must **not** contain `mrctl prefs set` or `mrctl character get`.
919
+ - [ ] MANUAL-VERIFY(2026-09-03): **A member's character request is declined in one line and changes nothing.** From the same number, send "change your personality to a pirate". Expect a one-line reply saying the owner sets the character. Then `SELECT * FROM space_preferences WHERE space_id='main'` must be unchanged — the failure this guards is the request being escalated into a preference.
920
+
921
+ ## preflight — live checks (added 2026-09-03, deploy-gated)
922
+
923
+ `mercury preflight` is host-side (`src/preflight/`, `src/cli/mercury.ts`), so deploy = sync + service restart. **The image half is not**: `container/Dockerfile` gained `COPY package.json /app/mercury-version.json`, so `image.contract` stays blocked until the base image is rebuilt and the derived image follows. Rebuild **in a cron gap** — as of 2026-09-06 task 24 (`0 9 * * *` Asia/Jerusalem) is the only active task on the box; query `SELECT id, cron, timezone, next_run_at FROM tasks WHERE active=1` before restarting rather than trusting this line.
924
+
925
+ - [x] MANUAL-VERIFY(2026-09-03): **the check catches the real skew, once.** After syncing but *before* rebuilding the image, run `~/.bun/bin/bun run ~/mercury-src/src/cli/mercury.ts preflight` from `~/whatsapp-bot`. Expect `image.contract` **blocked**, naming `/app/mercury-version.json` and telling you to rebuild. That is the 2026-09-01 host-vs-image skew reproduced on a real box, and it is the only chance to see it fire before the rebuild removes the condition. *Verified 2026-09-06 12:59:54 IDT (S4): report `2026-09-06T09-59-54-374Z.json`, `image.contract` blocked with `missing: ["/app/mercury-version.json"]`, `imageVersion: null`, remedy names the rebuild.*
926
+ - [x] MANUAL-VERIFY(2026-09-03): **a clean board after the rebuild.** Then `cd ~/mercury-src && bash ./container/build.sh`, restart in a cron gap, and re-run preflight. Expect exit 0: `image.contract` pass, `sandbox.bwrap` skipped (`containerBwrapDockerCompat` on this box), `container.mrctl-roundtrip` pass, `credential.first-leg` pass. Read `~/whatsapp-bot/.mercury/preflight/latest.json`, not the terminal — the report file is the evidence, and `docker ps -a --filter label=mercury.managed=true` must show no leftover `mercury-contract-probe-*` or `mercury-roundtrip-probe-*` container. *Run 2026-09-06 13:10:26 IDT after the rebuild (report `2026-09-06T10-10-26-466Z.json`): 8 pass · 0 warn · **1 blocked** · 2 skipped, exit 1. `image.contract` **pass**, `sandbox.bwrap` skipped, `credential.first-leg` pass, no leftover probe container — but `container.mrctl-roundtrip` blocked with `401 Invalid or expired caller token`: this box has no `MERCURY_CALLER_TOKEN_KEY`, so the preflight process signs with its own per-process random key and the service verifies with another. Not a wrong `.env` and not fixable by re-running — filed as `docs/bugs/preflight-roundtrip-cannot-pass-without-configured-caller-token-key.md`. Left unticked until that bug is fixed or the owner sets the key.* **Ticked 2026-09-06 18:20 IDT:** the owner set `MERCURY_CALLER_TOKEN_KEY` in the box's `.env` (one line, value never displayed) and restarted at 18:19:58; `latest.json` at 18:20:34 reads exit 0, **9 pass · 0 warn · 0 blocked · 2 skipped**, `container.mrctl-roundtrip` pass with `answeredCallerId: "preflight"`, `answeredSpaceId: "main"`, `role: "member"`, image `a5a60d5b20c9`; `docker ps -a --filter label=mercury.managed=true` empty. The bug stays open for boxes that never set the key.*
927
+
928
+ The three below close audit findings 1, 2 and 3 (2026-09-06). Each is a one-liner on the box and each fails on the *live* shape the unit tests can only fake.
929
+
930
+ - [x] MANUAL-VERIFY(2026-09-06): **`--json` is parseable on a box that loads extensions.** `cd ~/whatsapp-bot && ~/.bun/bin/bun run ~/mercury-src/src/cli/mercury.ts preflight --json | python3 -m json.tool > /dev/null && echo PARSED`. This box loads real extensions and the fixture only loads one, so it is the honest version of the test. A `Loaded extension` line anywhere in stdout means the `configureLogger({ level: "warn" })` did not take. *Verified 2026-09-06 13:11 IDT: stdout 4,945 bytes, stderr 0 bytes, no non-JSON line, `JSON.parse` in bun returns the ten top-level keys (`schema … reportPath`). `python3 -m json.tool` exits 0 on the same output; the one "NOT PARSEABLE" seen first was `set -o pipefail` surfacing preflight's own exit 1 (the round-trip blocker), not a parse failure.*
931
+ - [x] MANUAL-VERIFY(2026-09-06): **the round trip leaves no member row.** Before the run: `SELECT COUNT(*) FROM space_roles WHERE platform_user_id='preflight'` → whatever it is now (it may be non-zero from runs before the fix — clean those up with `DELETE FROM space_roles WHERE platform_user_id='preflight'` and say so). Run preflight, then the same query → **0**. Also `mrctl roles list` and the dashboard members table must not show `preflight`. *2026-09-06: count was 0 before and 0 after both runs — but the probe was refused at the token check (401, see the round-trip line) before any role resolution ran, so this proves nothing yet; re-check once the round trip actually completes.* **Re-checked 2026-09-06 18:20 IDT after the key was set:** the round trip completed (`answeredCallerId: preflight`, `role: member`) and `SELECT COUNT(*) FROM space_roles WHERE platform_user_id='preflight'` → **0**.*
932
+ - [x] MANUAL-VERIFY(2026-09-06): **the credential source names the passthrough mode.** In `latest.json`, `credential.first-leg` evidence carries `envPassthrough` matching this box's `agent.env_passthrough` (`claimed` here), and — since the leg is Anthropic OAuth — `source` reads `env:MERCURY_ANTHROPIC_OAUTH_TOKEN (parsed, not refreshed)`. The qualifier is the point: preflight does not spend a refresh, so a pass here is "a usable credential is present", not "it works". *Verified 2026-09-06 13:10 IDT, with one correction to this line's premise: evidence `{"envPassthrough":"claimed","credentialEnvVar":"ANTHROPIC_OAUTH_TOKEN","source":"auth.json:oauth","authPath":"…/.mercury/global/auth.json","leg":"anthropic:claude-opus-5"}`. `envPassthrough` matches the box; `source` reads `auth.json:oauth` rather than the `env:…` form because on this box the token lives in `auth.json`, not in the environment — the check named the real source, the expectation here assumed the wrong one.*
933
+
934
+ ## env_passthrough default flipped to `claimed` 2026-09-06
935
+
936
+ Host-side only (`src/config.ts`, `src/main.ts`) — a restart deploys it, no
937
+ image rebuild. This box already pins `agent.env_passthrough: claimed` in
938
+ `~/whatsapp-bot/mercury.yaml`, so the flip is a **no-op here**; that is exactly
939
+ why the default itself has to be proven rather than assumed.
940
+
941
+ - [ ] MANUAL-VERIFY(2026-09-06): **the default is `claimed` with no config saying so.** Comment out the `env_passthrough: claimed` line in `~/whatsapp-bot/mercury.yaml`, confirm by name only that `MERCURY_CONTAINER_ENV_PASSTHROUGH` is absent from `~/whatsapp-bot/.env` (`grep -c` — never print the file), restart **in a cron gap** (`SELECT id, cron, timezone, next_run_at FROM tasks WHERE active=1`), then read `mercury service logs` for the startup line. Expect `Container env passthrough: claimed` with a `withheld=` field naming the unscoped `MERCURY_*` vars (names only — a value in that log is itself a bug). Restore the yaml line and restart, again in a gap.
942
+ - [ ] MANUAL-VERIFY(2026-09-06): **the escape hatch still logs the old warning.** With `agent.env_passthrough: all` set explicitly, the startup line reads ``Container env passthrough: explicit `all` override`` with a `vars=` field. This is the only branch a deployment relying on blind passthrough will ever see, so it has to name the vars it is carrying.
943
+
944
+ ## override_pi_system_prompt parsed strictly 2026-09-06
945
+
946
+ Host-side only (`src/config.ts`, `src/agent/container-runner.ts`) — a restart
947
+ deploys it, no image rebuild. This box sets `override_pi_system_prompt: true` in
948
+ `~/whatsapp-bot/mercury.yaml` and no env var, so the change is a **no-op here**:
949
+ the point of the line below is that it stayed one.
950
+
951
+ - [ ] MANUAL-VERIFY(2026-09-06): **the container still gets `true`.** After the restart, during a live run: `docker inspect $(docker ps -q --filter label=mercury.managed=true | head -1) --format '{{range .Config.Env}}{{println .}}{{end}}' | grep OVERRIDE` prints `OVERRIDE_PI_SYSTEM_PROMPT=true`. Read it off the running container, not off `mercury.yaml` — the value the container was handed is the only thing that decides which prompt mode pi runs in.
952
+ - [ ] MANUAL-VERIFY(2026-09-06): **a typo now stops startup instead of taking the bot down quietly.** Add `MERCURY_OVERRIDE_PI_SYSTEM_PROMPT=no` to `~/whatsapp-bot/.env`, restart **in a cron gap** (`SELECT id, cron, timezone, next_run_at FROM tasks WHERE active=1`), and expect the service to fail to start: `mercury service logs` carries `Startup failed` with an error naming `override_pi_system_prompt` (`main.ts:715` turns a `loadConfig` throw into exit 1) — not a bot that boots and answers every message with a 400. Remove the line and restart, again in a gap.
953
+
954
+ ## swipe-reply to media keeps its quote block 2026-09-06
955
+
956
+ Host-side only (`src/core/reply-context.ts`, `src/core/runtime.ts`) — a restart
957
+ deploys it, no image rebuild. The de-duplication that drops a swipe-reply's
958
+ `<reply_to>` block now makes an exception for a quote whose opening tag carries
959
+ `media_type=`, because that attribute is the only place in the whole prompt
960
+ where the quoted message's media is named. The tests cover the branch; what
961
+ they cannot cover is that a real WhatsApp voice-note quote actually produces the
962
+ attribute in the stanza.
963
+
964
+ - [ ] MANUAL-VERIFY(2026-09-06): **a swipe-reply to a voice note still names the audio.** In the private `main` space (never the football group), send a voice note, then swipe-reply to it with `מה אמרתי בהודעה הקולית?`. The newest `messages` row with `role='user'` contains `media_type="voice"` and `[voice note]`, and the bot's answer addresses the recording rather than an empty quote.
965
+ - [ ] MANUAL-VERIFY(2026-09-06): **a text quote is still de-duplicated.** In the same space, swipe-reply to one of the bot's own text messages. The stored user row is the question alone — no `<reply_to` — so the exception did not widen into "never strip".
966
+
967
+ ## preflight round trip without a caller-token key 2026-09-06
968
+
969
+ Host-side only (`src/preflight/checks/roundtrip.ts`) — a sync + restart deploys
970
+ it, no image rebuild. **This box now has `MERCURY_CALLER_TOKEN_KEY` set** (the
971
+ owner added it at 18:19 IDT as the workaround), so the default path the fix
972
+ exists for is *not* the path this box takes by default. The first line below is
973
+ therefore the whole point: it has to be checked with the key temporarily out of
974
+ the way, or it proves nothing.
975
+
976
+ - [ ] MANUAL-VERIFY(2026-09-06): **the round trip passes with no caller-token key.** From `~/whatsapp-bot`, run preflight with the key blanked for that one process only — `MERCURY_CALLER_TOKEN_KEY= ~/.bun/bin/bun run ~/mercury-src/src/cli/mercury.ts preflight` (an env var beats `mercury.yaml`, and an empty value is read as unconfigured). Never edit the `.env`. In the report, `container.mrctl-roundtrip` must be **pass** with evidence `callerAuth: "headers"` and `answeredCallerId: "preflight"`, and its title must say the token leg was not exercised. Read `~/whatsapp-bot/.mercury/preflight/latest.json`, not the terminal.
977
+ - [ ] MANUAL-VERIFY(2026-09-06): **the configured key is still used.** Run preflight normally (no env override). Same check must be **pass** with evidence `callerAuth: "token"` — the fallback must not have swallowed the real path on a box that has a key.
978
+ - [ ] MANUAL-VERIFY(2026-09-06): **the header round trip still leaves no member row.** After the first run above, `SELECT COUNT(*) FROM space_roles WHERE platform_user_id='preflight'` → **0**, and `mrctl roles list` must not show `preflight`. The ephemeral-caller rule is keyed on the caller id, not on how it was asserted, so this should hold in both modes — which is exactly why it is worth checking once on the mode that never ran on a box before.
979
+
980
+ ## image-self-describing (R0.1, 2026-09-06)
981
+
982
+ Image-side: the Dockerfile changed, so nothing here is live until the image is
983
+ rebuilt. The checks below are the only proof the stamping works — the unit
984
+ tests cover the parser, not the build.
985
+
986
+ - [x] VERIFIED(2026-09-07, dev box, Docker 29.5.3 linux/amd64): built from the tip of `dev` with `--build-arg MERCURY_VERSION=0.19.0` under a scratch tag; `com.mercury.version` and `org.opencontainers.image.version` both read `0.19.0`. Original check: **the build stamps a real version.** On a machine with Docker and this checkout, `bash container/build.sh`, then `docker image inspect mercury-agent:$(bun -e 'console.log(require("./package.json").version)') -f '{{index .Config.Labels "com.mercury.version"}}'` — expect the package.json version, never `unknown`. `unknown` means the `--build-arg` did not reach the `ARG`, and the whole R0.4/R0.5 version check would be reading a constant.
987
+ - [x] VERIFIED(2026-09-07): `/app/image-manifest.json` present, version `0.19.0`, 16 `copySources` including `src/agent/container-entry.ts` and `container/Dockerfile`, zero `--from=` paths; the copied Dockerfile's sha256 matches the repo's LF-normalised file. `bun run smoke:image` against the same image passed all four checks, including the two R0.2 added (version label; `container-entry.ts` and `mrctl.ts` both `bun build` inside the image). Original check: **the manifest is inside the image and describes it.** `docker run --rm --entrypoint cat mercury-agent:latest /app/image-manifest.json` — expect the same version, a `dockerfileSha256`, and `copySources` listing `src/agent/container-entry.ts` and `container/Dockerfile`. Nothing from a `COPY --from=` stage (`/usr/local/bin/bun`, `/usr/local/go`) may appear.
988
+ - [ ] MANUAL-VERIFY(2026-09-06): **the derived image inherits the label.** After a space with extensions has built `mercury-agent-ext-*`, `docker image inspect <derived> -f '{{index .Config.Labels "com.mercury.version"}}'` must report the same version as the base. Verified once by hand on the tagula box 2026-09-05 for the old labels; this re-checks it for the ones the build now sets, because R0.4's host-side check reads the version through the derived image, not the base.
989
+ - [ ] MANUAL-VERIFY(2026-09-06): **both tags point at one image.** `docker image inspect mercury-agent:latest -f '{{.Id}}'` and the same for `:<version>` — identical ids. If they differ, `build.sh` built twice and `:latest` is not the version it claims.
990
+
991
+ ## mercury-publishes-image (R0.3, 2026-09-06)
992
+
993
+ CI-side: `release.yml`'s new `publish-image` job does not run until the next
994
+ `v*` tag, so none of this is proven yet. The first release after this is the
995
+ test.
996
+
997
+ - [ ] MANUAL-VERIFY(2026-09-06): **the job runs and pushes.** On the next `v*` tag, watch the `publish-image` job. It runs after `publish-npm`, so if it fails npm is ahead of GHCR — safe (boxes follow the image) but the release is not finished. Expect `ghcr.io/avishai-tsabari/mercury-agent:<version>` in the repo's Packages.
998
+ - [ ] MANUAL-VERIFY(2026-09-06): **a prerelease does not move `:latest`.** If the next tag is a prerelease, confirm the job log says `:latest untouched` and that `:latest` still resolves to the previous stable id.
999
+ - [ ] MANUAL-VERIFY(2026-09-06): **the package landed private, and the fleet token can read it.** G-007 keeps visibility private. After the first push, GitHub → Packages → `mercury-agent` should show Private (it inherits the repo's visibility; nothing here changes it). Then from a box, or any shell with the fleet's `REGISTRY_PULL_TOKEN`: `docker login ghcr.io -u x-access-token --password-stdin` and `docker pull ghcr.io/avishai-tsabari/mercury-agent:<version>` — must succeed, since that token is what `box-upgrade.ts` will use once it points here.
1000
+ - [ ] MANUAL-VERIFY(2026-09-06): **retire the fleet's duplicate.** Once a Mercury-published image exists for a version, `agent-image.yml` in tagula-agents is building the same thing into a different namespace. Decide deliberately: point `box-upgrade.ts` at the Mercury image and delete that workflow, or keep the fleet image and accept two publishers. Do not leave both running unexamined — two images with the same version label and different contents is worse than either.
@@ -57,7 +57,7 @@ Custom roles can be created by assigning permissions to any role name.
57
57
  | `spaces.rename` | Rename a space and link/unlink conversations |
58
58
  | `spaces.delete` | Delete current space and all related DB data |
59
59
  | `model.list` | List the configured model chain (`mrctl model list`, chat `/model list`) |
60
- | `model.switch` | Switch the active model for the space (`mrctl model switch`, chat `/model switch`) — takes effect on the next agent run |
60
+ | `model.switch` | Pin the space to one model leg (`mrctl model switch`, chat `/model switch`) or clear that pin (`mrctl model reset`, chat `/model reset`) — takes effect on the next agent run. A pinned space has no fallback; reset is refused while the space has an active scheduled task |
61
61
  | `media.receive` | Incoming attachments are saved to `inbox/` and shown to the agent |
62
62
  | `media.send` | Outbox files produced on this caller's turn are delivered back to the chat |
63
63
 
@@ -203,10 +203,15 @@ win by being specific:
203
203
  - *Presenting tool results* — "simple lists, never commands/JSON". A
204
204
  reporter who must not answer in lists: say so; a profile whose users *are*
205
205
  developers: say the opposite.
206
- - *Character → "set a preference"* — a standing instruction from a member
207
- becomes "only an admin can set that", which in a friends' group reads as
208
- refusing a joke. Say what the bot does with a standing request from a
209
- member (relay, decline in one line, or treat as banter).
206
+ - *Character → "set a preference"* — a standing instruction from an
207
+ admin becomes "only an admin can set that", which in a friends' group reads
208
+ as refusing a joke. A member never sees that procedure at all: from the
209
+ 2026-09 image rebuild (`7d242e2`) the `## Character` heading and its "Always
210
+ follow it" / "only the bot owner can change it" sentences ship to every
211
+ caller, while the `mrctl` procedure under them stays gated on `prefs.set`, so
212
+ a member's request gets the one-line decline. Say what the bot does with a
213
+ standing request from a member (relay, decline in one line, or treat as
214
+ banter).
210
215
  - *"Be concise"*, *"Ask for clarification"* from the pi preamble and the
211
216
  default global `AGENTS.md` — a reporter that must give a full factual
212
217
  answer, or a bot that must not ask before acting, says so.
@@ -360,10 +365,25 @@ What that means for a profile:
360
365
  bot an explicit rule for when to update it. The three DM spaces on the
361
366
  live bot that have one use it; the football space, which needed it most,
362
367
  never had one.
363
- 3. **Episodes need keywords that will recur.** An episode about "Abu Fani's
364
- transfer" with `keywords: ["אבו פאני","hapoel","transfer"]` surfaces on
365
- the next message containing one of those words; without keywords it never
366
- surfaces. Prefer a notebook the bot reads by rule over relying on scoring.
368
+ 3. **Episodes need keywords that will recur, one word each.** An episode
369
+ about "Abu Fani's transfer" with `keywords: ["פאני","fani","hapoel",
370
+ "transfer"]` surfaces on the next message containing one of those words;
371
+ without keywords it never surfaces. The message is split on whitespace,
372
+ ASCII punctuation, the hyphen and the maqaf, so a keyword with a space, an
373
+ apostrophe or a dash — `"אבו פאני"`, `"מנצ'סטר"`, `"man-united"` — can
374
+ never match anything (a geresh or gershayim *inside* a word in the message
375
+ is dropped rather than split, so `לבית"ר` does reach the keyword `ביתר`;
376
+ the keyword itself is never rewritten). A Hebrew prefix on the word still
377
+ matches (`ליונייטד`, `וכשיונייטד`) as long as four letters remain once it
378
+ is stripped — five after a leading `מ`, which also opens whole words. So
379
+ list the bare word once — every extra keyword lowers the note's score —
380
+ and before choosing a short keyword, read it back with each of
381
+ `ו/ה/ב/ל/ש/כ` in front: if that spells a different everyday word, the
382
+ note will surface on that word too, so pick another keyword. Mem is the
383
+ one the host handles itself — it turned `שוער` (goalkeeper) into `משוער`
384
+ (projected) — and the extra letter it costs is deliberate: `מחיפה` no
385
+ longer reaches a note keyed `חיפה`, while `חיפה` and `בחיפה` still do.
386
+ Prefer a notebook the bot reads by rule over relying on scoring.
367
387
  4. **Do not let distillation stand in for a notebook.** On the live bot
368
388
  napkin distils every space daily and has written *zero* entity files for
369
389
  the football space — group banter does not meet its "lasting signal"
@@ -13,7 +13,6 @@
13
13
 
14
14
  import { randomBytes } from "node:crypto";
15
15
  import {
16
- copyFileSync,
17
16
  existsSync,
18
17
  mkdirSync,
19
18
  readdirSync,
@@ -22,6 +21,7 @@ import {
22
21
  statSync,
23
22
  unlinkSync,
24
23
  } from "node:fs";
24
+ import { copyFile } from "node:fs/promises";
25
25
  import { dirname, join } from "node:path";
26
26
  import { type ArchiveBackend, ArchiveConfigError } from "./types.js";
27
27
 
@@ -84,7 +84,9 @@ export class LocalArchiveBackend implements ArchiveBackend {
84
84
  // hash that claims to describe complete content.
85
85
  const tmp = `${dest}.${randomBytes(4).toString("hex")}.tmp`;
86
86
  try {
87
- copyFileSync(srcPath, tmp);
87
+ // Awaited: blobs run to the 25 MB outbox cap and this backend is on the
88
+ // host delivery path — a sync copy stalls every other space.
89
+ await copyFile(srcPath, tmp);
88
90
  renameSync(tmp, dest);
89
91
  } catch (err) {
90
92
  // Leave nothing behind on failure; the queue will retry.
@@ -122,7 +124,8 @@ export class LocalArchiveBackend implements ArchiveBackend {
122
124
  // attached to a reply and sent to the customer as if it were whole.
123
125
  const tmp = `${destPath}.${randomBytes(4).toString("hex")}.tmp`;
124
126
  try {
125
- copyFileSync(src, tmp);
127
+ // Awaited, same reason as `put`.
128
+ await copyFile(src, tmp);
126
129
  renameSync(tmp, destPath);
127
130
  } catch (err) {
128
131
  try {