switchroom 0.21.10 → 0.21.12

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 (40) hide show
  1. package/dist/cli/switchroom.js +143 -27
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +5 -2
  4. package/skills/dev-protocol/SKILL.md +14 -10
  5. package/skills/switchroom-release/SKILL.md +11 -5
  6. package/telegram-plugin/dist/gateway/gateway.js +385 -35
  7. package/telegram-plugin/format.ts +56 -0
  8. package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
  9. package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
  10. package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
  11. package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
  12. package/telegram-plugin/gateway/gateway.ts +61 -68
  13. package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
  14. package/telegram-plugin/gateway/outbound-send-path.ts +55 -1
  15. package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
  16. package/telegram-plugin/gateway/reply-route-log.ts +118 -0
  17. package/telegram-plugin/gateway/stream-render.ts +1 -1
  18. package/telegram-plugin/history.ts +21 -0
  19. package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
  20. package/telegram-plugin/render/html-fold.ts +22 -4
  21. package/telegram-plugin/render/parse.ts +9 -1
  22. package/telegram-plugin/render/render.ts +14 -13
  23. package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
  24. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
  25. package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
  26. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
  27. package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
  28. package/telegram-plugin/tests/history.test.ts +78 -0
  29. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
  30. package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
  31. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +76 -3
  32. package/telegram-plugin/tests/send-reply-golden.test.ts +192 -3
  33. package/telegram-plugin/tests/status-pin.test.ts +2 -2
  34. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
  35. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
  36. package/telegram-plugin/tests/telegram-format.test.ts +52 -0
  37. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
  38. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
  39. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
  40. package/telegram-plugin/worker-activity-feed.ts +2 -2
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.21.10", COMMIT_SHA = "fd1650e7", COMMIT_DATE = "2026-08-14T03:50:00Z";
2123
+ var VERSION = "0.21.12", COMMIT_SHA = "66041822", COMMIT_DATE = "2026-08-14T20:57:00Z";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -84729,6 +84729,45 @@ var init_server4 = __esm(() => {
84729
84729
  var ROUTE_FIELD_SHIP_TS = 1785456000;
84730
84730
 
84731
84731
  // src/fleet-health/detect.ts
84732
+ function emptyGwHits() {
84733
+ const out = {};
84734
+ for (const name of GATEWAY_SIGNAL_NAMES)
84735
+ out[name] = 0;
84736
+ return out;
84737
+ }
84738
+ function orphanedDbAlarmLanes(alarmLine) {
84739
+ const names = [];
84740
+ for (const m of alarmLine.matchAll(ORPHANED_DB_ALARM_TARGET_RE)) {
84741
+ const target = m[1];
84742
+ names.push(target.slice(target.lastIndexOf("/") + 1));
84743
+ }
84744
+ if (names.length === 0)
84745
+ return null;
84746
+ const declared = ORPHANED_DB_ALARM_COUNT_RE.exec(alarmLine)?.[1];
84747
+ if (declared === undefined || names.length !== Number(declared))
84748
+ return null;
84749
+ return names;
84750
+ }
84751
+ function classifyOrphanedDbTick(lines, alarmIdx) {
84752
+ const lanes = orphanedDbAlarmLanes(lines[alarmIdx] ?? "");
84753
+ if (lanes === null || !lanes.every((n) => n.startsWith("history.db"))) {
84754
+ return "unrecovered";
84755
+ }
84756
+ let verdict = null;
84757
+ for (let j = alarmIdx + 1;j < lines.length; j++) {
84758
+ const line = lines[j];
84759
+ if (!line || !ORPHANED_DB_SWEEP_LINE_RE.test(line))
84760
+ continue;
84761
+ if (GATEWAY_SIGNATURES["orphaned-db-handle"].test(line))
84762
+ break;
84763
+ if (ORPHANED_DB_LANE_VETO_RE.test(line))
84764
+ return "unrecovered";
84765
+ verdict ??= ORPHANED_DB_RECOVERED_RE.test(line) ? "recovered" : "unrecovered";
84766
+ if (verdict === "unrecovered")
84767
+ return "unrecovered";
84768
+ }
84769
+ return verdict ?? "unrecovered";
84770
+ }
84732
84771
  function parseTurns(text) {
84733
84772
  const out = [];
84734
84773
  for (const raw of text.split(`
@@ -84832,27 +84871,39 @@ function detectTurnFindings(agent, turns, opts = {}) {
84832
84871
  }
84833
84872
  function detectGatewayFindings(agent, logText, logName = `logs/${agent}/gateway-supervisor.log`) {
84834
84873
  const findings = [];
84835
- const gw_hits = {
84836
- "duplicate-delivery-represent": 0,
84837
- "represent-escalation": 0,
84838
- "reply-delivery-failure": 0,
84839
- "orphaned-db-handle": 0
84840
- };
84874
+ const gw_hits = emptyGwHits();
84841
84875
  const lines = logText.split(`
84842
84876
  `);
84877
+ const eventIndex = new Map;
84843
84878
  for (const [name, re] of Object.entries(GATEWAY_SIGNATURES)) {
84844
84879
  for (let i2 = 0;i2 < lines.length; i2++) {
84845
84880
  const line = lines[i2];
84846
84881
  if (!line)
84847
84882
  continue;
84848
84883
  if (re.test(line)) {
84849
- gw_hits[name] += 1;
84884
+ const signal = name === "orphaned-db-handle" && classifyOrphanedDbTick(lines, i2) === "recovered" ? "orphaned-db-handle-recovered" : name;
84885
+ gw_hits[signal] += 1;
84886
+ const origin = extractTurnId(line);
84887
+ const pointer = `${logName}:${i2 + 1}`;
84888
+ const ts = extractTs(line);
84889
+ if (origin !== null) {
84890
+ const eventKey = `${signal}|${origin}`;
84891
+ const at = eventIndex.get(eventKey);
84892
+ if (at !== undefined) {
84893
+ const prev = findings[at];
84894
+ if (ts !== null || prev.ts === null) {
84895
+ findings[at] = { ...prev, log_pointer: pointer, ts };
84896
+ }
84897
+ continue;
84898
+ }
84899
+ eventIndex.set(eventKey, findings.length);
84900
+ }
84850
84901
  findings.push({
84851
- signal: name,
84902
+ signal,
84852
84903
  agent,
84853
- turn_id: extractTurnId(line) ?? `${agent}:gw#${i2 + 1}`,
84854
- log_pointer: `${logName}:${i2 + 1}`,
84855
- ts: extractTs(line)
84904
+ turn_id: origin ?? `${agent}:gw#${i2 + 1}`,
84905
+ log_pointer: pointer,
84906
+ ts
84856
84907
  });
84857
84908
  }
84858
84909
  }
@@ -84891,14 +84942,27 @@ function scanAgent(agent, turnsText, gatewayText, opts = {}) {
84891
84942
  escalate
84892
84943
  };
84893
84944
  }
84894
- var HANG_MS = 360000, HANG_MAXTOOLS = 2, SILENT_NOOP_FLOOR_TS = 1783900800, GATEWAY_SIGNATURES, ISO_TS_RE;
84945
+ var HANG_MS = 360000, HANG_MAXTOOLS = 2, SILENT_NOOP_FLOOR_TS = 1783900800, GATEWAY_SIGNATURES, GATEWAY_SIGNAL_MEMBERS, GATEWAY_SIGNAL_NAMES, ORPHANED_DB_RECOVERED_RE, ORPHANED_DB_SWEEP_LINE_RE, ORPHANED_DB_ALARM_TARGET_RE, ORPHANED_DB_ALARM_COUNT_RE, ORPHANED_DB_LANE_VETO_RE, ISO_TS_RE;
84895
84946
  var init_detect = __esm(() => {
84896
84947
  GATEWAY_SIGNATURES = {
84897
84948
  "duplicate-delivery-represent": /represent duplicate-send/,
84898
- "represent-escalation": /obligation escalation/,
84949
+ "represent-escalation": /obligation escalation (?:delivered \+ closed|PERMANENTLY undeliverable)/,
84899
84950
  "reply-delivery-failure": /tg-post method=sendRichMessage[^\n]*status=err(?![a-z])/,
84900
84951
  "orphaned-db-handle": /orphaned-db-sweep DETECTED \d+ deleted-inode DB handle/
84901
84952
  };
84953
+ GATEWAY_SIGNAL_MEMBERS = {
84954
+ "duplicate-delivery-represent": true,
84955
+ "represent-escalation": true,
84956
+ "reply-delivery-failure": true,
84957
+ "orphaned-db-handle": true,
84958
+ "orphaned-db-handle-recovered": true
84959
+ };
84960
+ GATEWAY_SIGNAL_NAMES = Object.keys(GATEWAY_SIGNAL_MEMBERS);
84961
+ ORPHANED_DB_RECOVERED_RE = /orphaned-db-sweep reopened history\.db \u2014 writes are durable again/;
84962
+ ORPHANED_DB_SWEEP_LINE_RE = /orphaned-db-sweep /;
84963
+ ORPHANED_DB_ALARM_TARGET_RE = /\bfd=\d+ (\S+)/g;
84964
+ ORPHANED_DB_ALARM_COUNT_RE = /DETECTED (\d+) deleted-inode DB handle/;
84965
+ ORPHANED_DB_LANE_VETO_RE = /orphaned-db-sweep found (?:an orphaned registry\.db handle|orphaned handle\(s\) on )/;
84902
84966
  ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?/;
84903
84967
  });
84904
84968
 
@@ -84906,6 +84970,12 @@ var init_detect = __esm(() => {
84906
84970
  function mapSignal(signal) {
84907
84971
  return SIGNAL_MAP[signal];
84908
84972
  }
84973
+ function countingUnitFor(signal) {
84974
+ return GATEWAY_SIGNAL_SET.has(signal) ? "gateway-event" : "log-line";
84975
+ }
84976
+ function siblingDedupKeys(key) {
84977
+ return SIBLING_DEDUP_KEYS.get(key) ?? [];
84978
+ }
84909
84979
  function dedupKeyFor(finding) {
84910
84980
  const m = mapSignal(finding.signal);
84911
84981
  return `${m.job_spec}:${m.signature}`;
@@ -84935,8 +85005,9 @@ function reachFactor(reachCount) {
84935
85005
  function issuePriority(severity, frequency, reachCount, newestIso, now2 = new Date, windowDays = 30) {
84936
85006
  return severity * frequencyFactor(frequency) * reachFactor(reachCount) * recencyFactor(newestIso, now2, windowDays);
84937
85007
  }
84938
- var SIGNAL_MAP, ALL_JOB_SPECS;
85008
+ var SIGNAL_MAP, ALL_JOB_SPECS, GATEWAY_SIGNAL_SET, LEGACY_COUNTING_UNIT = "log-line", RECLASSIFICATION_GROUPS, SIBLING_DEDUP_KEYS;
84939
85009
  var init_mapping = __esm(() => {
85010
+ init_detect();
84940
85011
  SIGNAL_MAP = {
84941
85012
  "silent-no-op-candidate": {
84942
85013
  failure_mode: "silent-no-op",
@@ -84968,6 +85039,12 @@ var init_mapping = __esm(() => {
84968
85039
  job_spec: "survive-reboots-and-real-life",
84969
85040
  signature: "orphaned-db-handle:deleted-inode-writes"
84970
85041
  },
85042
+ "orphaned-db-handle-recovered": {
85043
+ failure_mode: "drift",
85044
+ severity: 1,
85045
+ job_spec: "survive-reboots-and-real-life",
85046
+ signature: "orphaned-db-handle:recovered-in-tick"
85047
+ },
84971
85048
  "hang-long-stalled": {
84972
85049
  failure_mode: "partial",
84973
85050
  severity: 2,
@@ -85048,6 +85125,23 @@ var init_mapping = __esm(() => {
85048
85125
  "talk-to-agents-from-anywhere",
85049
85126
  "track-plan-quota-live"
85050
85127
  ];
85128
+ GATEWAY_SIGNAL_SET = new Set(GATEWAY_SIGNAL_NAMES);
85129
+ RECLASSIFICATION_GROUPS = [
85130
+ ["orphaned-db-handle", "orphaned-db-handle-recovered"],
85131
+ ["silent-no-op-candidate", "flush-recovered-turn"]
85132
+ ];
85133
+ SIBLING_DEDUP_KEYS = (() => {
85134
+ const m = new Map;
85135
+ for (const group of RECLASSIFICATION_GROUPS) {
85136
+ const keys = group.map((s) => {
85137
+ const map2 = mapSignal(s);
85138
+ return `${map2.job_spec}:${map2.signature}`;
85139
+ });
85140
+ for (const key of keys)
85141
+ m.set(key, keys.filter((k) => k !== key));
85142
+ }
85143
+ return m;
85144
+ })();
85051
85145
  });
85052
85146
 
85053
85147
  // src/fleet-health/ledger.ts
@@ -85063,7 +85157,8 @@ function indexPriorIssues(prior) {
85063
85157
  status: iss.status,
85064
85158
  job_spec: rec.job_spec,
85065
85159
  failure_mode: iss.failure_mode,
85066
- severity: iss.severity
85160
+ severity: iss.severity,
85161
+ counting_unit: iss.counting_unit ?? LEGACY_COUNTING_UNIT
85067
85162
  });
85068
85163
  }
85069
85164
  }
@@ -85109,7 +85204,8 @@ function buildLedger(findings, opts = {}) {
85109
85204
  occurrences: [],
85110
85205
  reach: new Set,
85111
85206
  newest: null,
85112
- count: 0
85207
+ count: 0,
85208
+ counting_unit: countingUnitFor(f.signal)
85113
85209
  };
85114
85210
  aggs.set(key, agg);
85115
85211
  }
@@ -85128,12 +85224,16 @@ function buildLedger(findings, opts = {}) {
85128
85224
  for (const agg of aggs.values()) {
85129
85225
  const count = agg.count;
85130
85226
  const prior = priorIdx.get(agg.dedup_key);
85227
+ const unitChanged = prior !== undefined && prior.counting_unit !== agg.counting_unit;
85131
85228
  let status = "open";
85132
- if (count <= RESOLVED_THRESHOLD && prior && prior.frequency > RESOLVED_THRESHOLD) {
85133
- status = "resolved-pending-verify";
85229
+ if (unitChanged) {
85230
+ status = count <= RESOLVED_THRESHOLD && prior.status === "resolved-pending-verify" ? "resolved-pending-verify" : "open";
85134
85231
  } else if (prior?.status === "resolved-pending-verify" && count <= RESOLVED_THRESHOLD) {
85135
85232
  status = "closed";
85233
+ } else if (prior && count <= RESOLVED_THRESHOLD && count < prior.frequency) {
85234
+ status = "resolved-pending-verify";
85136
85235
  }
85236
+ const reopened = prior?.status === "closed" && status !== "closed";
85137
85237
  const issue2 = {
85138
85238
  dedup_key: agg.dedup_key,
85139
85239
  failure_mode: agg.failure_mode,
@@ -85143,7 +85243,9 @@ function buildLedger(findings, opts = {}) {
85143
85243
  recency: agg.newest,
85144
85244
  occurrences: agg.occurrences,
85145
85245
  ...prior?.gh_issue !== undefined ? { gh_issue: prior.gh_issue } : {},
85146
- status
85246
+ status,
85247
+ ...reopened ? { reopened: true } : {},
85248
+ counting_unit: agg.counting_unit
85147
85249
  };
85148
85250
  const list2 = byJob.get(agg.job_spec) ?? [];
85149
85251
  list2.push(issue2);
@@ -85155,6 +85257,7 @@ function buildLedger(findings, opts = {}) {
85155
85257
  if (prior.status !== "open" && prior.status !== "resolved-pending-verify") {
85156
85258
  continue;
85157
85259
  }
85260
+ const migratedTo = siblingDedupKeys(dedup_key).filter((k) => aggs.has(k));
85158
85261
  const issue2 = {
85159
85262
  dedup_key,
85160
85263
  failure_mode: prior.failure_mode,
@@ -85164,7 +85267,10 @@ function buildLedger(findings, opts = {}) {
85164
85267
  recency: null,
85165
85268
  occurrences: [],
85166
85269
  ...prior.gh_issue !== undefined ? { gh_issue: prior.gh_issue } : {},
85167
- status: "closed"
85270
+ status: "closed",
85271
+ close_reason: migratedTo.length > 0 ? "reclassified" : "count-drop",
85272
+ ...migratedTo.length > 0 ? { reclassified_into: migratedTo.sort() } : {},
85273
+ counting_unit: prior.counting_unit
85168
85274
  };
85169
85275
  const list2 = byJob.get(prior.job_spec) ?? [];
85170
85276
  list2.push(issue2);
@@ -129717,19 +129823,29 @@ function syncIssue(deps, repo, job_spec, iss) {
129717
129823
  if (!upd.ok) {
129718
129824
  deps.log(`fleet-health: gh issue edit #${num3} failed: ${upd.stderr}`);
129719
129825
  }
129720
- if (iss.status === "closed") {
129721
- const c = deps.run([
129826
+ if (iss.reopened) {
129827
+ const r = deps.run([
129722
129828
  "issue",
129723
- "close",
129829
+ "reopen",
129724
129830
  num3,
129725
129831
  "-R",
129726
129832
  repo,
129727
129833
  "--comment",
129728
- `Verified count-drop (frequency ${iss.frequency} \u2264 resolved threshold). Closed by the Fleet Health sensor.`
129834
+ `Reopened by the Fleet Health sensor: this defect is back (frequency ${iss.frequency}` + ` in the current scan window). The earlier close was not a durable fix.`
129729
129835
  ]);
129730
- if (c.ok)
129731
- deps.log(`fleet-health: closed GH #${num3} (count-drop) for ${iss.dedup_key}`);
129836
+ if (r.ok)
129837
+ deps.log(`fleet-health: reopened GH #${num3} for ${iss.dedup_key}`);
129732
129838
  else
129839
+ deps.log(`fleet-health: gh issue reopen #${num3} failed: ${r.stderr}`);
129840
+ return;
129841
+ }
129842
+ if (iss.status === "closed") {
129843
+ const reclassified = iss.close_reason === "reclassified";
129844
+ const comment = reclassified ? `No occurrences in the current scan window because these findings were` + ` RECLASSIFIED into ${(iss.reclassified_into ?? []).join(", ")} \u2014 the same` + ` detected condition re-sorted by its outcome. This is NOT a verified fix;` + ` the evidence now lives on the sibling issue. Closed by the Fleet Health sensor.` : `Verified count-drop (frequency ${iss.frequency} \u2264 resolved threshold).` + ` Closed by the Fleet Health sensor.`;
129845
+ const c = deps.run(["issue", "close", num3, "-R", repo, "--comment", comment]);
129846
+ if (c.ok) {
129847
+ deps.log(`fleet-health: closed GH #${num3} (${reclassified ? "reclassified" : "count-drop"})` + ` for ${iss.dedup_key}`);
129848
+ } else
129733
129849
  deps.log(`fleet-health: gh issue close #${num3} failed: ${c.stderr}`);
129734
129850
  }
129735
129851
  }
@@ -21565,7 +21565,7 @@ function allocateAgentUid(name) {
21565
21565
  }
21566
21566
 
21567
21567
  // src/build-info.ts
21568
- var VERSION = "0.21.10";
21568
+ var VERSION = "0.21.12";
21569
21569
 
21570
21570
  // src/setup/hindsight-recall-passthrough.ts
21571
21571
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.21.10",
4
+ "version": "0.21.12",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -28,9 +28,10 @@
28
28
  "test:vitest": "vitest run",
29
29
  "test:bun": "bun test telegram-plugin/tests/hermes-messages-paging.test.ts telegram-plugin/tests/hermes-session-search.test.ts telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/hindsight-bank-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-scope-persist.test.ts src/vault/broker/server-tokenless-scope.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/self-approval-bypass.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/progress-fallback-cap.test.ts telegram-plugin/tests/progress-cap.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/worker-origin-gap-dispatch.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
30
30
  "test:watch": "vitest",
31
- "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-parked-turn-start-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-ctx-send-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-claude-cli-lockstep.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
31
+ "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-parked-turn-start-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-ctx-send-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-claude-cli-lockstep.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && node scripts/check-mutation-coverage.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
32
32
  "lint:tsc": "tsc --noEmit",
33
33
  "lint:hindsight-write-redaction": "node scripts/check-hindsight-write-redaction.mjs",
34
+ "lint:mutation-coverage": "node scripts/check-mutation-coverage.mjs --all",
34
35
  "lint:secret-pattern-parity": "bun scripts/check-secret-pattern-parity.ts",
35
36
  "lint:plugin-references": "node scripts/check-plugin-references.mjs",
36
37
  "lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
@@ -56,6 +57,7 @@
56
57
  "lint:claude-cli-lockstep": "node scripts/check-claude-cli-lockstep.mjs",
57
58
  "lint:changelog-entry": "node scripts/check-changelog-entry.mjs",
58
59
  "changelog:generate": "node scripts/gen-changelog-entry.mjs",
60
+ "changelog:cut": "node scripts/cut-changelog-release.mjs",
59
61
  "lint:agent-attribution-trailers": "node scripts/check-agent-attribution-trailers.mjs",
60
62
  "lint:hostd-template-guard": "bun scripts/check-hostd-template-guard.ts",
61
63
  "lint:bench-baseline-anonymised": "node scripts/check-bench-baseline-anonymised.mjs",
@@ -82,6 +84,7 @@
82
84
  "@types/bun": "^1.3.11",
83
85
  "@types/node": "^22.0.0",
84
86
  "@vitest/coverage-v8": "3.2.4",
87
+ "commonmark": "0.31.2",
85
88
  "typescript": "^5.7.0",
86
89
  "vitest": "^3.2.4"
87
90
  },
@@ -139,17 +139,21 @@ is not a rebuttal.
139
139
  `AWAITING_CHECKS` means a required workflow is not listening for
140
140
  `merge_group`; `.github/MERGE-QUEUE.md` owns that failure mode and the
141
141
  invariants that prevent it.
142
- - **Stage the CHANGELOG entry author-side, before `gh pr create`.** A PR that
143
- changes shippable code must add a `## Unreleased` entry (#4469's
142
+ - **Stage the changelog note author-side, before `gh pr create`.** A PR that
143
+ changes shippable code must add a NEW `changelog.d/` fragment file (#4469's
144
144
  `check-changelog-entry.mjs`, part of `npm run lint`, enforces it). Don't
145
- hand-write it: run `bun run changelog:generate` in the repo — it derives the
146
- entry from your conventional-commit title, appends it under `## Unreleased`
147
- (idempotently — a second run is a no-op), and respects the escape hatches. Then
148
- commit CHANGELOG.md so it lands in your own push. It runs AUTHOR-side, not in
149
- CI, because a CI commit-back with the default `GITHUB_TOKEN` cannot re-trigger
150
- the required checks and would wedge the PR (`.github/MERGE-QUEUE.md` §
151
- "Author-side changelog generation"). Opt a docs/chore/test PR out with the
152
- `no-changelog` label or a `[skip changelog]` token on its own line.
145
+ hand-edit `CHANGELOG.md`: run `bun run changelog:generate` in the repo — it
146
+ derives a per-PR fragment (`changelog.d/<pr>-<slug>.<type>.md`) from your
147
+ conventional-commit title (idempotently — a second run is a no-op) and
148
+ respects the escape hatches. Then commit the fragment so it lands in your
149
+ own push. Fragment files are per-PR, so they never conflict with another
150
+ in-flight PR editing the shared `## Unreleased` section does, which is
151
+ exactly the conflict class fragments replace (see `changelog.d/README.md`).
152
+ It runs AUTHOR-side, not in CI, because a CI commit-back with the default
153
+ `GITHUB_TOKEN` cannot re-trigger the required checks and would wedge the PR
154
+ (`.github/MERGE-QUEUE.md` § "Author-side changelog generation"). Opt a
155
+ docs/chore/test PR out with the `no-changelog` label or a `[skip changelog]`
156
+ token on its own line.
153
157
  - **A test that wouldn't fail on the bug it guards is not a test.** Assert the
154
158
  observable outcome, not that the code path executed.
155
159
  - **Prefer a deterministic mechanism over prompt discipline.** If a check, a
@@ -22,14 +22,20 @@ Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a
22
22
  1. `git fetch origin`, confirm `main` is at the commit you want released.
23
23
  2. `gh pr list --state open` — confirm no PR meant for this release is still open. Ask the operator if unsure.
24
24
  3. Confirm CI on `main` is green (`gh run list --branch main --limit 3`).
25
- 4. Read `CHANGELOG.md`the `## Unreleased` section is the **continuously-maintained** staging area for this release's notes. Every PR since the last release stages its own entry there as it merges, enforced by `scripts/check-changelog-entry.mjs` (part of `npm run lint`), so by release time this section should already read as a near-complete draft — you tidy and summarise, you do NOT author it from scratch. **An empty (or missing) `## Unreleased` at release time is now an ANOMALY, not the normal state:** it means either there genuinely is nothing to release, OR the enforcement was bypassed (`no-changelog` labels / `[skip changelog]` tokens on their own line on PRs that should have staged entries). Before assuming the former, cross-check against `git log <last-tag>..origin/main` — if real shippable work landed but Unreleased is bare, investigate and reconstruct rather than shipping a hollow release (this is exactly the v0.20.12 failure mode the enforcement exists to prevent).
25
+ 4. Read the staged notes they live in TWO places, both **continuously maintained** as PRs merge and both enforced by `scripts/check-changelog-entry.mjs` (part of `npm run lint`): the `changelog.d/` **fragment files** (the primary path — every shippable PR adds its own `<pr>-<slug>.<type>.md`; see `changelog.d/README.md`) and any legacy/hand-staged entries under `## Unreleased` in `CHANGELOG.md`. Together they should already read as a near-complete draft — you assemble and tidy, you do NOT author from scratch. **Nothing staged at release time (no fragments AND an empty `## Unreleased`) is an ANOMALY, not the normal state** — `bun run changelog:cut` refuses to cut it: it means either there genuinely is nothing to release, OR the enforcement was bypassed (`no-changelog` labels / `[skip changelog]` tokens on their own line on PRs that should have staged notes). Before assuming the former, cross-check against `git log <last-tag>..origin/main` — if real shippable work landed but nothing is staged, investigate and reconstruct rather than shipping a hollow release (this is exactly the v0.20.12 failure mode the enforcement exists to prevent).
26
26
  5. Pick the next version: read the latest tag (`git tag --list 'v*' --sort=-v:refname | head -1`) and bump the patch (or minor if the operator asks). Confirm with the operator which.
27
27
 
28
- ## Step 1 — Consolidate the changelog (the release commit is CHANGELOG-only)
28
+ ## Step 1 — Assemble the changelog (the release commit is CHANGELOG + fragment deletions only)
29
29
 
30
- - The section is maintained continuously (see pre-flight 4), so this step is now a **rename + tidy**, not authorship: change the `## Unreleased` header to `## vX.Y.Z — <one-line summary>`, then read through the staged entries and lightly edit for grouping/summary. If Unreleased was empty or thin, STOP and resolve the anomaly (pre-flight 4) before proceeding — do not invent a release note.
31
- - **Re-seed a fresh empty `## Unreleased` block at the top** (header + the convention HTML comment) immediately below `# Changelog`, so the staging area exists for the next cycle and `scripts/check-changelog-entry.mjs` keeps enforcing it. (Copy the comment block from the section you just renamed.)
32
- - The release commit touches **CHANGELOG.md only**. Do NOT bump `package.json` (placeholder discipline).
30
+ - The notes are staged continuously (see pre-flight 4), so this step is **one command + a review**, not authorship:
31
+
32
+ ```bash
33
+ bun run changelog:cut -- --version vX.Y.Z --summary "<one-line summary>"
34
+ ```
35
+
36
+ `scripts/cut-changelog-release.mjs` folds every `changelog.d/` fragment (plus anything hand-staged under `## Unreleased`) into a new `## vX.Y.Z — <summary>` section grouped by category, **re-seeds a fresh empty `## Unreleased` block** (header + convention comment) below `# Changelog`, and **deletes the consumed fragment files**. Use `--dry-run` first to preview the assembled section. It FAILS LOUDLY on a double cut or an empty cut — if it refuses for "nothing to release", STOP and resolve the anomaly (pre-flight 4); do not invent a release note.
37
+ - Read through the assembled section and lightly edit for grouping/summary before committing.
38
+ - The release commit touches **CHANGELOG.md and the deleted `changelog.d/` fragments only**. Do NOT bump `package.json` (placeholder discipline). (A CHANGELOG + fragment-deletion PR changes no shippable code, so `check-changelog-entry.mjs` passes it without a staged note of its own — deleting fragments never counts as staging one.)
33
39
  - 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.
34
40
 
35
41
  ## Step 2 — Create the DRAFT release on a PINNED SHA, then push the tag