switchroom 0.19.19 → 0.19.23

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +121 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +2995 -1583
  5. package/dist/host-control/main.js +122 -2
  6. package/dist/vault/approvals/kernel-server.js +124 -4
  7. package/dist/vault/broker/server.js +124 -4
  8. package/package.json +7 -4
  9. package/profiles/_base/start.sh.hbs +101 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +64 -109
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
  12. package/profiles/_shared/dev-protocol.md.hbs +13 -42
  13. package/profiles/_shared/execution-discipline.md.hbs +7 -14
  14. package/profiles/coding/CLAUDE.md.hbs +0 -6
  15. package/profiles/default/CLAUDE.md.hbs +21 -50
  16. package/skills/dev-protocol/SKILL.md +90 -107
  17. package/skills/switchroom-release/SKILL.md +103 -20
  18. package/telegram-plugin/bunfig.toml +10 -0
  19. package/telegram-plugin/card-format.ts +92 -3
  20. package/telegram-plugin/dist/gateway/gateway.js +873 -184
  21. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  22. package/telegram-plugin/format.ts +19 -7
  23. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  24. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  25. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  26. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  27. package/telegram-plugin/gateway/gateway.ts +75 -63
  28. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  29. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  30. package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
  31. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  32. package/telegram-plugin/gateway/stream-render.ts +6 -0
  33. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  34. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  35. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  36. package/telegram-plugin/retry-api-call.ts +15 -2
  37. package/telegram-plugin/send-gate.ts +1 -1
  38. package/telegram-plugin/status-no-truncate.ts +64 -1
  39. package/telegram-plugin/status-pin-driver.ts +50 -27
  40. package/telegram-plugin/status-pin.ts +43 -5
  41. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  42. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  43. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  44. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  45. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  46. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  47. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  48. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  49. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  50. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  51. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  52. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  53. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  54. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  55. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  56. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  57. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  58. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  59. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  60. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  61. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  62. package/telegram-plugin/tool-activity-summary.ts +85 -13
  63. package/telegram-plugin/worker-activity-feed.ts +5 -1
  64. package/vendor/hindsight-memory/scripts/drain_pending.py +304 -34
  65. package/vendor/hindsight-memory/scripts/lib/pending.py +886 -70
  66. package/vendor/hindsight-memory/scripts/lib/retain_split.py +71 -13
  67. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1602 -14
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +93 -13
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +44 -3
  76. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -13,51 +13,14 @@ description: >
13
13
 
14
14
  # Development Protocol — the playbook
15
15
 
16
- The always-loaded CLAUDE.md "Development Protocol" section is the summary.
17
- This skill is the long-form procedure. Work through the five phases in order;
18
- they are checkpoints, not vibes.
19
-
20
- ## 1. Orient — ground before you build
21
-
22
- Before forming any theory or plan:
23
-
24
- - **Read the real source.** The repo's source files at the current HEAD — not
25
- build artifacts (`dist/`, generated files, caches), not your memory of the
26
- code, not the task description's paraphrase. If a claim matters, open the
27
- file.
28
- - **Verify the root cause, not the first plausible cause.** Reproduce or trace
29
- the failure to a specific mechanism before fixing. "This line looks wrong"
30
- is a lead; the fix ships only when you can say *why* it produced the
31
- observed symptom.
32
- - **Report contradicting evidence.** If what you find contradicts the task
33
- description, the ticket, or your own working theory — say so explicitly and
34
- stop to re-plan. Never force-fit evidence to the plan you already had.
35
- - **Cite everything.** Claims about the codebase carry `file:line`, commit
36
- hashes, or PR numbers. "The scaffold appends fragments at
37
- `src/agents/scaffold.ts:4113`" is a claim; "the scaffold appends fragments
38
- somewhere" is not.
39
-
40
- ## 2. Clarify vs proceed
41
-
42
- - **Infer first.** Most questions are answerable from the codebase, git
43
- history, existing tests, or docs. Exhaust those before asking.
44
- - **One question at a time.** If genuinely unsure after inferring, ask the
45
- single question whose answer unblocks the most work. Phrase it as a
46
- decision with a default: state what you found, the 2–3 viable options, which
47
- you'd pick and why, and ask for confirmation — e.g. *"The config loader
48
- supports both YAML and JSON overlays; the task says 'config file' without
49
- specifying. I'd extend the YAML path since all existing overlays are YAML
50
- (src/config/merge.ts:88) — confirm, or should JSON be covered too?"* Never
51
- send a questionnaire.
52
- - **Phase discipline.** Clarify during *planning*. Once the plan is agreed,
53
- execute autonomously: make the reasonable call on small ambiguities, record
54
- the assumption in your report, and keep moving. Mid-execution questions are
55
- reserved for discoveries that invalidate the plan.
56
-
57
- ## 3. Design-align on larger tasks
58
-
59
- **Classify the task first.** Treat it as "larger" (design-align before
60
- implementing) when ANY of these hold:
16
+ The always-loaded CLAUDE.md "Development Protocol" section carries the
17
+ judgement criteria. This skill carries the parts that are *this fleet's
18
+ specific opinion* the ones you would get wrong by defaulting to generic
19
+ good practice, because our answer differs from the obvious one.
20
+
21
+ ## 1. Is this a "larger" task? (decides whether you design-align first)
22
+
23
+ Treat it as larger — design report before implementing — when ANY of these hold:
61
24
 
62
25
  - It changes a public interface, schema, config shape, or on-disk format.
63
26
  - It cuts across 3+ modules or touches a load-bearing invariant.
@@ -67,65 +30,85 @@ implementing) when ANY of these hold:
67
30
  ("add flag Y").
68
31
  - It will land as more than one PR.
69
32
 
70
- Small, single-concern, obvious-approach changes skip straight to phase 4.
71
-
72
- For larger tasks:
73
-
74
- 1. **Design report before code.** Send the user an evidence-grounded report:
75
- what exists today (with citations), what will change, the chosen approach
76
- and its rejected alternatives, and the PR staging plan. Get alignment
77
- before implementation.
78
- 2. **Red-team your own plan adversarially.** Review the design item by item.
79
- Each item gets a verdict `SOUND`, `RISK`, or `WRONG` backed by
80
- evidence (a file you read, a test you ran, a documented behavior), not
81
- intuition. Fix every `WRONG` and address every `RISK` before starting.
82
- 3. **Stage delivery as focused single-concern PRs.** One concern per PR:
83
- reviewable in one sitting, revertable in one command. Never bundle a
84
- refactor with a behavior change.
85
-
86
- ## 4. Pipeline — how a change ships
87
-
88
- 1. **Branch off fresh main.** `git fetch && git checkout -b <branch> origin/main`.
89
- 2. **Implement with durable fixes.** Fix root causes. A workaround is
90
- acceptable only with an explicit reason stated and a follow-up filed.
91
- Prefer deterministic mechanisms (a check, a hook, a schema, a lint gate)
92
- over model-dependent behavior if code can enforce the guarantee, don't
93
- leave it to prompt discipline.
94
- 3. **Tests assert outcomes.** Every test must fail if the bug it guards
95
- returns. A test that merely exercises the code path without asserting the
96
- observable outcome is not a test.
97
- 4. **Scoped tests + lint locally.** Run the test files covering what you
98
- touched, plus the repo's lint gate. Local runs are a fast filter; **CI is
99
- the full-suite authority** — never claim done off a local run alone.
100
- 5. **Adversarial review of the diff.** Dispatch a reviewer (sub-agent or
101
- fresh pass) with this structure:
102
- - Input: the full diff, the task statement, and the design report if one
103
- exists.
104
- - Charge: *find reasons this change is wrong* — correctness, missed edge
105
- cases, untested behavior, inconsistency with surrounding code, docs
106
- drift, security/data-loss risk.
107
- - Output: a findings list, each with severity (high/medium/low), the
108
- evidence (`file:line`), and a concrete fix.
109
- 6. **Fix ALL findings — including lows.** A low you skip is a bug you
110
- shipped. If a finding is genuinely invalid, rebut it with evidence in
111
- writing; silence is not a rebuttal.
112
- 7. **Re-review the fix.** The re-review verdict must contain, per original
113
- finding: the finding ID, what changed (`file:line` of the fix), whether it
114
- fully resolves the finding (`RESOLVED` / `PARTIAL` / `REBUTTED` with
115
- evidence), and whether the fix introduced anything new. A bare "fixed" is
116
- not a verdict.
117
- 8. **Merge only on CI green.** No exceptions. A red or flaky CI run is a
118
- blocker to investigate, not to override.
119
-
120
- ## 5. Communicate while you work
121
-
122
- - **Consolidated messages.** Batch related findings and results into one
123
- substantive update; never send five fragments where one message serves.
124
- - **Always-visible progress.** Long-running work surfaces status the user can
125
- see (progress card, interim edit, explicit "still running: X"). Never go
126
- dark mid-task.
127
- - **No foreground watches over 30 seconds.** Anything longer — builds, CI
128
- waits, deploys runs in the background with a notification on completion.
129
- Don't block a turn polling.
130
- - **Max 15 parallel sub-agents.** Fan out for genuinely parallel work
131
- (independent reviews, independent modules), but cap the swarm at 15.
33
+ Small, single-concern, obvious-approach changes skip straight to the pipeline.
34
+ Design-aligning a one-liner is its own failure mode.
35
+
36
+ ## 2. Design report + red-team (larger tasks only)
37
+
38
+ The report states what exists today **with citations**, what will change, the
39
+ chosen approach, the alternatives you rejected and why, and the PR staging
40
+ plan. Get alignment before implementing.
41
+
42
+ Then red-team your own plan item by item. Each item gets a verdict —
43
+ `SOUND`, `RISK`, or `WRONG` backed by evidence you can point at (a file you
44
+ read, a test you ran, documented behaviour), not intuition. Fix every `WRONG`
45
+ and address every `RISK` before starting. A red-team that returns all-`SOUND`
46
+ on a non-trivial plan is a red-team you didn't actually do.
47
+
48
+ ## 3. Ask one question, as a decision with a default
49
+
50
+ If you're genuinely blocked after inferring from code and history, don't send
51
+ a questionnaire. State what you found, the 2–3 viable options, which you'd
52
+ pick and why, and ask for confirmation:
53
+
54
+ > "The config loader supports both YAML and JSON overlays; the task says
55
+ > 'config file' without specifying. I'd extend the YAML path since all
56
+ > existing overlays are YAML (`src/config/merge.ts:88`) — confirm, or should
57
+ > JSON be covered too?"
58
+
59
+ Clarify during *planning*. Once the plan is agreed, execute autonomously:
60
+ make the reasonable call on small ambiguities, record the assumption in your
61
+ report, keep moving. Mid-execution questions are reserved for discoveries
62
+ that invalidate the plan.
63
+
64
+ ## 4. Adversarial review — bounded on purpose
65
+
66
+ Dispatch the review to a **fresh** pass or sub-agent; the coder cannot review
67
+ its own work in-context. Structure it:
68
+
69
+ - **Input:** the full diff, the task statement, and the design report if one exists.
70
+ - **Charge:** *find reasons this change is wrong* — correctness, missed edge
71
+ cases, untested behaviour, inconsistency with surrounding code, docs drift,
72
+ security or data-loss risk.
73
+ - **Output:** a findings list, each with a severity, the evidence (`file:line`),
74
+ and a concrete fix.
75
+
76
+ **The severity gate this is the fleet-specific part.** The old rule was
77
+ "fix ALL findings including lows, then re-review". That is a loop generator by
78
+ construction: an adversarial reviewer always surfaces lows (that is its job),
79
+ fixing lows produces a new diff, and a new diff earned another re-review. It
80
+ produced PRs going four rounds where the last round's only finding was an
81
+ inaccurate doc comment. So:
82
+
83
+ - **Blockers and mediums block the merge.** Fix them.
84
+ - **Lows do NOT block.** Fix a low inline only if it's a genuine one-liner;
85
+ otherwise **file a follow-up issue and merge**. Filing is mandatory — an
86
+ unfiled low is a dropped bug, and there is no human team to catch it later.
87
+ - **A re-review is earned only by a behavioural fix.** A docs-, comment-,
88
+ log-, or test-only fix commit does not earn another pass.
89
+ - **Two rounds is the cap.** Prefix every commit answering a review round
90
+ `review-fix:` `scripts/check-review-rounds.mjs` counts them and the
91
+ `review-rounds` check fails past two, unless a `review-cap-override` label
92
+ is on the PR.
93
+
94
+ If a finding is genuinely invalid, rebut it with evidence in writing; silence
95
+ is not a rebuttal.
96
+
97
+ When you do re-review, the verdict states per original finding: the finding
98
+ ID, what changed (`file:line` of the fix), whether it fully resolves the
99
+ finding (`RESOLVED` / `PARTIAL` / `REBUTTED` with evidence), and whether the
100
+ fix introduced anything new. A bare "fixed" is not a verdict.
101
+
102
+ ## 5. Non-obvious pipeline rules
103
+
104
+ - **CI is the full-suite authority.** Local scoped tests are a fast filter,
105
+ never the merge evidence. Never claim done off a local run alone.
106
+ - **A test that wouldn't fail on the bug it guards is not a test.** Assert the
107
+ observable outcome, not that the code path executed.
108
+ - **Prefer a deterministic mechanism over prompt discipline.** If a check, a
109
+ hook, a schema, or a lint gate can enforce the guarantee, write that instead
110
+ of a convention — conventions demonstrably fail here.
111
+ - **Never go dark, and never foreground-watch over 30 seconds.** Builds, CI
112
+ waits, and deploys run in the background with a notification. Batch related
113
+ findings into one substantive update rather than five fragments. Cap
114
+ parallel sub-agents at 15.
@@ -12,7 +12,9 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
12
12
 
13
13
  - A release = a `vX.Y.Z` **git tag** on `main` (the merge commit of the CHANGELOG PR).
14
14
  - The tag is the version source of truth (`scripts/build.mjs:resolveVersion()`). `package.json` `version` is a **stale placeholder by design** — never bump it in a commit (the `//version` comment + #2733 discipline). The uncommitted pack-time bump happens in CI now, not by hand.
15
- - Cutting the tag fires TWO workflows in parallel on tag push: `docker-images` (builds + pushes the 6 ghcr images) and `npm-publish` (builds, packs, verifies, publishes to npm). **Rollout is gated on BOTH going green.**
15
+ - Cutting the tag fires **two** workflows: `docker-images` (builds + pushes the 6 ghcr images) and `release` (the **orchestrator** — builds the four static binaries, attaches them, waits for `docker-images`, then calls `npm-publish`, then takes the GitHub Release out of draft).
16
+ - **`npm-publish.yml` no longer has a tag trigger (#3654).** It is reachable only via `workflow_call` from `release.yml` and via `workflow_dispatch`. npm is the one leg that cannot be undone, so it runs last and only once everything else is green. It also re-proves both preconditions from inside its own run, so a hand dispatch cannot bypass the ordering either.
17
+ - **The GitHub Release is created as a DRAFT and stays one until every leg is green.** `install.sh` resolves the version to install from `/releases/latest`, and that endpoint excludes drafts — so a half-finished release is invisible to `curl | sh` users and the previous complete release keeps serving them. This is not theoretical: v0.19.19 shipped published-with-zero-assets and broke the installer on every platform.
16
18
 
17
19
  ## Before you start — pre-flight (verify, don't assume)
18
20
 
@@ -28,30 +30,97 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
28
30
  - The release commit touches **CHANGELOG.md only**. Do NOT bump `package.json` (placeholder discipline).
29
31
  - Branch protection blocks direct push to `main`, so: create a `release/vX.Y.Z` branch, push it, open a `chore: release vX.Y.Z` PR (base `main`), arm auto-merge (squash, delete-branch) on green CI.
30
32
 
31
- ## Step 2 — Cut the tag (on the merge commit, not the PR branch)
33
+ ## Step 2 — Create the DRAFT release on a PINNED SHA, then push the tag
32
34
 
33
- Once the changelog PR is merged:
34
- - `git fetch origin && git checkout main && git pull --ff-only`
35
- - Tag the merge commit and create the GitHub Release:
36
- - `gh release create vX.Y.Z -R switchroom/switchroom --target main --title 'vX.Y.Z — <summary>' --notes-file <notes-file>`
35
+ Once the changelog PR is merged. **Order matters, and so does the pin.**
36
+
37
+ ### 2a Resolve and PIN the commit
38
+
39
+ ```bash
40
+ git fetch origin
41
+ SHA="$(git rev-parse origin/main)"
42
+ git --no-pager log -1 --oneline "$SHA" # show the operator exactly what is being released
43
+ ```
44
+
45
+ **Never pass `--target main`.** `main` is resolved server-side at the moment the API call lands, and agents merge PRs in parallel here — a PR that merged in the seconds between your pre-flight check and your `gh release create` would be silently swallowed into the release. Resolve `$SHA` once, confirm it is the commit you inspected in pre-flight, and use that literal SHA everywhere below.
46
+
47
+ ### 2b — Create the release as a DRAFT
48
+
49
+ ```bash
50
+ gh release create "vX.Y.Z" -R switchroom/switchroom \
51
+ --draft \
52
+ --target "$SHA" \
53
+ --title 'vX.Y.Z — <summary>' \
54
+ --notes-file <notes-file>
55
+ ```
56
+
57
+ - **`--draft` is mandatory.** A published release with no assets immediately becomes `/releases/latest` and 404s every `curl | sh` install for the entire ~25-minute build window. `release.yml` will forcibly re-draft an incomplete published release within about a minute, but do not rely on the safety net — it exists for the case where this step was done wrong.
58
+ - Creating the release object *before* the tag is what removes the race: `release.yml`'s `guard` job needs it to exist within ~150s of the tag push.
37
59
  - **Notes extraction gotcha (historical):** the naive `awk '/^## vX/,/^## v/' CHANGELOG` range collapses to a single line. Use a start-flag awk: `awk 'f{print} /^## vX\.Y\.Z/{print; f=1} f && /^## v/ && !/^## vX\.Y\.Z/{exit}'` — or extract the section to a temp file by line range.
38
- - **`gh release create` has been silently dropped in past runs.** After running it, verify: `gh release view vX.Y.Z` must return the release. If it didn't create, re-run.
60
+ - **`gh release create` has been silently dropped in past runs.** Verify it exists and is a draft:
61
+ ```bash
62
+ gh release view vX.Y.Z -R switchroom/switchroom --json tagName,isDraft
63
+ ```
64
+ `gh` resolves drafts by tag name (it falls back to scanning the release list). Note the raw REST `GET /releases/tags/{tag}` does **not** — it 404s on a draft. That difference is why the workflow scripts use the list endpoint; don't "fix" them to use the by-tag endpoint.
39
65
 
40
- ## Step 3 Wait for BOTH tag-push workflows (hard gates)
66
+ ### 2cPush the tag at that same pinned SHA
41
67
 
42
- The tag push triggers `docker-images` AND `npm-publish` in parallel. **Do not proceed to rollout until both are green AND verified.**
68
+ GitHub does not create the tag ref for a *draft* release (it creates it on publish), so the tag push below is what actually starts the pipeline. Confirm that before pushing, because if the ref already existed at `$SHA` the push would be a silent no-op and **no workflow would fire**:
43
69
 
44
- ### Gate A — npm publish (`npm-publish.yml`)
45
- - `gh run list --workflow=npm-publish.yml --limit 1` wait for it to reach `completed` / `success`.
46
- - Verify the publish is live: `npm view switchroom version` must return `X.Y.Z` (not the old version). Retry a few times — npm registry propagation can lag a few seconds.
47
- - If this workflow fails: **the release is not published.** Do NOT roll. Diagnose (NPM_TOKEN unset? npm 5xx? empty dist?). Re-run via `gh workflow run npm-publish.yml --ref vX.Y.Z` after fixing.
70
+ ```bash
71
+ git ls-remote --tags origin "refs/tags/vX.Y.Z" # expect NO output
72
+ git push origin "$SHA:refs/tags/vX.Y.Z"
73
+ ```
74
+
75
+ Pushing the SHA-to-ref form rather than `git tag && git push --tags` guarantees the tag lands on the commit you pinned in 2a, not on whatever your local `main` happens to be. (If the ref *does* already exist at a different commit, the push is rejected — that is the safe direction. Do not force it; work out why first.)
76
+
77
+ Then confirm both workflows actually started before you walk away:
78
+
79
+ ```bash
80
+ gh run list -R switchroom/switchroom --branch vX.Y.Z --limit 5
81
+ ```
48
82
 
49
- ### Gate B docker images (`docker-images.yml`)
83
+ You should see a `docker-images` run and a `release` run. If you see neither, the tag ref already existed and nothing fired.
84
+
85
+ ## Step 3 — Wait for the pipeline (two workflows, one of them orchestrated)
86
+
87
+ The tag push triggers `docker-images` and `release`. `release` internally waits for `docker-images`, then publishes to npm, then un-drafts the GitHub Release. **Do not proceed to rollout until both are green AND verified.**
88
+
89
+ ### Gate A — docker images (`docker-images.yml`)
50
90
  - `gh run list --workflow=docker-images.yml --limit 1` — wait for `completed` / `success`.
51
- - Verify all 6 images are published: `docker manifest inspect ghcr.io/switchroom/<image>:vX.Y.Z` for `agent`, `auth-broker`, `kernel`, `broker`, `web`, `hostd`. Each must resolve.
52
- - If any image is missing: do NOT roll — the rollout canary version-assert fails on an unpublished tag. Wait + re-check.
91
+ - Verify all 6 images are published. The repository name is `switchroom-<name>`, **not** `<name>` — `.github/workflows/docker-images.yml` builds `${REGISTRY}/${IMAGE_NAMESPACE}/switchroom-${{ matrix.image.name }}`, so `ghcr.io/switchroom/agent` does not exist and returns `manifest unknown`. Check each of:
92
+ ```bash
93
+ for n in agent auth-broker kernel broker web hostd; do
94
+ docker manifest inspect "ghcr.io/switchroom/switchroom-$n:vX.Y.Z" >/dev/null \
95
+ && echo "OK switchroom-$n" || echo "MISS switchroom-$n"
96
+ done
97
+ ```
98
+ Each must resolve.
99
+ - If any image is missing: do NOT roll — the rollout canary version-assert fails on an unpublished tag. Wait + re-check. `release` will block on this by itself, so a red image build means npm never publishes and the release never leaves draft. That is the design.
100
+
101
+ ### Gate B — the release pipeline (`release.yml`)
102
+ - `gh run list --workflow=release.yml --limit 1` — wait for `completed` / `success`. Expect ~25-30 minutes: four native build legs plus the wait on `docker-images`.
103
+ - Its jobs, in order: `guard` (release exists + held out of `latest`) → `build` ×4 → `bundle` → `publish` (attach) → `images-gate` (wait on docker-images) → `npm` → `finalize` (un-draft). A red job anywhere leaves the release a **draft** and npm **unpublished** — which is the correct, recoverable state.
104
+ - Verify the release page actually has assets **and is no longer a draft**:
105
+ ```bash
106
+ gh release view vX.Y.Z -R switchroom/switchroom --json isDraft,assets \
107
+ --jq '{isDraft, assets: [.assets[].name]}'
108
+ ```
109
+ `isDraft` must be `false`, and `assets` must list all four binaries (`switchroom-{linux,macos}-{amd64,arm64}`) **and** `switchroom-checksums.txt`. This is the gate that did not exist through v0.19.19 — every release up to then shipped **zero** assets and the advertised `curl | sh` installer (`install.sh`) was dead on every platform (#3633).
110
+ - Confirm the installer's own resolution path agrees:
111
+ ```bash
112
+ gh api repos/switchroom/switchroom/releases/latest --jq '{tag_name, assets: [.assets[].name]}'
113
+ ```
114
+ This is literally what `install.sh` calls. If it still reports the previous version, `finalize` did not run.
115
+
116
+ ### Gate C — npm publish
117
+ - npm is published by the `npm` job **inside** the `release` run, not by a separate workflow run. `gh run list --workflow=npm-publish.yml` will show nothing new for a normal release — that is expected, not a failure.
118
+ - Verify the publish is live: `npm view switchroom version` must return `X.Y.Z` (not the old version). Retry a few times — npm registry propagation can lag a few seconds.
119
+
120
+ **Only when Gates A, B AND C are green + verified** do you proceed.
53
121
 
54
- **Only when Gate A AND Gate B are green + verified** do you proceed.
122
+ ### Rehearsing a workflow change without releasing anything
123
+ `gh workflow run release.yml --ref <branch>` — `dry_run` defaults to `true`, so it builds, checksums and verifies the full bundle and attaches it as a workflow artifact, touching no GitHub Release, no npm, and no image tags. This is the only supported way to prove a change to the release pipeline before a real tag.
55
124
 
56
125
  ## Step 4 — Fleet rollout (operator-gated, canary-first)
57
126
 
@@ -63,14 +132,28 @@ The tag push triggers `docker-images` AND `npm-publish` in parallel. **Do not pr
63
132
 
64
133
  - **Never bump `package.json` `version` in a commit.** It's a stale placeholder; the tag is the source of truth and `npm-publish.yml` does the uncommitted pack-time bump.
65
134
  - **Never run `npm publish` by hand from the agent container.** You can't reach the operator's npm auth, and the workflow is the reliable path. If the workflow is broken, fix the workflow — don't side-step it.
66
- - **Never roll the fleet before Gate A (npm) AND Gate B (images) are both green + verified.** A release that's on the fleet but not on npm is the exact regression this skill exists to prevent.
135
+ - **Never create the GitHub Release without `--draft`,** and **never `gh release create --target main`.** Pin the SHA (step 2a).
136
+ - **Never take the release out of draft by hand** while the pipeline is still running. `finalize` is the only thing that should publish it; un-drafting early puts an incomplete release on `/releases/latest` and breaks every installer.
137
+ - **Never roll the fleet before Gates A, B AND C are green + verified.** A release that's on the fleet but not on npm is the exact regression this skill exists to prevent.
67
138
  - **Never push directly to `main`.** The CHANGELOG PR goes through auto-merge on green.
68
139
  - **Never force-push `main` or bypass hooks (`--no-verify`).**
69
140
 
70
141
  ## If something goes wrong
71
142
 
72
- - **npm-publish failed but the tag is already pushed:** fix + `gh workflow run npm-publish.yml --ref vX.Y.Z`. Do NOT roll until it's green + `npm view` confirms.
73
- - **Images failed but npm succeeded:** the npm package is live but the fleet can't roll yet. Fix the image workflow / re-run. (npm being ahead of images is fine — the CLI is published for npm consumers; the fleet waits on images.)
143
+ **The single recovery command for almost everything is a re-dispatch of the orchestrator:**
144
+
145
+ ```bash
146
+ gh workflow run release.yml -R switchroom/switchroom --ref vX.Y.Z -f dry_run=false
147
+ ```
148
+
149
+ It re-runs every leg — including `finalize`, which is what actually takes the release out of draft. Re-running one leg on its own generally leaves the release stuck as a draft.
150
+
151
+ **But a re-dispatch cannot fix a defect that lives in the tag's own tree.** `workflow_dispatch` executes the workflow YAML **at the dispatched ref**, so `--ref vX.Y.Z` re-runs the same broken file and fails identically, every time. This is not theoretical — it is how v0.19.20 died (#3691). Before reaching for the re-dispatch, root-cause the failure in the workflow source **at that tag** (`git show vX.Y.Z:.github/workflows/release.yml`) and compare it to `main`. If the bug is in the tag's tree, the only path is a fix merged to `main` plus a **fresh tag**; abandon the old tag as a permanent draft (do not delete it, do not hand-publish it) and burn a patch version. Re-dispatch is for *transient* failures — a flaky runner, an npm 5xx, a `docker-images` run that has since gone green.
152
+
153
+ - **`release` failed at `guard` ("no GitHub Release exists"):** step 2b was skipped or the tag name is misspelled. Create the draft release, then re-dispatch as above.
154
+ - **`release` failed at `images-gate`:** `docker-images` was not green for this tag+commit. Fix it, `gh workflow run docker-images.yml --ref vX.Y.Z`, wait for green, then re-dispatch `release.yml`. Nothing was published to npm and the release is still a draft — nothing to undo.
155
+ - **`release` failed at `npm` (e.g. a transient npm 5xx):** re-dispatch `release.yml` as above. `npm-publish` treats "already published" as success, so a re-run is safe and idempotent. Dispatching `npm-publish.yml --ref vX.Y.Z` directly also works and its own gates still apply, but it will NOT un-draft the release, so you would then have to re-dispatch `release.yml` anyway.
156
+ - **Everything is green but the release is still a draft:** `finalize` did not run. Check `gh run view <run-id>` for a skipped job, then re-dispatch. Do not hand-publish — `finalize` re-verifies the asset set immediately before flipping the flag.
74
157
  - **Rollout started before publish verified (the old bug):** abort the rollout, publish, then re-roll. Do not let a half-published release sit on the fleet.
75
158
 
76
159
  ## Operator one-time setup (tell them once, not every release)
@@ -0,0 +1,10 @@
1
+ # bun test configuration for runs whose CWD is telegram-plugin/ — CI's
2
+ # `bun-test-run` job (scripts/bun-test-ci.sh) and `bun run --cwd telegram-plugin`.
3
+ #
4
+ # bun reads the bunfig.toml in its CWD only, so this file exists purely to load
5
+ # the same state-dir hermeticity preload as the repo-root bunfig.toml. Keep the
6
+ # two in sync; `npm run lint:agent-state-dir-hermeticity` fails if either stops
7
+ # loading the guard. Rationale for the guard itself lives in
8
+ # tests/vitest-setup/agent-state-dir-guard.mjs.
9
+ [test]
10
+ preload = ["../tests/vitest-setup/agent-state-dir-guard.mjs"]
@@ -38,6 +38,69 @@ import { normalizeDashes } from './text-voice-scrub.js'
38
38
  */
39
39
  export { escapeMarkdown, codeSpanSafe }
40
40
 
41
+ /**
42
+ * Separator appended to every card line that is followed by a hard break when
43
+ * `stackCardLines` runs in `collapseSafe` mode (#3666).
44
+ *
45
+ * It is a NO-BREAK SPACE (U+00A0), chosen deliberately:
46
+ *
47
+ * - It is a real character in the message text, so it survives Telegram's
48
+ * pinned-bar collapse (which drops the `\n` and substitutes nothing) and
49
+ * keeps the two lines' glyphs apart in the one-line preview.
50
+ * - It is NOT ASCII whitespace, so neither this module's own
51
+ * trailing-whitespace strip nor the GFM parser's hard-break handling
52
+ * (which consumes only the ASCII spaces immediately before the newline)
53
+ * can eat it.
54
+ * - It is invisible where the card is normally read — the chat feed — since
55
+ * it lands at end-of-line. That is the whole reason a separator with
56
+ * visible ink (`' ·'`, `' —'`) was rejected: it would put dangling
57
+ * punctuation on every line of every pinned card to fix a defect that only
58
+ * manifests on the pin bar.
59
+ *
60
+ * If a real-pin eyeball shows a single space is too weak a break, this constant
61
+ * is the one place to strengthen it (e.g. `' ·'`) — the seam is deliberate.
62
+ */
63
+ export const COLLAPSE_SAFE_SEPARATOR = '\u00A0'
64
+
65
+ /**
66
+ * Normalise a line's tail before `stackCardLines` re-terminates it.
67
+ *
68
+ * Two strips, both there for the SAME reason: this function must be idempotent,
69
+ * so re-stacking an already-stacked body can never accumulate terminator
70
+ * characters.
71
+ *
72
+ * - trailing ASCII whitespace, so exactly one ` \n` hard break is emitted;
73
+ * - in `collapseSafe` mode, any `COLLAPSE_SAFE_SEPARATOR` run the line
74
+ * already carries (interleaved with ASCII whitespace, since a caller may
75
+ * have split the body on `\n` rather than on ` \n`), so exactly one
76
+ * separator is emitted.
77
+ *
78
+ * The separator strip is written against the CONSTANT, not against a hardcoded
79
+ * U+00A0, so it stays correct if the separator is ever strengthened to visible
80
+ * ink. That one-constant seam is what COLLAPSE_SAFE_SEPARATOR's own doc comment
81
+ * promises, and it would be a lie if this strip did not follow it.
82
+ */
83
+ function normalizeLineTail(line: string, collapseSafe: boolean): string {
84
+ let out = line.replace(/[ \t\r]+$/, '')
85
+ // The length guard is not defensive noise: `''.endsWith('')` is true and
86
+ // `''.slice(0, -0)` is `''`, so an empty separator would spin this loop
87
+ // forever — a hang in the gateway's render path. The doc comment above the
88
+ // constant invites future edits to it, so make that edit unable to hang.
89
+ if (!collapseSafe || COLLAPSE_SAFE_SEPARATOR.length === 0) return out
90
+ while (out.endsWith(COLLAPSE_SAFE_SEPARATOR)) {
91
+ out = out.slice(0, -COLLAPSE_SAFE_SEPARATOR.length).replace(/[ \t\r]+$/, '')
92
+ }
93
+ return out
94
+ }
95
+
96
+ /** Options for `stackCardLines`. */
97
+ export interface StackCardLinesOpts {
98
+ /** Append `COLLAPSE_SAFE_SEPARATOR` to each hard-broken line so the stack
99
+ * stays readable on a surface that collapses it to one line (#3666).
100
+ * Opt-in: only pinned cards pay the (tiny) extra character per line. */
101
+ collapseSafe?: boolean
102
+ }
103
+
41
104
  /**
42
105
  * Join a card's pre-rendered, single-line entries so they STACK in the
43
106
  * Bot API 10.1 rich-message renderer (#2669) — the same visual result the
@@ -63,8 +126,19 @@ export { escapeMarkdown, codeSpanSafe }
63
126
  * This is the card-surface analogue of the reply path's
64
127
  * `normalizeParagraphBreaks`: it guarantees a card authored as stacked
65
128
  * bullet/step lines renders identically to a normal reply.
129
+ *
130
+ * `opts.collapseSafe` (#3666) additionally makes the stack survive being
131
+ * rendered on a surface that COLLAPSES the message to one line — Telegram's
132
+ * pinned-message bar drops the newlines and substitutes nothing, mashing the
133
+ * last glyph of each line into the first glyph of the next
134
+ * (`… · opus 5✓ Reading gateway.ts→ Running search …`). Opt-in, because it is
135
+ * only correct for cards that are actually pinned; see COLLAPSE_SAFE_SEPARATOR.
66
136
  */
67
- export function stackCardLines(lines: string[]): string {
137
+ export function stackCardLines(
138
+ lines: string[],
139
+ opts?: StackCardLinesOpts,
140
+ ): string {
141
+ const collapseSafe = opts?.collapseSafe === true
68
142
  const pieces: string[] = []
69
143
  for (let i = 0; i < lines.length; i++) {
70
144
  const line = lines[i]
@@ -75,12 +149,27 @@ export function stackCardLines(lines: string[]): string {
75
149
  // A blank current or next line is a genuine `\n\n` paragraph gap — leave
76
150
  // the separator a plain newline so the blank entry reconstructs the gap.
77
151
  if (cur === '' || next === '') {
152
+ // The gap still needs a collapse separator when THIS line has content:
153
+ // a collapsing surface drops the `\n\n` with no substitute exactly as it
154
+ // drops a ` \n`, and the blank entry is the empty string, so it
155
+ // contributes NO character of its own to hold the two sides apart. Only
156
+ // when `cur` is blank is the separator skipped — appending it to a blank
157
+ // entry would turn the gap into a visible U+00A0 paragraph in the FEED.
158
+ if (collapseSafe && cur !== '') {
159
+ pieces[pieces.length - 1] = normalizeLineTail(line, true) + COLLAPSE_SAFE_SEPARATOR
160
+ }
78
161
  pieces.push('\n')
79
162
  continue
80
163
  }
81
164
  // Strip any trailing whitespace the line already carried so we emit
82
- // exactly one ` \n` hard break (never accumulate spaces on a re-run).
83
- pieces[pieces.length - 1] = line.replace(/[ \t\r]+$/, '')
165
+ // exactly one ` \n` hard break (never accumulate spaces on a re-run) and,
166
+ // under collapseSafe, exactly one separator — see normalizeLineTail.
167
+ // The collapse separator is appended AFTER the strip (it is not ASCII
168
+ // whitespace, so it survives both this strip and the markdown parser's
169
+ // own trailing-whitespace handling) and BEFORE the two hard-break spaces,
170
+ // which stay immediately adjacent to the `\n` so the break still parses.
171
+ pieces[pieces.length - 1] =
172
+ normalizeLineTail(line, collapseSafe) + (collapseSafe ? COLLAPSE_SAFE_SEPARATOR : '')
84
173
  pieces.push(' \n')
85
174
  }
86
175
  return pieces.join('')