instar 1.3.446 → 1.3.448

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 (36) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +22 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/types.d.ts +12 -0
  5. package/dist/core/types.d.ts.map +1 -1
  6. package/dist/core/types.js.map +1 -1
  7. package/dist/monitoring/SessionWatchdog.d.ts +22 -1
  8. package/dist/monitoring/SessionWatchdog.d.ts.map +1 -1
  9. package/dist/monitoring/SessionWatchdog.js +35 -5
  10. package/dist/monitoring/SessionWatchdog.js.map +1 -1
  11. package/dist/permissions/RelationshipAnomalyScorer.d.ts +123 -0
  12. package/dist/permissions/RelationshipAnomalyScorer.d.ts.map +1 -0
  13. package/dist/permissions/RelationshipAnomalyScorer.js +249 -0
  14. package/dist/permissions/RelationshipAnomalyScorer.js.map +1 -0
  15. package/dist/permissions/RelationshipBehaviorStore.d.ts +90 -0
  16. package/dist/permissions/RelationshipBehaviorStore.d.ts.map +1 -0
  17. package/dist/permissions/RelationshipBehaviorStore.js +156 -0
  18. package/dist/permissions/RelationshipBehaviorStore.js.map +1 -0
  19. package/dist/permissions/SlackPermissionObserver.d.ts +24 -0
  20. package/dist/permissions/SlackPermissionObserver.d.ts.map +1 -1
  21. package/dist/permissions/SlackPermissionObserver.js +35 -0
  22. package/dist/permissions/SlackPermissionObserver.js.map +1 -1
  23. package/dist/permissions/index.d.ts +2 -0
  24. package/dist/permissions/index.d.ts.map +1 -1
  25. package/dist/permissions/index.js +2 -0
  26. package/dist/permissions/index.js.map +1 -1
  27. package/dist/server/routes.d.ts.map +1 -1
  28. package/dist/server/routes.js +19 -0
  29. package/dist/server/routes.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/data/builtin-manifest.json +46 -46
  32. package/src/data/state-coherence-registry.json +12 -0
  33. package/upgrades/1.3.447.md +27 -0
  34. package/upgrades/1.3.448.md +97 -0
  35. package/upgrades/side-effects/slack-relationship-stepup.md +107 -0
  36. package/upgrades/side-effects/watchdog-stuck-failclosed.md +70 -0
@@ -0,0 +1,107 @@
1
+ # Side-Effects Review — Slack Permission System Phase 3: relationship-aware anomaly + step-up ladder
2
+
3
+ **Version / slug:** `slack-relationship-stepup`
4
+ **Date:** 2026-06-09
5
+ **Author:** Echo
6
+ **Second-pass reviewer:** Echo (self, dedicated reviewer pass — this touches a "gate" decision surface)
7
+
8
+ ## Summary of the change
9
+
10
+ Phase 3 (Pillar 3) of the Slack org permission system: a relationship-aware behavioral second factor. It adds two new modules in `src/permissions/`: `RelationshipBehaviorStore.ts` (a durable, deterministic, privacy-respecting per-principal behavioral baseline — it records the SHAPE of each interaction: action label, sensitivity tier, hour-of-day, message length, urgency flag — NEVER message content) and `RelationshipAnomalyScorer.ts` (a real implementation of the existing `AnomalyScorer` interface that scores how out-of-character a request is across five deterministic signals plus an OPTIONAL fail-closed LLM style check). `SlackPermissionObserver.ts` is extended to FEED the baseline (observe-only, directed requests only) from real traffic. `index.ts` exports the new modules. `server.ts` wires them DARK/opt-in behind `slack.permissionGate.relationshipAnomaly.enabled` (default: the existing urgency-only `HeuristicAnomalyScorer` — nothing changes). `routes.ts` adds a read-only `GET /permissions/baselines` inspection route. `state-coherence-registry.json` registers the new `slack-relationship-baselines` state category. Decision points it interacts with: the existing `SlackPermissionGate.evaluate` step-up path (§7.4 composition — anomaly can only RAISE a would-be-allowed FLOOR action to step-up, never lower any bar). Everything is OBSERVE-ONLY: the step-up verdict is computed and logged to the decision ledger; nothing is live-challenged or blocked (the observer ships enforce=false).
11
+
12
+ ## Decision-point inventory
13
+
14
+ - `SlackPermissionGate.evaluate` step-up path (`src/permissions/SlackPermissionGate.ts:167-179`) — pass-through (UNCHANGED) — the gate already consumes an `AnomalyScorer.assess()` score against `stepUpThreshold` and escalates a would-be-allowed floor action to `step-up`. This change supplies a richer scorer into the SAME slot; the gate logic is untouched.
15
+ - `RelationshipAnomalyScorer.assess` (new) — add — produces a 0..1 anomaly SIGNAL (no blocking authority of its own). It is consumed by the gate; it never blocks/allows directly.
16
+ - `RelationshipBehaviorStore.record` (new) — add — append/aggregate per-principal baseline; pure data, no decision.
17
+ - `SlackPermissionObserver.recordBehavior` (new) — add — feeds the baseline observe-only; no decision, no block.
18
+ - `GET /permissions/baselines` (new route) — add — read-only inspection; no decision surface.
19
+
20
+ ---
21
+
22
+ ## 1. Over-block
23
+
24
+ **What legitimate inputs does this change reject that it shouldn't?**
25
+
26
+ In OBSERVE-ONLY mode (the only mode this ships in): NONE. No message is ever blocked or challenged — the step-up verdict is logged, never acted on (the observer's `enforce` flag stays false; this PR does not enable enforce).
27
+
28
+ For the FUTURE enforce path (data-gated, not enabled here), the relevant over-fire is a spurious step-up: e.g. a legitimate owner who genuinely changes their behavior (new role, travels to a new timezone shifting their hour-of-day, suddenly writes longer messages). The scorer mitigates this conservatively: a thin baseline (< establishedMin=5 interactions) suppresses the action/tier/style signals entirely (no "out of character" without an established "character"); a no-baseline principal scores 0 (no fabricated step-up); and anomaly can ONLY raise the bar on a request that was ALREADY going to be allowed — it never invents a new gate. The composition (§7.4) means an over-fire's worst case is "I'd like to confirm it's really you via your known channel," not a hard deny.
29
+
30
+ ---
31
+
32
+ ## 2. Under-block
33
+
34
+ **What failure modes does this still miss?**
35
+
36
+ - A compromised account that mimics the principal's exact style/cadence/action repertoire and avoids urgency language scores low — the behavioral factor is a probabilistic signal, not a guarantee (by design; §7 frames it as "feeling something off," not proof).
37
+ - The deterministic style signal is coarse (message LENGTH z-score only). A content-level voice mismatch is only caught when the optional LLM style check is enabled (and even then only on a clear MISMATCH).
38
+ - A NEW principal (no baseline) has no "character," so a first-ever out-of-character request from a brand-new compromised account scores 0. This is the documented conservative choice: the FLOOR (RolePolicy) still protects the dangerous action regardless — a new owner still needs owner-role authority for a floor action; anomaly is an ADD-ON tightener, not the floor.
39
+ - Off-cadence detection uses local server hour vs the principal's recorded hour histogram; a principal who legitimately works irregular hours has a flat histogram and rarely trips off-cadence (acceptable — fewer false positives).
40
+
41
+ These are acceptable for an observe-only second factor whose entire purpose is to be MEASURED (FP/FN rate) before it ever enforces.
42
+
43
+ ---
44
+
45
+ ## 3. Level-of-abstraction fit
46
+
47
+ This is correctly a DETECTOR (a signal producer), not an authority. `RelationshipAnomalyScorer` returns a score + reasons; it holds no block/allow authority. The AUTHORITY remains `SlackPermissionGate`, which already owns the deterministic Layer-0 floor and the role ceilings — the dangerous decisions stay in code, not in the brittle scorer. The scorer FEEDS the gate's existing step-up slot rather than running parallel to it. The durable baseline is a dedicated store rather than a retrofit onto the generic `RelationshipManager`: the generic manager has no structured per-request SHAPE history (action-tier/hour histograms, length stats) and adding privacy-sensitive structured tracking to it would over-couple a cross-platform memory system to a Slack-permission concern. The new store is small, Slack-scoped, and content-free — the right layer. This mirrors how the rest of the permissions module is decoupled (it imports nothing from core; deps are injected).
48
+
49
+ ---
50
+
51
+ ## 4. Signal vs authority compliance
52
+
53
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
54
+
55
+ - [x] No — this change produces a SIGNAL consumed by an existing smart gate.
56
+
57
+ `RelationshipAnomalyScorer` is a pure signal producer: it returns `{ score, reasons }` and never blocks. The `SlackPermissionGate` (the authority) consumes that signal and is the only thing that produces a verdict — and even then, anomaly can ONLY raise a would-be-allowed floor action to step-up; it can never turn a refuse into an allow (verified by the "anomaly can only RAISE the bar" test). The OPTIONAL LLM style check is `gating: true` (provider-swaps at the IntelligenceRouter) and fails CLOSED (any failure omits its contribution; it never widens) — compliant with the No-Silent-Degradation standard. The deterministic signals stand alone with no LLM dependency, so the dangerous path never depends on an LLM.
58
+
59
+ ---
60
+
61
+ ## 5. Interactions
62
+
63
+ - **Shadowing:** The scorer runs INSIDE `SlackPermissionGate.evaluate`, only on the would-allow-a-floor-action branch (after the deterministic floor + grant check). It cannot shadow the floor check — the floor runs first and a refuse is returned before the scorer is ever consulted. Verified by the member-floor-request test (stays a refuse).
64
+ - **Double-fire:** The observer records the baseline AND the existing decision ledger records the verdict on the same `observe()` call — two distinct append-only writes to two distinct files, no contention. Baseline recording is gated to directed requests only (overheard chatter is excluded), so channel noise doesn't pollute the baseline.
65
+ - **Races:** `RelationshipBehaviorStore` writes via temp-file + rename (atomic-ish) so a crash mid-write can't truncate the baseline. It is per-principal-keyed within a single JSON file; the single-writer model matches the registry `conflictShape: single-writer`. The observer is the only writer in production.
66
+ - **Feedback loops:** The baseline is fed by observed traffic and read by the scorer — but recording is post-verdict and never influences the SAME message's verdict (the verdict is computed from the baseline AS-OF-BEFORE this message). No self-reinforcing loop within a turn.
67
+
68
+ ---
69
+
70
+ ## 6. External surfaces
71
+
72
+ - **Other agents / install base:** None. The feature ships DARK behind `slack.permissionGate.relationshipAnomaly.enabled` (default off). With the flag off, `server.ts` keeps the existing `HeuristicAnomalyScorer` (urgency-only) exactly as before — byte-for-byte unchanged behavior for every existing agent. No CLAUDE.md template or PostUpdateMigrator change (consistent with Slices 0/1/2, which also added nothing there — this is internal server-wired config, not an agent-installed file or a conversational capability).
73
+ - **External systems (Slack):** No change to Slack message handling. Observe-only: nothing is sent, blocked, or reacted-to differently.
74
+ - **Persistent state:** Adds ONE new state file `slack-relationship-baselines.json` (registered in the coherence registry, machine-local, content-free SHAPE aggregates). It is only written when the feature is enabled AND a behaviorStore is wired; otherwise the file never exists.
75
+ - **New read route:** `GET /permissions/baselines` returns SHAPE aggregates only (verified by a test asserting no message content appears in the payload).
76
+ - **Timing/runtime:** Off-cadence uses server-local hour; documented limitation, no correctness dependency.
77
+
78
+ ---
79
+
80
+ ## 7. Rollback cost
81
+
82
+ Pure additive code change, ships dark. Rollback = revert the commit and ship a patch; no migration, no agent-state repair, no user-visible regression (the feature was never on by default). The new state file, if any agent had enabled the feature, is a self-contained machine-local JSON that can be deleted with no downstream effect (the scorer treats a missing baseline as "no baseline" → score 0). The new coherence-registry category is descriptive metadata; removing it has no runtime effect. Estimated rollback: one revert commit, zero downtime.
83
+
84
+ ## Conclusion
85
+
86
+ The review produced no design changes — the change was built as a signal producer feeding the existing authority from the start, and ships observe-only/dark, matching the spec's §7.6 ("the anomaly detector ships dark/observe-only; logs would-be step-ups; nothing gates until the FP rate is known-good"). The conservative no-baseline / thin-baseline behavior is the documented, deliberate choice (don't fabricate a step-up you can't justify from history; the floor protects the dangerous action regardless). The optional LLM style check is fail-closed and add-only. The deterministic core has no LLM dependency. Clear to ship as a dark/observe-only Phase 3 increment pending the spec's convergence gate (the spec is operator-approved but `review-convergence: pending`, so this lands as a Tier-2 increment behind that gate).
87
+
88
+ ---
89
+
90
+ ## Second-pass review (independent adversarial — "gate"-class change)
91
+
92
+ **Verdict: CONCUR on all five invariants, with one substantive CONCERN (baseline-poisoning) — mitigation #1 applied + regression-tested in this PR; deeper mitigations tracked as follow-ups.**
93
+
94
+ The independent adversarial reviewer verified against the code: (1) anomaly only RAISES a would-be-allowed floor verdict to step-up — it can never convert a refuse to allow, weaken the floor, or suppress a required step-up (the score is consulted only after the deterministic deny-by-default floor check passes); (2) message text cannot LOWER the deterministic score (shape-based; the urgency regex + length z-score are add-only) and the optional LLM check is strictly add-only (a prompt-injected "this is the real CEO" → MATCH/empty/unparseable → adds nothing); (3) the store persists SHAPE only (no text/topics; tests + the route assert it); (4) observe-only + dark default confirmed; (5) no authority-by-suspicion (new/thin baseline → no spurious step-up; the floor still protects).
95
+
96
+ **CONCERN — baseline poisoning (was real):** the highest-weight signal (out-of-character action, 0.45) fired only when `actionCounts[action] === 0`, so a patient attacker / slowly-compromised account could seed a single prior observation to permanently disable it; the reviewer reproduced a poisoned baseline scoring 0.45 < the 0.5 step-up threshold → no step-up. It was a follow-up (not a merge blocker) since the change ships observe-only and the deterministic floor still requires owner/grant regardless — but it had to be closed before enforcement is ever turned on (the whole value of Pillar 3 is catching a *legitimately-authorized* account behaving out of character, which is exactly what poisoning targets).
97
+
98
+ **Mitigation #1 APPLIED in this PR (`RelationshipAnomalyScorer.ts`):** the out-of-character signal now fires when the action's SHARE is below a floor (`rareActionShareFloor`, default 0.10), not only when never-seen, with the weight scaled by rarity (full at never-seen → 0 at the floor) so a genuinely-routine action never trips it. Regression test added (the reviewer's exact poisoned baseline — 50 reads + 2 seeded money-movement obs → the malicious request now scores ≥ 0.5 and the rare-action signal still names it). **Follow-ups tracked (deeper poisoning resistance):** (#2) recency/EWMA-decay weighting so a recent burst can't durably reshape the histogram; (#3) a minimum-baseline-AGE gate (using the existing `firstSeen`) + a per-principal observation-rate cap. These are schema-touching and not required while observe-only — to be done before the enforce flip.
99
+
100
+ ---
101
+
102
+ ## Evidence pointers
103
+
104
+ - `tests/unit/slack-relationship-anomaly.test.ts` — 19 tests (store durability/privacy, five-signal scoring, no-baseline conservatism, confidence scaling, LLM fail-closed, gate composition, observer recording).
105
+ - `tests/integration/permissions-routes.test.ts` — `GET /permissions/baselines` route (SHAPE-only, single-principal, empty-state).
106
+ - `npx tsc --noEmit` — clean. `npm run lint` — clean (state-registry lint accepts the new category; no-silent-llm-fallback ratchet accepts the gating:true style-check callsite).
107
+ - Spec: `docs/specs/SLACK-ORG-INTEGRATION-SPEC.md` §7.1–7.4, §7.6.
@@ -0,0 +1,70 @@
1
+ # Side-Effects Review — SessionWatchdog stuck-command fail-closed
2
+
3
+ **Version / slug:** `watchdog-stuck-failclosed`
4
+ **Date:** `2026-06-09`
5
+ **Author:** `Instar Agent (echo)`
6
+ **Second-pass reviewer:** `independent reviewer subagent — concurred`
7
+
8
+ ## Summary of the change
9
+
10
+ `SessionWatchdog.isCommandStuck` decides whether a long-running shell command inside a session should be Ctrl+C'd. It flags candidates after 3 min, then asks an LLM "stuck vs legitimate" before escalating. Previously, when the LLM was unavailable (`!this.intelligence`) or threw (rate-limit / circuit-open / timeout), it returned `true` (fail-open) → Ctrl+C. Under load (exactly when the LLM is unavailable) this interrupted legitimate builds/tests/coverage runs ("Interrupted · What should Claude do instead?"). This change makes both fail paths fail CLOSED via a new `hardCeilingExceeded(elapsedMs)` helper: when the judge can't run, do NOT interrupt below `hardCeilingMs` (config `monitoring.watchdog.hardCeilingSec`, default 1800s; `0` disables), and only escalate past it — preserving deterministic recovery of a genuinely hung command without the LLM. Files: `src/monitoring/SessionWatchdog.ts` (field + constructor + isCommandStuck + helper), `src/core/types.ts` (config field), `tests/unit/SessionWatchdog-failclosed.test.ts` (new), `tests/unit/SessionWatchdog-pipeline.test.ts` (updated the test that encoded the old fail-open).
11
+
12
+ ## Decision-point inventory
13
+
14
+ - `SessionWatchdog.isCommandStuck` (destructive escalation gate → Ctrl+C) — **modify** — the two fail-open branches (no-LLM, catch) now fail closed with a deterministic hard-ceiling backstop. The positive-LLM-verdict path (legitimate→false, stuck→true) is unchanged.
15
+ - `hardCeilingExceeded` — **add** — pure deterministic helper (`hardCeilingMs > 0 && elapsedMs > hardCeilingMs`).
16
+ - Stdin-consumer fail-closed guard (existing, line ~389) — **pass-through** — unchanged; still refuses to escalate a stdin-consumer without an LLM.
17
+
18
+ ## 1. Over-block
19
+
20
+ This change strictly REDUCES over-block. Before, under load the watchdog Ctrl+C'd every command past 3 min (massive over-block of legitimate work). After, those legitimate commands are left alone. No new legitimate input is now interrupted that wasn't before. (Inside the attention of "could a legitimate command exceed the 30-min hard ceiling with the LLM down and get killed?" — yes, a >30-min legitimate command running while the LLM is fully unavailable would be Ctrl+C'd. That is far rarer than the 3-min false-positives this removes, and the ceiling is tunable / disengageable with `0`.)
21
+
22
+ ## 2. Under-block
23
+
24
+ A genuinely-stuck command running while the LLM is unavailable is no longer killed at 3 min — it waits until the 30-min hard ceiling (or, with `hardCeilingSec: 0`, is never auto-killed without a positive LLM verdict). This is the deliberate, safe trade for a destructive action: recovery of a truly-hung command is delayed (not lost — the ceiling still fires, the user can `/interrupt`, and the SilenceSentinel observes a non-progressing session). Residual gap (stated, not deferred): the change does not add an output-progress signal (kill-only-if-no-new-output) to the LLM-available path — a wrong "stuck" verdict from the LLM on a quiet-but-legitimate command can still escalate; that path is unchanged by this fix and is a much rarer failure than the fail-open path being closed here.
25
+
26
+ ## 3. Level-of-abstraction fit
27
+
28
+ Correct layer. The decision lives in the watchdog's own escalation gate, where the destructive authority (Ctrl+C) is exercised. The new helper is a deterministic primitive at the same layer. No higher-level gate should own "is this shell command hung" — it is a per-session, per-process judgment the watchdog already owns. The fix aligns the normal-command path with the already-correct stdin-consumer fail-closed behavior at the same layer (consistency, not a new layer).
29
+
30
+ ## 4. Signal vs authority compliance
31
+
32
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
33
+
34
+ - [x] No — this change has no NEW block/allow surface; it narrows an existing brittle authority so it no longer fires destructively on an unavailable judge.
35
+
36
+ This is the canonical case the "No Silent Degradation to Brittle Fallback" standard targets: a destructive action (Ctrl+C) was gated on an LLM judgment that fell OPEN to a brittle default. The fix makes the destructive action fail CLOSED (do nothing) when the judge can't run, with a deterministic backstop for the genuinely-hung case. It removes brittle destructive authority rather than adding any.
37
+
38
+ ## 5. Interactions
39
+
40
+ - **Shadowing:** runs in the same `checkSession` escalation path; order unchanged. The stdin-consumer guard (line ~389) still runs after `isCommandStuck`; since `isCommandStuck` now returns false more often (below ceiling, no LLM), the path simply returns earlier (temporaryExclusions.add). No check is newly shadowed.
41
+ - **Double-fire:** none — escalation still happens at most once per candidate; `temporaryExclusions` and `escalationState` semantics unchanged.
42
+ - **Races:** `hardCeilingMs` is read-only after construction; `hardCeilingExceeded` is pure. No new shared state.
43
+ - **Feedback loops:** none.
44
+
45
+ ## 6. External surfaces
46
+
47
+ - **Other agents / users:** ships to the install base. User-visible effect is positive: fewer "Interrupted" events on healthy sessions under load. No message format or API change.
48
+ - **Config:** adds optional `monitoring.watchdog.hardCeilingSec`. Code default `?? 1800`, so existing agents get the new behavior on update with NO config-file change required — **no migration needed** (the default lives in the constructor, not in a config file). `ConfigDefaults.ts` is untouched intentionally.
49
+ - **Persistent state:** none.
50
+ - **Timing:** behavior depends on `elapsedMs` vs `hardCeilingMs` and on LLM availability — all already-present runtime inputs.
51
+
52
+ ## 7. Rollback cost
53
+
54
+ Pure code change, no persistent state, no migration. Back-out = revert the commit, ship the next patch; behavior returns to the prior (fail-open) state. No agent-state repair. Low rollback cost. (Operational note: a deployed agent can also neutralize the change live by setting `hardCeilingSec` very low to restore aggressive behavior, or `0` to make it maximally conservative — no redeploy needed.)
55
+
56
+ ## Conclusion
57
+
58
+ The review confirms a scope-narrowing, safety-improving fix: it removes a destructive fail-open (the observed cause of "interrupted out of nowhere" under load) and adds no new blocking surface, while a deterministic hard ceiling preserves recovery of genuinely-hung commands. No design change required by the review. Because the change touches a watchdog with kill authority, a Phase-5 second-pass review is required before commit.
59
+
60
+ ## Second-pass review (if required)
61
+
62
+ **Reviewer:** independent reviewer subagent (general-purpose)
63
+ **Independent read of the artifact: concur**
64
+
65
+ Concur with the review. The reviewer independently traced every path and confirmed: (1) both fail paths now return `hardCeilingExceeded(elapsedMs)` not bare `true` (SessionWatchdog.ts:671, 732), with a correct strict-`>` boundary and `0`-disables guard (:748); (2) the genuinely-hung non-consumer case still reaches `sendKey('C-c')` past the ceiling (traced :671→:418); (3) **kill authority strictly narrows** — for the two changed branches `new ≤ old` for every input, so the change can never produce a Ctrl+C the old code wouldn't have; (4) the stdin-consumer guard (:395) composes correctly — a hung stdin-consumer without an LLM is still (intentionally, conservatively) not killed even past the ceiling, while the `crontab -` deterministic-recovery example is a non-consumer so it does reach the kill path; (5) `?? 1800` honors an explicit `0`; (6) tests exercise both sides with realistic inputs and the pipeline test was strengthened (old fail-open assertion replaced with fail-closed + a real past-ceiling recovery assertion), not weakened. The §2 residual gap (LLM-available wrong-"stuck" verdict on a quiet legitimate command) is out of scope and disclosed, not silently deferred.
66
+
67
+ ## Evidence pointers
68
+
69
+ - Live: `logs/server.log` — `[Watchdog] "<session>": stuck command (190s|230s) … — sending Ctrl+C` on legitimate work under load; user screenshot of a session interrupted mid-`docs-coverage.mjs --check`.
70
+ - Tests: `tests/unit/SessionWatchdog-failclosed.test.ts` (8, both sides), `tests/unit/SessionWatchdog-pipeline.test.ts` (updated). 175 watchdog/triage unit tests green; tsc + lint clean.