instar 1.1.0 → 1.1.2

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 (34) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +7 -1
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/commands/setup.d.ts.map +1 -1
  5. package/dist/commands/setup.js +19 -24
  6. package/dist/commands/setup.js.map +1 -1
  7. package/dist/core/PostUpdateMigrator.d.ts +1 -0
  8. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  9. package/dist/core/PostUpdateMigrator.js +69 -0
  10. package/dist/core/PostUpdateMigrator.js.map +1 -1
  11. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  12. package/dist/lifeline/TelegramLifeline.js +21 -0
  13. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  14. package/dist/memory/NativeModuleHealer.d.ts +15 -0
  15. package/dist/memory/NativeModuleHealer.d.ts.map +1 -1
  16. package/dist/memory/NativeModuleHealer.js +22 -0
  17. package/dist/memory/NativeModuleHealer.js.map +1 -1
  18. package/dist/monitoring/NativeHealDegradationBridge.d.ts +32 -0
  19. package/dist/monitoring/NativeHealDegradationBridge.d.ts.map +1 -0
  20. package/dist/monitoring/NativeHealDegradationBridge.js +61 -0
  21. package/dist/monitoring/NativeHealDegradationBridge.js.map +1 -0
  22. package/dist/server/AgentServer.d.ts.map +1 -1
  23. package/dist/server/AgentServer.js +6 -0
  24. package/dist/server/AgentServer.js.map +1 -1
  25. package/dist/threadline/WakeSocketServer.d.ts +16 -0
  26. package/dist/threadline/WakeSocketServer.d.ts.map +1 -1
  27. package/dist/threadline/WakeSocketServer.js +100 -6
  28. package/dist/threadline/WakeSocketServer.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/data/builtin-manifest.json +19 -19
  31. package/upgrades/1.1.1.md +58 -0
  32. package/upgrades/1.1.2.md +130 -0
  33. package/upgrades/side-effects/boot-wrapper-plist-coherence.md +176 -0
  34. package/upgrades/side-effects/native-heal-degradation-bridge.md +206 -0
@@ -0,0 +1,130 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- bump: patch -->
4
+ <!-- patch = bug fixes, refactors, test additions, doc updates -->
5
+
6
+ ## What Changed
7
+
8
+ **fix(boot-wrapper): plist↔wrapper extension drift no longer takes agents dark.**
9
+
10
+ Three intertwined boot-time changes that together close a "agent goes
11
+ silently dark and won't come back" failure mode observed in the field
12
+ (Echo, 2026-05-20):
13
+
14
+ 1. **`installBootWrapper()` always writes `instar-boot.cjs`** (not `.js`),
15
+ regardless of the project's `package.json` `"type"` field. `.cjs`
16
+ forces CommonJS semantics in Node, so the wrapper's `require()` calls
17
+ work in both type=module and type=commonjs projects. Previously the
18
+ function picked an extension based on `package.json` AND deleted the
19
+ alt extension — so if a project gained `"type": "module"` after the
20
+ plist was generated (e.g., via a package upgrade), the next call to
21
+ `installBootWrapper` deleted the `.js` file the plist still pointed
22
+ at. launchd then exec'd a nonexistent file on every restart and none
23
+ of the downstream self-heal (`ServerSupervisor.preflightSelfHeal`,
24
+ `NativeModuleHealer`, `INSTAR_SUPERVISED` detection) ever ran because
25
+ the boot wrapper itself never loaded.
26
+
27
+ 2. **`TelegramLifeline.selfHealPlist` adds a wrapper-existence check.**
28
+ The existing three checks all passed when the plist referenced
29
+ `instar-boot.js` but the file was deleted (the plist content still
30
+ matched the legacy-detection patterns). A fourth check now extracts
31
+ the wrapper path from `ProgramArguments` and verifies the file
32
+ exists on disk; if not, the plist is regenerated immediately.
33
+ Defense-in-depth — the always-.cjs change above eliminates the
34
+ creation of this state going forward, but this check rescues any
35
+ already-existing drift before the next launchd restart hits the
36
+ bad path.
37
+
38
+ 3. **`WakeSocketServer.start()` recovers from EADDRINUSE.** The
39
+ pre-existing unlink-before-bind pass handled the common
40
+ "stale socket file from unclean exit" case but silently swallowed
41
+ unlink errors and offered no fallback if `listen()` itself fired
42
+ EADDRINUSE. On EADDRINUSE the server now probes the socket: if a
43
+ live peer answers, surface the error normally (don't clobber a real
44
+ second instance); if nothing answers, force-unlink and retry listen
45
+ once. Bounded retry — single attempt, no churn.
46
+
47
+ 4. **`PostUpdateMigrator.migrateBootWrapperToCjs`** — closes the gap
48
+ for in-the-wild agents whose launchd plists were generated before
49
+ this change with `instar-boot.js` in `ProgramArguments`. On darwin,
50
+ if the plist exists and references `instar-boot.js`, the migration
51
+ regenerates via `installAutoStart` so the plist now points at
52
+ `instar-boot.cjs`. Idempotent — re-runs that find a `.cjs`-referencing
53
+ plist are silent no-ops. The migration does NOT delete the legacy
54
+ `.js` file; rollback safety wins over file cleanup.
55
+
56
+ Files touched (excluding tests / docs / upgrade notes):
57
+ - `src/commands/setup.ts` — `installBootWrapper`, `ensureBootWrapper`
58
+ - `src/commands/server.ts` — auto-start plist sanity check accepts `.cjs`
59
+ - `src/lifeline/TelegramLifeline.ts` — selfHealPlist 4th check
60
+ - `src/threadline/WakeSocketServer.ts` — EADDRINUSE recovery
61
+ - `src/core/PostUpdateMigrator.ts` — `migrateBootWrapperToCjs`
62
+
63
+ ## Evidence
64
+
65
+ **Repro of the original failure** (Echo, 2026-05-20, 10:25 PT):
66
+
67
+ 1. Echo's `package.json` has `"type": "module"` (instar's own package
68
+ has had this since v1.0.0).
69
+ 2. Earlier `installBootWrapper` calls picked `.cjs`, deleted `.js`, and
70
+ the plist was generated at that point referencing `.cjs`.
71
+ 3. A subsequent code path (`installBootWrapper` running while the boot
72
+ was actively crashing for a different reason — Node major-version
73
+ ABI break, since rolled back) wrote `.cjs` AGAIN and deleted any
74
+ `.js` that had been re-created by an even-older code path.
75
+ 4. The plist on disk still referenced `.cjs`, but ALSO any plist that
76
+ had been generated when echo's `package.json` was missing the
77
+ `type: module` declaration referenced `.js` — and the `.js` file
78
+ was gone.
79
+ 5. launchd's next restart exec'd the nonexistent path → process never
80
+ started → all downstream self-heal (`NativeModuleHealer.healBetterSqlite3`,
81
+ `ServerSupervisor.preflightSelfHeal`, bind-failure escalation)
82
+ silently never ran.
83
+ 6. Operator intervention (Dawn, manual plist edit) was required to
84
+ restore the agent.
85
+
86
+ **Observed before:** `git log` shows multiple in-the-wild reports of
87
+ launchd plists pointing at a deleted `instar-boot.js` after a project
88
+ gained `"type": "module"`. The selfHealPlist check at
89
+ `src/lifeline/TelegramLifeline.ts:2076` reads `content.includes('instar-boot.js')`,
90
+ which returns `true` even after the file is deleted — so the existing
91
+ checks all pass and no regen fires.
92
+
93
+ **Observed after:**
94
+ - New test `tests/unit/boot-wrapper-plist-coherence.test.ts` exercises
95
+ `installBootWrapper` with package.json `type: module`, type: commonjs,
96
+ and no package.json — all three write `instar-boot.cjs`. A test
97
+ explicitly plants a pre-existing `instar-boot.js` and verifies it is
98
+ NOT deleted by the new code path (rollback safety).
99
+ - New test `tests/unit/PostUpdateMigrator-bootWrapperCjs.test.ts`
100
+ exercises the four migration branches (non-darwin skip, no plist
101
+ skip, already-.cjs idempotent skip, and the `.js → .cjs` detection +
102
+ regeneration path).
103
+ - New test `tests/unit/wake-socket-server-stale-recovery.test.ts`
104
+ exercises the stale-socket cleanup path and asserts that a live peer
105
+ is NOT clobbered (the recovery surfaces EADDRINUSE in that case).
106
+ - Existing test suites unchanged; `installBootWrapper` callers in
107
+ `installMacOSLaunchAgent` and `TelegramLifeline.selfHealPlist`
108
+ receive the new `.cjs` path through the unchanged `wrappers.js`
109
+ return field so no callsite needs adjustment.
110
+
111
+ ## What to Tell Your User
112
+
113
+ - **Your agent stays online through package upgrades**: if I had an
114
+ internal package configuration change, my launchd entry point used
115
+ to risk getting renamed out from under itself. That whole class of
116
+ failure is closed off now — the boot file has a stable name that
117
+ works regardless of internal toggles.
118
+ - **Stale socket files don't lock me out anymore**: if I exited
119
+ uncleanly and a leftover socket file blocked the next start, I now
120
+ detect that and clean it up safely, only after first checking that
121
+ nothing else is actually using it.
122
+
123
+ ## Summary of New Capabilities
124
+
125
+ | Capability | How to Use |
126
+ |-----------|-----------|
127
+ | Plist↔wrapper coherence guarantee | automatic (no action required) |
128
+ | selfHealPlist verifies wrapper-path-on-disk | automatic (runs in lifeline boot) |
129
+ | WakeSocketServer EADDRINUSE recovery | automatic (runs in listener startup) |
130
+ | PostUpdateMigrator regenerates `.js`-plists | automatic on next instar update |
@@ -0,0 +1,176 @@
1
+ # Side-Effects Review — Boot-Wrapper / Plist Coherence
2
+
3
+ **Version / slug:** `boot-wrapper-plist-coherence`
4
+ **Date:** `2026-05-20`
5
+ **Author:** Echo (instar developer agent)
6
+ **Trigger:** Field failure 2026-05-20, ~10:25 PT: instar agent "echo"
7
+ went dark, required operator intervention (Dawn — manual plist edit) to
8
+ recover. Root cause: launchd plist's `ProgramArguments` referenced
9
+ `instar-boot.js` but that file had been deleted by `installBootWrapper`
10
+ because `package.json` had gained `"type": "module"`.
11
+
12
+ ## Summary of the change
13
+
14
+ Four interlocking edits that together eliminate the plist↔wrapper
15
+ extension-drift failure class:
16
+
17
+ 1. **`installBootWrapper()` always writes `.cjs`.** No more reading
18
+ `package.json "type"` and switching extensions. No more deleting the
19
+ alt extension.
20
+ 2. **`ensureBootWrapper()` looks for `.cjs`.** The presence check used
21
+ to look for the type-dependent extension; now it always looks for
22
+ `.cjs`.
23
+ 3. **`TelegramLifeline.selfHealPlist` adds Check 4:** verify the wrapper
24
+ path referenced by the plist's `ProgramArguments` exists on disk. If
25
+ not, regenerate via `installAutoStart`. Defense-in-depth for any
26
+ already-drifted state.
27
+ 4. **`PostUpdateMigrator.migrateBootWrapperToCjs`** detects existing
28
+ `.js`-referencing plists and regenerates them to point at `.cjs` via
29
+ `installAutoStart`. Idempotent.
30
+ 5. **`WakeSocketServer.start()`** uses a probe-before-clobber pattern.
31
+ Previously: pre-bind unconditional `unlink`. New: listen first; on
32
+ EADDRINUSE, probe-connect to determine if the socket is live or
33
+ stale; only unlink+retry if confirmed stale. Prevents silently
34
+ stealing a path from a live peer.
35
+
36
+ ## Decision matrix
37
+
38
+ ### Over-block
39
+
40
+ | Scenario | Outcome |
41
+ |----------|---------|
42
+ | Project has package.json `"type": "module"` | Always-`.cjs` is correct (no behavioral change vs. pre-existing `.cjs`-write path) |
43
+ | Project has no package.json | Always-`.cjs` works (Node treats `.cjs` as CommonJS regardless of parent context) |
44
+ | Project has package.json `"type": "commonjs"` | Always-`.cjs` works (CommonJS is the explicit context) |
45
+ | Existing `.js` wrapper on disk | NOT touched (rollback-safe — old plists referencing `.js` keep working until migrator runs) |
46
+ | Existing `.cjs` wrapper on disk | Overwritten with current content (preserves existing behavior) |
47
+
48
+ No over-block. The `.cjs` extension is strictly broader than the previous `.js`+`.cjs` split.
49
+
50
+ ### Under-block
51
+
52
+ | Scenario | Outcome |
53
+ |----------|---------|
54
+ | Plist references `.js`, no `.js` file on disk, no `.cjs` file on disk | Migrator regenerates; selfHealPlist Check 4 catches |
55
+ | Plist references `.cjs`, `.cjs` file deleted | selfHealPlist Check 4 catches (regenerates) |
56
+ | Plist references `.js`, `.js` file present, `.cjs` file present | Migrator regenerates plist to `.cjs` (idempotent on re-run) |
57
+ | Plist references `.js`, `.js` file present, `.cjs` file missing | Migrator regenerates plist (writes `.cjs` + updates plist) |
58
+
59
+ No under-block.
60
+
61
+ ### Level of abstraction fit
62
+
63
+ - The fix lives at the WRAPPER GENERATION + WRAPPER VERIFICATION layer,
64
+ which is the same layer where the bug originated. Not pushed up into
65
+ the Remediator (out of scope for this PR — Remediator is still under
66
+ Tier 2 rollout per the v3 spec) and not pushed down into launchd (no
67
+ way to influence launchd's interpretation of the plist).
68
+ - `installBootWrapper`'s contract is unchanged from a caller's
69
+ perspective: it still returns `{ sh, js }`. The `js` field name is now
70
+ a slight misnomer (the file is `.cjs`) but every existing caller uses
71
+ it as an opaque path string, so no callsite changes.
72
+
73
+ ### Signal-vs-authority compliance
74
+
75
+ | Component | Signal or Authority | Reason |
76
+ |-----------|--------------------|--------|
77
+ | `installBootWrapper` always-`.cjs` | Authority (writes files) | Deterministic, content-based decision. No judgment call. |
78
+ | `ensureBootWrapper` `.cjs` lookup | Signal (returns boolean) | Caller decides what to do with "wrappers missing". |
79
+ | `selfHealPlist` Check 4 | Authority (writes files via installAutoStart) | Existence check is deterministic. The regeneration is a documented recovery primitive. |
80
+ | `migrateBootWrapperToCjs` | Authority (writes files via installAutoStart) | Detection is structural (plist content matches regex). Regeneration is the same primitive other migrations use. |
81
+ | `WakeSocketServer` probe-then-recover | Signal-and-Authority hybrid | Probe is a signal (connect-or-not); the unlink+retry is bounded recovery (single retry, hard timeout). |
82
+
83
+ Compliant. The new authorities are bounded recovery primitives, not
84
+ judgmental gates. The new signals are deterministic structural checks.
85
+
86
+ ### Interactions with adjacent systems
87
+
88
+ | System | Interaction | Risk |
89
+ |--------|-------------|------|
90
+ | `NativeModuleHealer` | None direct. NativeModuleHealer fires after boot wrapper loads; this fix ensures the boot wrapper CAN load. | Strictly improves NativeModuleHealer's blast radius. |
91
+ | `ServerSupervisor.preflightSelfHeal` | None direct. Preflight runs after boot wrapper spawns the lifeline. | Same — strictly improves preflight's coverage. |
92
+ | `detectLaunchdSupervised` | None. Runs inside the lifeline; the fix is upstream. | None. |
93
+ | `INSTAR_SUPERVISED` plist env | Compatible — `installMacOSLaunchAgent` regenerates the full plist including this env var. | None. |
94
+ | `RestartOrchestrator` | Same as above — fix is upstream. | None. |
95
+ | `DegradationReporter` | None direct. Migrator failures land in `result.errors[]` which feed the migration report, not Degradation. Acceptable: migrator runs in a known supervised window, not in production hot paths. | None. |
96
+ | `WakeSocketServer` listener daemon usage | The probe-before-clobber change only fires on EADDRINUSE. Normal startup path (no stale file) is unchanged. | Tested with planted-stale-file + live-peer scenarios. |
97
+ | Existing `.js`-wrapper-using agents | Continue to work — `.js` file is NOT deleted; old plists still load; migrator regenerates plist on next update. | Backward compatible by design. |
98
+ | Tests that simulate `instar-boot.js` (e2e/launchd-*) | These tests generate their OWN `.js` wrappers and do NOT go through `installBootWrapper`. Unchanged. | None — verified by reading the test files. |
99
+
100
+ ### Rollback cost
101
+
102
+ - Pure code change. Revert is one git revert per affected file.
103
+ - No persistent state changes that block rollback. The migrator runs
104
+ forward-only; rolling back leaves agents on `.cjs` plists (which work
105
+ fine) — the old `.js` files are still around if needed.
106
+ - `WakeSocketServer` change: revert restores pre-bind-unlink behavior.
107
+ The live-peer-clobber behavior re-emerges but is rare in practice
108
+ (single-tenant agents on a machine).
109
+ - Migrator runs idempotently — rolling back and re-deploying produces
110
+ the same end state.
111
+
112
+ ## Acceptance criteria
113
+
114
+ 1. **Repro of the Echo failure mode.**
115
+ - Test: `tests/unit/boot-wrapper-plist-coherence.test.ts > installBootWrapper — always writes .cjs > does NOT delete a pre-existing instar-boot.js (rollback safety)`
116
+ - Plants a pre-existing `instar-boot.js`, runs `installBootWrapper` with `package.json type=module`. Asserts the `.js` file still exists. Pre-change behavior: file deleted. Post-change: file preserved.
117
+
118
+ 2. **selfHealPlist catches drift.**
119
+ - Implicit in `TelegramLifeline.ts` source: `Check 4` extracts the wrapper path from `ProgramArguments` and verifies `fs.existsSync`. Covered by `Check 4` logic.
120
+
121
+ 3. **Migrator handles existing `.js`-referencing plists.**
122
+ - Test: `tests/unit/PostUpdateMigrator-bootWrapperCjs.test.ts > detects a plist that references instar-boot.js and attempts regeneration`
123
+ - Plants a plist referencing `instar-boot.js`. Runs the migrator. Asserts the migrator either succeeds (`upgraded`) or surfaces an error (`errors`) — both prove the detection-and-attempt path ran.
124
+
125
+ 4. **WakeSocketServer does NOT clobber live peers.**
126
+ - Test: `tests/unit/wake-socket-server-stale-recovery.test.ts > refuses to clobber a live peer (surfaces EADDRINUSE)`
127
+ - Binds a real `net.Server` to the socket path, then attempts to start a second `WakeSocketServer`. Asserts the second emits EADDRINUSE without taking the path away from the live peer.
128
+
129
+ 5. **WakeSocketServer recovers from stale socket.**
130
+ - Test: `tests/unit/wake-socket-server-stale-recovery.test.ts > cleans up a stale socket file on start (pre-bind unlink path)` and `> recovers from EADDRINUSE when no live peer is listening`
131
+ - Plants an empty file at the socket path, starts the server, asserts the listen succeeds.
132
+
133
+ ## Notes for second-pass reviewer
134
+
135
+ - **Why always `.cjs` and not "always write both `.js` and `.cjs`?":**
136
+ Considered. Always-both would allow rollback to point at `.js` if
137
+ needed. Rejected because: (1) `.js` in a `type=module` project is a
138
+ broken artifact (Node can't load it as CJS); (2) the migrator
139
+ regenerates plists to `.cjs` automatically, so rollback isn't a
140
+ user-visible scenario; (3) keeping an unused broken file invites
141
+ confusion. The current code leaves `.js` files alone but doesn't
142
+ create new ones.
143
+
144
+ - **Why not run the migrator on every lifeline boot instead of relying
145
+ on `PostUpdateMigrator`?** `selfHealPlist` already does the
146
+ equivalent on every lifeline boot — it's Check 4 of that function.
147
+ The migrator entry catches the case where the agent installs the new
148
+ instar version but hasn't yet booted the lifeline (e.g., the
149
+ postupdate migration runs first).
150
+
151
+ - **Why probe-then-unlink instead of just `listen()`-then-error-bubble
152
+ on EADDRINUSE?** Without the recovery path, a stale socket file from
153
+ an unclean exit blocks startup forever until the operator manually
154
+ removes it. The probe-then-unlink preserves correctness (don't
155
+ clobber live peers) while restoring the auto-recovery the original
156
+ pre-bind-unlink path provided.
157
+
158
+ - **Migrator side-effect on darwin only:** Linux systemd unit files
159
+ reference the bash wrapper (`instar-boot.sh`) which is unaffected
160
+ by this change. Non-darwin migrator path is a no-op skip.
161
+
162
+ ## Rollback playbook
163
+
164
+ If this PR causes regressions:
165
+
166
+ 1. `git revert <pr-merge-sha>` on the canonical instar repo's `main`.
167
+ 2. Tag a patch release (e.g., `v1.1.2`) with the revert.
168
+ 3. `npx instar` auto-update propagates the revert to all in-the-wild
169
+ agents on next supervision cycle.
170
+ 4. Agents whose plists were already migrated to `.cjs` continue to
171
+ work — `installBootWrapper` post-revert writes `.cjs` for projects
172
+ with `type=module`, which matches what the migrated plists reference.
173
+ 5. Agents whose plists were NOT yet migrated (still on `.js`)
174
+ continue on `.js` — unchanged from pre-PR state.
175
+
176
+ No persistent state cleanup required.
@@ -0,0 +1,206 @@
1
+ # Side-Effects Review — Native-Module Heal → DegradationReporter bridge
2
+
3
+ **Version / slug:** `native-heal-degradation-bridge`
4
+ **Date:** `2026-05-20`
5
+ **Author:** Echo (instar developer agent)
6
+ **Second-pass reviewer:** voluntary independent audit run despite the
7
+ change not falling under the mandatory-review categories — the bridge
8
+ emits into the outbound-messaging path (via DegradationReporter), which
9
+ is adjacent enough to warrant a second opinion.
10
+
11
+ **Second-pass result:** *Concur with the review.* Reviewer independently
12
+ verified:
13
+ - `listeners` Set is effectively process-global (healer is a module-level
14
+ singleton); per-bridge `Set` dedupe is the right second layer for
15
+ future per-module retries.
16
+ - F-8 Remediator `invokeFromRemediator` and legacy `openWithHeal` both
17
+ route through `logHealEvent` and share the `healAttempted` one-shot
18
+ guard, so no double-fire is possible today.
19
+ - Listener-error swallow is bounded to the observability-consumer call
20
+ site and does not hide heal exceptions (those remain thrown by
21
+ `healBetterSqlite3Sync`).
22
+ - Tests 7–9 exercise the real production listener-dispatch path via
23
+ `healBetterSqlite3Sync` with a mocked install-prefix resolver; tests
24
+ 1–6 use a synthetic fanout helper appropriate for unit-isolating the
25
+ bridge translation logic.
26
+ - Forward-looking note (not blocking): if per-module heal retries are
27
+ added later, success events should clear the dedupe entry and emit a
28
+ "recovered" degradation so a retry-success-after-failure sequence
29
+ doesn't leave the user with a stale failure alert. Today's one-shot
30
+ semantics make this impossible to hit, so the bridge is correct as
31
+ written; the comment in the bridge already anticipates the future
32
+ surface.
33
+
34
+ ## Summary of the change
35
+
36
+ `NativeModuleHealer` already writes `HealEvent`s to
37
+ `<stateDir>/native-module-heals.jsonl` and `console.error`s on failure. Its
38
+ own jsdoc names `DegradationReporter` as a consumer ("consumed by health
39
+ checks and DegradationReporter") but no consumer was ever wired. This PR
40
+ fills the gap with the minimum surface area required:
41
+
42
+ 1. `NativeModuleHealer.onHealEvent(listener)` — public listener
43
+ registration on the existing singleton. Listener fires after every
44
+ persisted `HealEvent` (success and failure). Returns an unsubscribe
45
+ function. Listener errors are swallowed inside the dispatch loop so
46
+ observability cannot break the heal path itself.
47
+ 2. `src/monitoring/NativeHealDegradationBridge.ts` — new ~70-line module.
48
+ Subscribes to the healer; on a failure event, calls
49
+ `DegradationReporter.report(...)` with a component-specific impact
50
+ line. Successful heals are silent on purpose (the feature recovered,
51
+ no user action required). Dedupes per component within the process
52
+ life so a future heal-retry surface cannot spam the alert path.
53
+ 3. `AgentServer` — one new import + one new call (`bridgeNativeHealToDegradation()`)
54
+ placed immediately after the existing `NativeModuleHealer.configure(...)`
55
+ line, inside the same `if (options.config.stateDir)` block.
56
+
57
+ Files touched:
58
+ - `src/memory/NativeModuleHealer.ts` — adds `HealEventListener` export,
59
+ `listeners` Set, `onHealEvent()` method, listener-clear in
60
+ `resetForTesting()`, listener-dispatch in `logHealEvent()`.
61
+ - `src/monitoring/NativeHealDegradationBridge.ts` — new module.
62
+ - `src/server/AgentServer.ts` — one import + one call.
63
+ - `tests/unit/NativeHealDegradationBridge.test.ts` — 9 new tests covering
64
+ failure→report, success-silent, per-component dedupe, unknown-component
65
+ fallback impact, missing-field reason fallback, unsubscribe, real
66
+ listener dispatch through `healBetterSqlite3Sync` with a mocked install
67
+ prefix, listener-error isolation, and listener cleanup.
68
+ - `upgrades/NEXT.md` — entry, evidence paragraph, user message, two
69
+ capability-table rows.
70
+
71
+ ## Decision-point inventory
72
+
73
+ - `NativeModuleHealer.logHealEvent` (deterministic authority over heal
74
+ persistence) — **extend** — adds a listener-dispatch step after the
75
+ existing jsonl write. Listener errors are swallowed; the heal contract
76
+ is unchanged.
77
+ - `AgentServer` startup (composition root) — **extend** — adds a single
78
+ subscriber registration call. No new state.
79
+ - `DegradationReporter.report` — **consume only**, no surface change.
80
+
81
+ ---
82
+
83
+ ## 1. Over-block
84
+
85
+ **What legitimate inputs does this change reject that it shouldn't?**
86
+
87
+ None. No new gating, no new filtering, no rejection logic. The bridge
88
+ adds a degradation event on heal *failure* and stays silent on heal
89
+ *success*. Successful heals continue to behave identically to today
90
+ (the feature opens, the user sees nothing).
91
+
92
+ ## 2. Under-block
93
+
94
+ **What failure modes does this still miss?**
95
+
96
+ - **CLI commands that hit the healer outside the server process.** A
97
+ user running `instar memory ...` against an ABI-mismatched
98
+ better-sqlite3 will get the existing `console.error` and a thrown error
99
+ but no Telegram alert — `AgentServer` is the only registration site.
100
+ This is the same scope as the existing DegradationReporter wiring
101
+ (CLI commands never had the Telegram alert path anyway). The CLI user
102
+ sees the error on stdout, which is the appropriate surface for a
103
+ foreground command.
104
+ - **Multiple AgentServer instantiations in one process.** Each
105
+ `bridgeNativeHealToDegradation()` call adds a new listener with a
106
+ fresh dedupe Set. In production AgentServer is a singleton; in tests
107
+ callers pass an explicit reporter. Not a real risk; documented as a
108
+ YAGNI in the bridge module.
109
+ - **Build manifest / pre-load drift detection.** Out of scope — covered
110
+ in topic 11013's earlier design proposal as a separate (lower
111
+ priority) item. The heal works without it.
112
+
113
+ ## 3. Level-of-abstraction fit
114
+
115
+ **Is this at the right layer?**
116
+
117
+ Yes. The healer is the existing authority over native-module rebuilds
118
+ and the canonical producer of `HealEvent`s. DegradationReporter is the
119
+ existing authority over "feature-fallback → user-visible alert."
120
+ Bridging them in a third file keeps each side single-responsibility:
121
+ the healer doesn't import the reporter (no `src/memory/*` →
122
+ `src/monitoring/*` coupling beyond the bridge), the reporter doesn't
123
+ know about the healer at all, and the bridge is a pure subscriber that
124
+ can be unit-tested with a synthesized `HealEvent`.
125
+
126
+ `AgentServer` is the correct composition root for the registration —
127
+ it's where `NativeModuleHealer.configure(...)` already happens, so the
128
+ two lines stay co-located.
129
+
130
+ ## 4. Signal vs authority compliance
131
+
132
+ **Reference:** the principle in MEMORY.md (`feedback_signal_vs_authority`):
133
+ "Brittle/low-context filters detect and emit signals. Only a higher-level
134
+ intelligent gate with full context has blocking authority."
135
+
136
+ - The **healer** is the deterministic authority over native-module
137
+ rebuilds. Unchanged.
138
+ - The **bridge** is a pure signal transducer — `HealEvent` (signal) →
139
+ `DegradationEvent` (signal). It has no blocking authority, no decision
140
+ surface, no LLM call.
141
+ - `DegradationReporter` is the higher-level alert authority. Its
142
+ existing dispatch logic (Remediator routing, tone-gate, alert
143
+ cooldown) is unchanged. The bridge is just a new emitter feeding the
144
+ same authority.
145
+
146
+ ## 5. Interactions / cross-component effects
147
+
148
+ - **Existing W-1 Remediator path.** When `NativeModuleHealer.invokeFromRemediator(ctx)`
149
+ is the entry point, it still calls `healBetterSqlite3Sync` →
150
+ `logHealEvent` → listener dispatch. The bridge fires once for the
151
+ same component just as it does for the legacy `openWithHeal` path —
152
+ per-component dedupe absorbs both paths. No double-alert risk.
153
+ - **DegradationReporter remediator-routing.** Reports flow through
154
+ `_normalize()` like any other legacy `.report(...)` call. They will be
155
+ tagged `provenance: 'free-text'` and (per §A6) won't match any runbook
156
+ prefilter — they route to `no-matching-runbook` and feed
157
+ NovelFailureReviewer's clustering pipeline, which is appropriate for
158
+ "the W-1 heal couldn't repair its own target" alerts.
159
+ - **Telegram tone-gate.** Goes through the existing pipeline; the
160
+ default safe fallback template applies if the candidate text is
161
+ rejected.
162
+ - **Persistence.** `degradations.json` (existing) gains entries for
163
+ heal failures. Bounded to last 100 events per existing rotation.
164
+
165
+ ## 6. Rollback cost
166
+
167
+ Trivial. Revert is 5 file edits (3 modifications + 2 deletions). No
168
+ schema, no config, no migration, no on-disk format change. The
169
+ `native-module-heals.jsonl` log and `console.error` path are unchanged,
170
+ so observability survives a revert.
171
+
172
+ ## 7. Migration parity check
173
+
174
+ Not applicable. The change is server-internal. No agent-installed file
175
+ changes (no `.claude/settings.json`, no `.instar/config.json`, no
176
+ CLAUDE.md template, no hook scripts, no built-in skills) — existing
177
+ agents pick this up automatically when they restart against the new
178
+ `instar` binary, which is the normal update path. The Migration Parity
179
+ Standard's enumerated trigger list does not include `src/server/*`.
180
+
181
+ ## 8. Test integrity
182
+
183
+ Per the Testing Integrity Standard (`docs/specs/TESTING-INTEGRITY-SPEC.md`):
184
+
185
+ - **Tier 1 (unit).** 9 tests in
186
+ `tests/unit/NativeHealDegradationBridge.test.ts` — listener-dispatch,
187
+ failure-reports / success-silent / per-component-dedupe / unknown-component-fallback /
188
+ missing-field reason / unsubscribe; plus three on
189
+ `NativeModuleHealer.onHealEvent` exercising the real
190
+ `healBetterSqlite3Sync` early-exit path with a mocked install-prefix
191
+ resolver (listener invocation, listener-error isolation, unsubscribe).
192
+ - **Tier 2 (integration).** Not added — the bridge is a process-internal
193
+ subscriber with no HTTP surface. The existing
194
+ `tests/integration/degradation-reporter-*` suites continue to cover
195
+ the reporter's HTTP-visible behavior; this PR adds one more emitter
196
+ to that same path, no new routes.
197
+ - **Tier 3 (E2E lifecycle).** Not added — the wiring lives inside the
198
+ same `if (options.config.stateDir)` block that already conditions
199
+ `NativeModuleHealer.configure(...)` and `TokenLedger` construction
200
+ (which both have Tier-3 coverage). Adding a parallel boot-time
201
+ bridge-presence test would only restate that block's existing E2E
202
+ guarantee.
203
+
204
+ Pre-existing related suites (`NativeModuleHealer.test.ts` 17 tests,
205
+ `NativeModuleHealer-invokeFromRemediator.test.ts`, `degradation-reporter*`
206
+ 22 tests) pass unchanged after this PR.