instar 1.3.747 → 1.3.749

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 (85) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +136 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/HttpLeaseTransport.d.ts +22 -0
  5. package/dist/core/HttpLeaseTransport.d.ts.map +1 -1
  6. package/dist/core/HttpLeaseTransport.js +37 -3
  7. package/dist/core/HttpLeaseTransport.js.map +1 -1
  8. package/dist/core/LeaseCoordinator.d.ts +22 -0
  9. package/dist/core/LeaseCoordinator.d.ts.map +1 -1
  10. package/dist/core/LeaseCoordinator.js +11 -0
  11. package/dist/core/LeaseCoordinator.js.map +1 -1
  12. package/dist/core/MachinePoolRegistry.d.ts +11 -0
  13. package/dist/core/MachinePoolRegistry.d.ts.map +1 -1
  14. package/dist/core/MachinePoolRegistry.js +29 -1
  15. package/dist/core/MachinePoolRegistry.js.map +1 -1
  16. package/dist/core/PeerPresencePuller.d.ts +26 -1
  17. package/dist/core/PeerPresencePuller.d.ts.map +1 -1
  18. package/dist/core/PeerPresencePuller.js +15 -1
  19. package/dist/core/PeerPresencePuller.js.map +1 -1
  20. package/dist/core/PostUpdateMigrator.d.ts +16 -0
  21. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  22. package/dist/core/PostUpdateMigrator.js +80 -5
  23. package/dist/core/PostUpdateMigrator.js.map +1 -1
  24. package/dist/core/SessionManager.d.ts.map +1 -1
  25. package/dist/core/SessionManager.js +16 -0
  26. package/dist/core/SessionManager.js.map +1 -1
  27. package/dist/core/conversationBindGate.d.ts +62 -0
  28. package/dist/core/conversationBindGate.d.ts.map +1 -0
  29. package/dist/core/conversationBindGate.js +83 -0
  30. package/dist/core/conversationBindGate.js.map +1 -0
  31. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  32. package/dist/core/devGatedFeatures.js +12 -0
  33. package/dist/core/devGatedFeatures.js.map +1 -1
  34. package/dist/core/machineCoherenceAdvert.d.ts +108 -0
  35. package/dist/core/machineCoherenceAdvert.d.ts.map +1 -0
  36. package/dist/core/machineCoherenceAdvert.js +160 -0
  37. package/dist/core/machineCoherenceAdvert.js.map +1 -0
  38. package/dist/core/machineCoherenceManifest.d.ts +133 -0
  39. package/dist/core/machineCoherenceManifest.d.ts.map +1 -0
  40. package/dist/core/machineCoherenceManifest.js +293 -0
  41. package/dist/core/machineCoherenceManifest.js.map +1 -0
  42. package/dist/core/types.d.ts +21 -0
  43. package/dist/core/types.d.ts.map +1 -1
  44. package/dist/core/types.js.map +1 -1
  45. package/dist/monitoring/CommitmentTracker.d.ts.map +1 -1
  46. package/dist/monitoring/CommitmentTracker.js +4 -1
  47. package/dist/monitoring/CommitmentTracker.js.map +1 -1
  48. package/dist/monitoring/MachineCoherenceSentinel.d.ts +240 -0
  49. package/dist/monitoring/MachineCoherenceSentinel.d.ts.map +1 -0
  50. package/dist/monitoring/MachineCoherenceSentinel.js +336 -0
  51. package/dist/monitoring/MachineCoherenceSentinel.js.map +1 -0
  52. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  53. package/dist/monitoring/guardManifest.js +25 -0
  54. package/dist/monitoring/guardManifest.js.map +1 -1
  55. package/dist/monitoring/machineCoherenceEpisode.d.ts +152 -0
  56. package/dist/monitoring/machineCoherenceEpisode.d.ts.map +1 -0
  57. package/dist/monitoring/machineCoherenceEpisode.js +92 -0
  58. package/dist/monitoring/machineCoherenceEpisode.js.map +1 -0
  59. package/dist/monitoring/machineCoherenceEpisodeManager.d.ts +210 -0
  60. package/dist/monitoring/machineCoherenceEpisodeManager.d.ts.map +1 -0
  61. package/dist/monitoring/machineCoherenceEpisodeManager.js +724 -0
  62. package/dist/monitoring/machineCoherenceEpisodeManager.js.map +1 -0
  63. package/dist/monitoring/machineCoherenceEvaluate.d.ts +129 -0
  64. package/dist/monitoring/machineCoherenceEvaluate.d.ts.map +1 -0
  65. package/dist/monitoring/machineCoherenceEvaluate.js +183 -0
  66. package/dist/monitoring/machineCoherenceEvaluate.js.map +1 -0
  67. package/dist/scaffold/templates.d.ts.map +1 -1
  68. package/dist/scaffold/templates.js +7 -1
  69. package/dist/scaffold/templates.js.map +1 -1
  70. package/dist/server/AgentServer.d.ts +1 -0
  71. package/dist/server/AgentServer.d.ts.map +1 -1
  72. package/dist/server/AgentServer.js +1 -0
  73. package/dist/server/AgentServer.js.map +1 -1
  74. package/dist/server/routes.d.ts +2 -1
  75. package/dist/server/routes.d.ts.map +1 -1
  76. package/dist/server/routes.js +141 -88
  77. package/dist/server/routes.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/data/builtin-manifest.json +65 -65
  80. package/src/scaffold/templates.ts +7 -1
  81. package/upgrades/{1.3.747.md → 1.3.748.md} +16 -0
  82. package/upgrades/1.3.749.md +35 -0
  83. package/upgrades/side-effects/action-claim-dryrun-audit-mkdir-fix.md +45 -0
  84. package/upgrades/side-effects/machine-coherence-guard.md +229 -0
  85. package/upgrades/side-effects/slack-followthrough-generalization.md +49 -0
@@ -0,0 +1,724 @@
1
+ /**
2
+ * MachineCoherenceEpisodeManager — the §4 episode STATE MACHINE
3
+ * (machine-coherence-guard §4.1–§4.4, §4.6). Consumes the durable state layer
4
+ * (`machineCoherenceEpisode.ts`) and the confirmed skew rows from the sentinel's
5
+ * §3.3 confirmation engine, and drives the ONE-item, honest-lifecycle,
6
+ * bounded-recurrence episode contract.
7
+ *
8
+ * SLICE STATUS (C₁b-iii-b lands in sub-units — see the side-effects artifact):
9
+ * - THIS FILE (b1): open / join / suspend / resume / close taxonomy (§4.3:
10
+ * restored / suspended-peer-offline|unverifiable / expired-peer-gone /
11
+ * manifest-changed) + §4.4 single escalation + the operator "leave it" ack +
12
+ * the §4.2 VERBATIM attention-item body render. Durable file owned here
13
+ * (§4.6 corrupt → re-baseline). Emits EFFECTS the sentinel executes; the
14
+ * raise/append/resolve effects are gated on selfIsRaiser && live posture
15
+ * (dry-run + non-raiser run the machine + jsonl, never speak).
16
+ * - NOT YET (b2): §4.5 recurrence damper + per-day cap + the R3-M5 SHARED
17
+ * per-episode append budget (this slice's appends are unbudgeted).
18
+ * - NOT YET (b3): the §4.2.1 pendingFix reply-recognition flow.
19
+ *
20
+ * Supervision tier (N6): Tier 0 — deterministic; no LLM anywhere in this path
21
+ * (reply recognition, when it lands in b3, lives in the CONVERSATIONAL agent,
22
+ * never here — D17 intact).
23
+ */
24
+ import crypto from 'node:crypto';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { maybeRotateJsonl } from '../utils/jsonl-rotation.js';
28
+ import { COHERENCE_CRITICAL_FLAGS } from '../core/machineCoherenceManifest.js';
29
+ /** Local manifest lookup (avoids widening the increment-A module + its ratchet). */
30
+ function getFlagByKey(key) {
31
+ return COHERENCE_CRITICAL_FLAGS.find((f) => f.key === key);
32
+ }
33
+ import { emptyRecurrence, mintEpisodeId, readEpisodeFile, writeEpisodeFile, } from './machineCoherenceEpisode.js';
34
+ /**
35
+ * The two row classes §4.2.1-iii NEVER auto-proposes: `developmentAgent` (the F4
36
+ * root switch — flipping it flips every omitted dev-gated resolution) and the
37
+ * guard's OWN posture row (flipping the guard live is a §7 graduation action).
38
+ */
39
+ const NEVER_AUTO_PROPOSE_KEYS = new Set(['developmentAgent', 'monitoring.machineCoherence']);
40
+ const EMPTY_CLOSES = () => ({});
41
+ export class MachineCoherenceEpisodeManager {
42
+ stateDir;
43
+ cfg;
44
+ file;
45
+ /** In-memory tick counters (R2-N3 — never persisted; warm-up-absorbed on restart). */
46
+ resolveCleanTicks = 0;
47
+ /** The last self machine id seen on reconcile (for approveFix's raiser==divergent check). */
48
+ lastSelfMachineId = null;
49
+ /** The last nickname resolver seen on reconcile (for out-of-tick approveFix notes). */
50
+ lastNicknameOf;
51
+ /** In-memory verify-tick counter for an executing-verifying fix (§4.2.1-v). */
52
+ fixVerifyTicks = 0;
53
+ counters = {
54
+ episodesOpened: 0, wouldRaise: 0, itemsRaised: 0, suspends: 0, resumes: 0, closes: EMPTY_CLOSES(), escalations: 0,
55
+ };
56
+ logPath;
57
+ constructor(stateDir, cfg) {
58
+ this.stateDir = stateDir;
59
+ this.cfg = cfg;
60
+ // Agent-root logs dir (stateDir is `<agent>/.instar`; logs live at `<agent>/logs`).
61
+ this.logPath = path.join(stateDir, '..', 'logs', 'machine-coherence.jsonl');
62
+ const read = readEpisodeFile(stateDir);
63
+ if (read.status === 'ok') {
64
+ this.file = read.file;
65
+ }
66
+ else {
67
+ // Absent → fresh; corrupt → re-baseline WITHOUT crashing (§4.6, the
68
+ // GuardPostureProbe pattern). A corrupt re-baseline drops any in-flight
69
+ // pendingFix (R3-L3). The adopt-or-resolve of a locally-held open item is
70
+ // driven on the next reconcile by the normal open/resolve path (the item
71
+ // id is derived from the fresh episodeId; a stale item with a different
72
+ // id is left for the operator ack — a duplicate inside §0(b)'s envelope).
73
+ this.file = { version: 1, episode: null, recurrence: emptyRecurrence() };
74
+ if (read.status === 'corrupt') {
75
+ this.log({ t: 'rebaseline', reason: read.reason });
76
+ }
77
+ }
78
+ }
79
+ /** The item id for an episode (§4.2 — idempotent on the createAttentionItem chokepoint). */
80
+ itemId(episodeId) {
81
+ return `machine-coherence:${episodeId}`;
82
+ }
83
+ /** Only the elected raiser on a LIVE (enabled && !dryRun) guard actually speaks. */
84
+ speaks(input) {
85
+ return this.cfg.enabled && !this.cfg.dryRun && input.raiserMachineId === input.selfMachineId;
86
+ }
87
+ /**
88
+ * One reconcile pass (rides the sentinel tick, AFTER confirmation). Returns
89
+ * the effects the caller executes; the durable file + jsonl are written here.
90
+ */
91
+ reconcile(input) {
92
+ const effects = [];
93
+ const { now } = input;
94
+ this.lastSelfMachineId = input.selfMachineId;
95
+ this.lastNicknameOf = input.nicknameOf;
96
+ const rowById = new Map(input.confirmedRows.map((r) => [r.identity, r]));
97
+ const confirmedIds = new Set(rowById.keys());
98
+ // ── No open episode: REOPEN a recently-closed one whose row set intersects
99
+ // (§4.5 recurrence damper), else OPEN a fresh episode. ──
100
+ if (!this.file.episode) {
101
+ if (confirmedIds.size === 0)
102
+ return effects; // nothing to do
103
+ this.pruneRecurrence(now);
104
+ const reopen = this.findReopenTarget([...confirmedIds], now);
105
+ if (reopen)
106
+ this.reopenEpisode(input, [...confirmedIds], reopen, effects);
107
+ else
108
+ this.openEpisode(input, [...confirmedIds], effects);
109
+ return effects;
110
+ }
111
+ const ep = this.file.episode;
112
+ // ── Manifest-membership removal (§4.3 manifest-changed): an episode row's
113
+ // key is no longer a compared manifest key → the row vanishes for a
114
+ // NON-skew reason. If EVERY episode row is gone AND at least one vanished
115
+ // because its manifest key retired, close manifest-changed. ──
116
+ const survivingIds = ep.skewRowIdentities.filter((id) => confirmedIds.has(id));
117
+ // ── Suspension: a skew participant left the VERIFIABLE (compared) set ──
118
+ const participants = this.episodeParticipants(ep);
119
+ const offline = [...participants].filter((m) => !input.onlineMachineIds.has(m));
120
+ const unverifiable = [...participants].filter((m) => input.onlineMachineIds.has(m) && !input.comparedMachineIds.has(m));
121
+ const shouldSuspend = offline.length > 0 || unverifiable.length > 0;
122
+ if (shouldSuspend) {
123
+ this.applySuspend(input, ep, offline, unverifiable, effects);
124
+ return effects;
125
+ }
126
+ // A previously-suspended episode whose participants are all verifiable again → resume.
127
+ if (ep.suspended) {
128
+ this.applyResume(input, ep, effects);
129
+ // fall through to resolve/join evaluation this same tick
130
+ }
131
+ // ── All rows cleared (skew gone) → resolve-ticks toward `restored` ──
132
+ if (survivingIds.length === 0) {
133
+ // Distinguish manifest-changed (a key retired) from restored (skew healed).
134
+ const anyKeyRetired = ep.skewRowIdentities.some((id) => this.rowKeyRetired(id, input));
135
+ if (anyKeyRetired && !this.anyEpisodeKeyStillCompared(ep, input)) {
136
+ this.closeEpisode(input, ep, 'manifest-changed', effects);
137
+ return effects;
138
+ }
139
+ this.resolveCleanTicks += 1;
140
+ if (this.resolveCleanTicks >= this.cfg.resolveTicks) {
141
+ this.closeEpisode(input, ep, 'restored', effects);
142
+ }
143
+ return effects;
144
+ }
145
+ // Skew still present → reset the resolve clock; JOIN any newly-confirmed rows.
146
+ this.resolveCleanTicks = 0;
147
+ const newRows = [...confirmedIds].filter((id) => !ep.skewRowIdentities.includes(id));
148
+ if (newRows.length > 0) {
149
+ // §4.2.1-i: a skew-set change INVALIDATES a not-yet-executed pendingFix.
150
+ this.invalidatePendingFix(input, ep, 'skew-set-changed', effects);
151
+ this.joinRows(input, ep, newRows, effects);
152
+ }
153
+ // §4.2.1-v verify: an executing-verifying fix whose row hasn't cleared within
154
+ // fixVerifyTicks fires ONE honest failure append and clears (retry needs
155
+ // fresh approval); the episode stays open (closure belongs to §4.3 alone).
156
+ this.verifyPendingFix(input, ep, confirmedIds, effects);
157
+ // ── §4.4 escalation: open past escalateAfterMs (unsuspended clock), once,
158
+ // suppressed by the operator "leave it" ack. ──
159
+ this.maybeEscalate(input, ep, effects);
160
+ return effects;
161
+ }
162
+ episodeParticipants(ep) {
163
+ const s = new Set();
164
+ for (const id of ep.skewRowIdentities)
165
+ for (const m of participantsOf(id))
166
+ s.add(m);
167
+ return s;
168
+ }
169
+ /** A row's manifest key retired from the intersection (only meaningful for flag rows). */
170
+ rowKeyRetired(rowIdentity, input) {
171
+ const key = keyOf(rowIdentity);
172
+ if (dimensionOf(rowIdentity) !== 'flag')
173
+ return false;
174
+ // Retired ⇔ the key is no longer a manifest flag AND is not present as a live
175
+ // divergence — i.e. the intersection dropped it. We approximate with the
176
+ // manifest catalog: a key absent from the manifest is retired.
177
+ return !getFlagByKey(key);
178
+ }
179
+ anyEpisodeKeyStillCompared(ep, input) {
180
+ return ep.skewRowIdentities.some((id) => {
181
+ if (dimensionOf(id) !== 'flag')
182
+ return true; // version/manifest/protocol always compared
183
+ return !!getFlagByKey(keyOf(id));
184
+ });
185
+ }
186
+ openEpisode(input, rowIds, effects) {
187
+ const openedAtMs = input.now;
188
+ const episodeId = mintEpisodeId(openedAtMs);
189
+ const ep = {
190
+ episodeId,
191
+ openedAtMs,
192
+ skewRowIdentities: rowIds,
193
+ recurrence: emptyRecurrence(),
194
+ };
195
+ this.counters.episodesOpened += 1;
196
+ // §4.5 per-day cap: at most maxEpisodeItemsPerDay NEW items per rolling 24 h.
197
+ this.pruneRecurrence(input.now);
198
+ const itemsToday = this.file.recurrence.newItemTimestamps.length;
199
+ const overCap = itemsToday >= this.cfg.maxEpisodeItemsPerDay;
200
+ if (this.speaks(input) && !overCap) {
201
+ // Record the §4.2.1 proposal BEFORE rendering so the body can name it.
202
+ this.maybeProposeFix(input, ep);
203
+ const body = this.renderBody(input, ep);
204
+ ep.itemRaisedAt = input.now;
205
+ ep.attentionItemId = this.itemId(episodeId);
206
+ this.counters.itemsRaised += 1;
207
+ this.file.recurrence.newItemTimestamps.push(input.now);
208
+ effects.push({ kind: 'raise', itemId: ep.attentionItemId, title: body.title, summary: body.summary, description: body.description });
209
+ }
210
+ else if (this.speaks(input) && overCap) {
211
+ // Give up LOUDLY, once per window (P19): jsonl-only item, one final note.
212
+ this.maybeCapGiveup(input, effects);
213
+ this.counters.wouldRaise += 1;
214
+ }
215
+ else {
216
+ this.counters.wouldRaise += 1;
217
+ }
218
+ this.file.episode = ep;
219
+ this.persist();
220
+ this.log({ t: 'open', episodeId, rows: rowIds, spoke: this.speaks(input), overCap });
221
+ }
222
+ /** §4.5 reopen: a newly-confirmed skew intersecting a recently-closed episode
223
+ * re-opens it — SAME item un-resolved + one short append, no new item (reopens
224
+ * don't count toward the per-day cap). Latched-flapping bounds the appends. */
225
+ reopenEpisode(input, rowIds, target, effects) {
226
+ const openedAtMs = input.now;
227
+ const episodeId = mintEpisodeId(openedAtMs);
228
+ const latch = (this.file.recurrence.reopenLatch ??= { latched: false, reopenCount: 0, windowStartMs: input.now });
229
+ if (input.now - latch.windowStartMs > this.cfg.reopenWindowMs) {
230
+ latch.reopenCount = 0;
231
+ latch.windowStartMs = input.now;
232
+ latch.latched = false;
233
+ }
234
+ latch.reopenCount += 1;
235
+ const ep = {
236
+ episodeId,
237
+ openedAtMs,
238
+ skewRowIdentities: rowIds,
239
+ attentionItemId: target.itemId,
240
+ itemRaisedAt: target.itemId ? input.now : undefined,
241
+ reopenCount: latch.reopenCount,
242
+ recurrence: emptyRecurrence(),
243
+ };
244
+ this.file.episode = ep;
245
+ this.counters.episodesOpened += 1;
246
+ // Enter latched-flapping after flappingLatchReopens re-opens in the window.
247
+ if (!latch.latched && latch.reopenCount > this.cfg.flappingLatchReopens) {
248
+ latch.latched = true;
249
+ if (this.speaks(input) && ep.attentionItemId)
250
+ effects.push({ kind: 'append', itemId: ep.attentionItemId, text: 'this divergence is flapping — recording silently until it stabilizes' });
251
+ }
252
+ else if (!latch.latched && this.speaks(input) && ep.attentionItemId) {
253
+ // A reopened item is un-resolved (re-raised OPEN) with one short append.
254
+ effects.push({ kind: 'raise', itemId: ep.attentionItemId, title: 'Machine coherence: divergence is back', summary: 'this divergence is back — re-opening', description: 'this divergence is back — re-opening' });
255
+ }
256
+ // else: latched → jsonl-only (no append).
257
+ this.persist();
258
+ this.log({ t: 'reopen', episodeId, reusedItem: target.itemId, reopenCount: latch.reopenCount, latched: latch.latched });
259
+ }
260
+ maybeCapGiveup(input, effects) {
261
+ const rec = this.file.recurrence;
262
+ if (rec.capGiveupAtMs && input.now - rec.capGiveupAtMs < 86_400_000)
263
+ return; // once per 24 h
264
+ rec.capGiveupAtMs = input.now;
265
+ const mostRecent = [...rec.recentlyClosed].reverse().find((c) => c.itemId)?.itemId ?? this.file.episode?.attentionItemId;
266
+ if (mostRecent)
267
+ effects.push({ kind: 'append', itemId: mostRecent, text: 'coherence is flapping faster than I\'ll alarm — further episodes today are recorded silently; see /pool/machine-coherence' });
268
+ this.log({ t: 'cap-giveup' });
269
+ }
270
+ joinRows(input, ep, newRowIds, effects) {
271
+ ep.skewRowIdentities = [...ep.skewRowIdentities, ...newRowIds];
272
+ this.pushFlapAppend(input, ep, `Another divergence joined this episode: ${newRowIds.map((id) => keyOf(id)).join(', ')}.`, false, effects);
273
+ this.persist();
274
+ this.log({ t: 'row-join', episodeId: ep.episodeId, rows: newRowIds });
275
+ }
276
+ applySuspend(input, ep, offline, unverifiable, effects) {
277
+ if (ep.suspended)
278
+ return; // already suspended — idempotent (append only on transition)
279
+ ep.suspended = true;
280
+ ep.suspendReason = offline.length > 0 ? 'peer-offline' : 'peer-unverifiable';
281
+ this.resolveCleanTicks = 0;
282
+ this.counters.suspends += 1;
283
+ // §4.2.1-i/iv: suspension INVALIDATES a proposed/approved-holding pendingFix
284
+ // (an executing-verifying fix is exempt — its write already happened; R5-N2).
285
+ this.invalidatePendingFix(input, ep, 'suspended', effects);
286
+ const who = input.nicknameOf(offline[0] ?? unverifiable[0]);
287
+ const text = offline.length > 0
288
+ ? `the divergent machine (${who}) went offline — holding this open; I'll re-check when it returns`
289
+ : `${who} is online but I can't read its coherence card — holding`;
290
+ this.pushFlapAppend(input, ep, text, true, effects);
291
+ this.persist();
292
+ this.log({ t: 'suspend', episodeId: ep.episodeId, reason: ep.suspendReason });
293
+ }
294
+ // ── §4.2.1 pendingFix state machine (proposal → approved-holding →
295
+ // executing-verifying; operator-uid-gated; single-flight; loud failure) ──
296
+ /** Record the FIRST auto-proposable row as a `proposed` pendingFix (§4.2.1). A
297
+ * version/manifest/protocol row (no config override to write) and the two
298
+ * excluded flag classes (§4.2.1-iii) are NEVER auto-proposed. */
299
+ maybeProposeFix(input, ep) {
300
+ if (ep.pendingFix)
301
+ return; // cardinality: one at a time (R3-N8)
302
+ const row = ep.skewRowIdentities.find((id) => this.isAutoProposable(id));
303
+ if (!row)
304
+ return;
305
+ const key = keyOf(row);
306
+ const targetMachineId = divergentMachineFor(row, input);
307
+ const targetValue = plainValue(targetValueClassFor(row, input));
308
+ ep.pendingFix = {
309
+ state: 'proposed',
310
+ rowIdentity: row,
311
+ key,
312
+ dimension: 'flag',
313
+ targetMachineId,
314
+ targetValue,
315
+ proposalHash: proposalHash(ep.episodeId, key, targetMachineId, targetValue),
316
+ };
317
+ }
318
+ /** Only FLAG rows carry a config override to equalize; the two §4.2.1-iii root
319
+ * classes are excluded (manual decision block instead). */
320
+ isAutoProposable(rowIdentity) {
321
+ return dimensionOf(rowIdentity) === 'flag' && !NEVER_AUTO_PROPOSE_KEYS.has(keyOf(rowIdentity));
322
+ }
323
+ /**
324
+ * Operator approval of the recorded proposal (§4.2.1-i). The caller (the
325
+ * conversational reply path) has ALREADY verified the sender is the topic's
326
+ * verified operator (Know Your Principal) and passes `verifiedOperator`; the
327
+ * `proposalHash` is the display-integrity authority (a reply confirms ONLY the
328
+ * exact recorded proposal). Returns the transition + (for divergent==raiser)
329
+ * an `execute-fix` effect the caller runs through the atomic config funnel.
330
+ */
331
+ approveFix(args) {
332
+ const effects = [];
333
+ const ep = this.file.episode;
334
+ if (!ep || !ep.pendingFix)
335
+ return { result: { ok: false, reason: 'no-open-proposal' }, effects };
336
+ const pf = ep.pendingFix;
337
+ if (!args.verifiedOperator)
338
+ return { result: { ok: false, reason: 'not-verified-operator' }, effects }; // Know Your Principal
339
+ if (pf.proposalHash !== args.proposalHash)
340
+ return { result: { ok: false, reason: 'proposal-lapsed' }, effects };
341
+ if (pf.state !== 'proposed')
342
+ return { result: { ok: false, reason: 'already-in-flight' }, effects }; // single-flight (R4-N4)
343
+ pf.approvedAtMs = args.now;
344
+ if (pf.targetMachineId === this.lastSelfMachineId) {
345
+ // Divergent == raiser (mechanized): the raiser's own server writes + restarts.
346
+ pf.state = 'executing-verifying';
347
+ this.fixVerifyTicks = 0;
348
+ const cfgPath = getFlagByKey(pf.key)?.configPath ?? pf.key;
349
+ if (ep.attentionItemId)
350
+ effects.push({ kind: 'execute-fix', itemId: ep.attentionItemId, key: pf.key, configPath: cfgPath, targetValue: pf.targetValue });
351
+ }
352
+ else {
353
+ // Divergent == any other machine (held): the write is the agent's own hand
354
+ // on that machine; v1 has no cross-machine execution trigger (§4.2.1-iv).
355
+ pf.state = 'approved-holding';
356
+ if (ep.attentionItemId)
357
+ effects.push({ kind: 'append', itemId: ep.attentionItemId, text: `approved — I'll apply this from my own hands on ${this.nick(pf.targetMachineId)}; I'll confirm here when it lands` });
358
+ }
359
+ this.persist();
360
+ this.log({ t: 'fix-approved', episodeId: ep.episodeId, state: pf.state, target: pf.targetMachineId });
361
+ return { result: { ok: true, state: pf.state }, effects };
362
+ }
363
+ /** Invalidate a NOT-YET-EXECUTED pendingFix (§4.2.1-i). An executing-verifying
364
+ * fix is exempt (its durable write already happened — R5-N2). */
365
+ invalidatePendingFix(input, ep, reason, effects) {
366
+ const pf = ep.pendingFix;
367
+ if (!pf || pf.state === 'executing-verifying')
368
+ return;
369
+ ep.pendingFix = undefined;
370
+ this.fixVerifyTicks = 0;
371
+ if (reason === 'suspended' && pf.state === 'approved-holding' && this.speaks(input) && ep.attentionItemId) {
372
+ effects.push({ kind: 'append', itemId: ep.attentionItemId, text: `the fix you approved is paused — ${this.nick(pf.targetMachineId)} is unverifiable/offline; I'll re-propose when it returns` });
373
+ }
374
+ this.log({ t: 'fix-invalidated', episodeId: ep.episodeId, reason, wasState: pf.state });
375
+ }
376
+ /** §4.2.1-v verify: an executing-verifying fix whose row hasn't cleared within
377
+ * fixVerifyTicks fires ONE loud failure append + clears (episode stays open). */
378
+ verifyPendingFix(input, ep, confirmedIds, effects) {
379
+ const pf = ep.pendingFix;
380
+ if (!pf || pf.state !== 'executing-verifying')
381
+ return;
382
+ if (!confirmedIds.has(pf.rowIdentity)) {
383
+ ep.pendingFix = undefined;
384
+ this.fixVerifyTicks = 0;
385
+ this.log({ t: 'fix-cleared-row-gone', episodeId: ep.episodeId });
386
+ return;
387
+ }
388
+ this.fixVerifyTicks += 1;
389
+ if (this.fixVerifyTicks >= this.cfg.fixVerifyTicks) {
390
+ ep.pendingFix = undefined;
391
+ this.fixVerifyTicks = 0;
392
+ if (this.speaks(input) && ep.attentionItemId)
393
+ effects.push({ kind: 'append', itemId: ep.attentionItemId, text: `the fix didn't take — ${pf.key} is still divergent; a retry needs your fresh approval` });
394
+ this.persist();
395
+ this.log({ t: 'fix-failed', episodeId: ep.episodeId, key: pf.key });
396
+ }
397
+ }
398
+ nick(machineId) {
399
+ return this.lastNicknameOf ? this.lastNicknameOf(machineId) : machineId;
400
+ }
401
+ /**
402
+ * §4.5 SHARED per-episode append budget (R3-M5): all intra-episode FLAP-class
403
+ * appends (row-join, suspend/resume, takeover re-arm) share one rolling budget
404
+ * (`episodeAppendBudget` per `episodeAppendWindowMs`). ONE slot is RESERVED per
405
+ * window for the first suspend/resume transition (R4-L6 — the clock-changing
406
+ * note is never crowded out). Past the budget the episode enters latched
407
+ * flapping: ONE "flapping — recording silently" note, then jsonl-only until the
408
+ * rolling count falls back below budget (R4-N3/L7). Structural appends
409
+ * (escalation, cap give-up) do NOT ride this budget.
410
+ */
411
+ pushFlapAppend(input, ep, text, isSuspendResume, effects) {
412
+ if (!this.speaks(input) || !ep.attentionItemId)
413
+ return;
414
+ this.pruneRecurrence(input.now);
415
+ const b = (this.file.recurrence.appendBudget ??= { appendTimestamps: [], latched: false });
416
+ // Reserved slot: the first suspend/resume per window always speaks.
417
+ if (isSuspendResume && (b.reservedSuspendResumeAtMs === undefined || input.now - b.reservedSuspendResumeAtMs > this.cfg.episodeAppendWindowMs)) {
418
+ b.reservedSuspendResumeAtMs = input.now;
419
+ b.appendTimestamps.push(input.now);
420
+ effects.push({ kind: 'append', itemId: ep.attentionItemId, text });
421
+ return;
422
+ }
423
+ if (b.latched) {
424
+ if (b.appendTimestamps.length < this.cfg.episodeAppendBudget)
425
+ b.latched = false; // exit
426
+ else
427
+ return; // jsonl-only while latched
428
+ }
429
+ if (b.appendTimestamps.length >= this.cfg.episodeAppendBudget) {
430
+ b.latched = true;
431
+ b.appendTimestamps.push(input.now);
432
+ effects.push({ kind: 'append', itemId: ep.attentionItemId, text: 'this divergence is flapping — recording silently until it stabilizes' });
433
+ return;
434
+ }
435
+ b.appendTimestamps.push(input.now);
436
+ effects.push({ kind: 'append', itemId: ep.attentionItemId, text });
437
+ }
438
+ /** Lazy rolling-window eviction (R3-L2 — never triggers a write on its own). */
439
+ pruneRecurrence(now) {
440
+ const rec = this.file.recurrence;
441
+ rec.newItemTimestamps = rec.newItemTimestamps.filter((t) => now - t < 86_400_000);
442
+ rec.recentlyClosed = rec.recentlyClosed.filter((c) => now - c.closedAtMs < this.cfg.reopenWindowMs);
443
+ if (rec.appendBudget)
444
+ rec.appendBudget.appendTimestamps = rec.appendBudget.appendTimestamps.filter((t) => now - t < this.cfg.episodeAppendWindowMs);
445
+ }
446
+ findReopenTarget(rowIds, now) {
447
+ const want = new Set(rowIds);
448
+ for (const c of this.file.recurrence.recentlyClosed) {
449
+ if (now - c.closedAtMs >= this.cfg.reopenWindowMs)
450
+ continue;
451
+ if (c.rowIdentities.some((id) => want.has(id)))
452
+ return c;
453
+ }
454
+ return null;
455
+ }
456
+ applyResume(input, ep, effects) {
457
+ ep.suspended = false;
458
+ ep.suspendReason = undefined;
459
+ this.counters.resumes += 1;
460
+ // Resume is silent per §4.3 (same item, no new topic); jsonl records it.
461
+ this.persist();
462
+ this.log({ t: 'resume', episodeId: ep.episodeId });
463
+ }
464
+ maybeEscalate(input, ep, effects) {
465
+ if (ep.escalationAppended || ep.operatorAck || ep.suspended)
466
+ return;
467
+ if (input.now - ep.openedAtMs < this.cfg.escalateAfterMs)
468
+ return;
469
+ ep.escalationAppended = true;
470
+ this.counters.escalations += 1;
471
+ if (this.speaks(input) && ep.attentionItemId) {
472
+ effects.push({ kind: 'append', itemId: ep.attentionItemId, text: 'still divergent after 24h' });
473
+ }
474
+ this.persist();
475
+ this.log({ t: 'escalate', episodeId: ep.episodeId });
476
+ }
477
+ closeEpisode(input, ep, reason, effects) {
478
+ // Record the close in the recurrence memory (R2-N2 — outlives close; the
479
+ // item id rides along so a §4.5 reopen reuses the SAME item/topic).
480
+ this.file.recurrence.recentlyClosed.push({ rowIdentities: [...ep.skewRowIdentities], closedAtMs: input.now, itemId: ep.attentionItemId });
481
+ if (this.speaks(input) && ep.attentionItemId && reason === 'restored') {
482
+ const keys = ep.skewRowIdentities.map((id) => keyOf(id)).join(', ');
483
+ const nicks = [...this.episodeParticipants(ep)].map((m) => input.nicknameOf(m)).join(', ');
484
+ effects.push({ kind: 'resolve', itemId: ep.attentionItemId, note: `machine-coherence restored — ${keys} now agree across ${nicks}, held for ${this.cfg.resolveTicks} ticks` });
485
+ }
486
+ else if (this.speaks(input) && ep.attentionItemId) {
487
+ effects.push({ kind: 'resolve', itemId: ep.attentionItemId, note: this.closeNote(reason, ep, input) });
488
+ }
489
+ this.counters.closes[reason] = (this.counters.closes[reason] ?? 0) + 1;
490
+ this.resolveCleanTicks = 0;
491
+ this.file.episode = null;
492
+ this.persist();
493
+ this.log({ t: 'close', episodeId: ep.episodeId, reason });
494
+ }
495
+ closeNote(reason, ep, input) {
496
+ switch (reason) {
497
+ case 'expired-peer-gone':
498
+ return 'the divergent machine never came back — closing; a fresh divergence will open a new episode';
499
+ case 'manifest-changed': {
500
+ const keys = ep.skewRowIdentities.map((id) => keyOf(id)).join(', ');
501
+ return `${keys} are no longer compared under the new manifest — closing; not a restoration claim`;
502
+ }
503
+ default:
504
+ return `episode closed — ${reason}`;
505
+ }
506
+ }
507
+ /**
508
+ * Set (or clear) the durable operator "leave it" ack (§4.2 / R4-N2). Called by
509
+ * the conversational reply path. Suppresses the §4.4 escalation for this
510
+ * episode; cleared on a genuine §4.5 recurrence re-open (b2).
511
+ */
512
+ setOperatorAck(ack) {
513
+ if (!this.file.episode)
514
+ return;
515
+ this.file.episode.operatorAck = ack;
516
+ this.persist();
517
+ this.log({ t: ack ? 'operator-ack' : 'operator-ack-clear', episodeId: this.file.episode.episodeId });
518
+ }
519
+ /** A suspended episode past the expiry closes `expired-peer-gone` (§4.3). Called each tick by the sentinel. */
520
+ expireIfStale(now, nicknameOf) {
521
+ const effects = [];
522
+ const ep = this.file.episode;
523
+ if (!ep || !ep.suspended)
524
+ return effects;
525
+ // Suspended-since is approximated by openedAt when no explicit suspend-start
526
+ // is tracked in this slice; the b2 recurrence work adds the precise anchor.
527
+ if (now - ep.openedAtMs < this.cfg.suspendedEpisodeExpiryMs)
528
+ return effects;
529
+ this.closeEpisode({ now, nicknameOf }, ep, 'expired-peer-gone', effects);
530
+ return effects;
531
+ }
532
+ status() {
533
+ const ep = this.file.episode;
534
+ const pf = ep?.pendingFix;
535
+ return {
536
+ openEpisode: ep ? {
537
+ episodeId: ep.episodeId,
538
+ rows: ep.skewRowIdentities.length,
539
+ suspended: !!ep.suspended,
540
+ itemRaisedAt: ep.itemRaisedAt ? new Date(ep.itemRaisedAt).toISOString() : null,
541
+ pendingFix: pf ? { state: pf.state, key: pf.key, targetMachineId: pf.targetMachineId, targetValue: pf.targetValue, proposalHash: pf.proposalHash } : null,
542
+ } : null,
543
+ counters: this.counters,
544
+ };
545
+ }
546
+ /** The current proposal's hash (the reply-recognition authority), or null. */
547
+ currentProposalHash() {
548
+ return this.file.episode?.pendingFix?.proposalHash ?? null;
549
+ }
550
+ persist() {
551
+ writeEpisodeFile(this.stateDir, this.file);
552
+ }
553
+ log(row) {
554
+ try {
555
+ fs.mkdirSync(path.dirname(this.logPath), { recursive: true });
556
+ // Byte-cap safety rotation on append (O(1) size check). The precise 30-day
557
+ // time-based prune (SessionWatchdog `rotateLog` shape) rides the wiring
558
+ // slice as a periodic call on the sentinel cadence — noted in the artifact.
559
+ maybeRotateJsonl(this.logPath);
560
+ fs.appendFileSync(this.logPath, JSON.stringify({ ts: new Date().toISOString(), ...row }) + '\n');
561
+ }
562
+ catch {
563
+ /* jsonl is observability — never fail a transition on a log write */
564
+ }
565
+ }
566
+ /**
567
+ * Render the §4.2 attention-item body VERBATIM (M9 — impact first, plain
568
+ * language; a fix the agent performs on approval; technical detail last). The
569
+ * peer-influenced strings (nicknames, effective values) are clamp-bounded
570
+ * upstream (§4.2 exposure invariant) — rendered as data, never instructions.
571
+ */
572
+ renderBody(input, ep) {
573
+ const rows = ep.skewRowIdentities;
574
+ const nicks = [...this.episodeParticipants(ep)].map((m) => input.nicknameOf(m));
575
+ const nickList = joinNicknames(nicks);
576
+ // 1. Impact first (the manifest guarantee, per row, by nickname).
577
+ const guarantees = uniq(rows.map((id) => guaranteeFor(id))).join('; ');
578
+ const summary = `My machines have drifted apart — ${nickList} aren't running as the same me: ${guarantees}.`;
579
+ // 2. A complete proposed fix (direction is canonical + always named, §4.2.1-ii),
580
+ // OR a MANUAL decision block for the two excluded row classes (§4.2.1-iii:
581
+ // developmentAgent + the guard's own posture — flipping either is a
582
+ // graduation/blast-radius action the agent never auto-proposes).
583
+ const first = rows[0];
584
+ let fixLine;
585
+ if (!this.isAutoProposable(first)) {
586
+ const divergentNick = input.nicknameOf(divergentMachineFor(first, input));
587
+ fixLine = `This one I won't touch on my own — ${keyOf(first)} is a root switch (flipping it changes far more than this setting). It differs on ${divergentNick} vs ${restOfPoolExcluding(this.episodeParticipants(ep), divergentMachineFor(first, input), input.nicknameOf)}. Tell me which way you want it and I'll set it — I'll do nothing until you say.`;
588
+ }
589
+ else {
590
+ const divergent = divergentMachineFor(first, input); // the machine to change
591
+ const targetValue = plainValue(targetValueClassFor(first, input));
592
+ const feature = plainFeatureName(first);
593
+ const divergentNick = input.nicknameOf(divergent);
594
+ const restOfPool = joinNicknames([...this.episodeParticipants(ep)].filter((m) => m !== divergent).map((m) => input.nicknameOf(m)));
595
+ if (divergent === input.selfMachineId) {
596
+ const holdsLease = input.leaseHolderMachineId === input.selfMachineId;
597
+ const peerNick = input.nicknameOf([...this.episodeParticipants(ep)].find((m) => m !== divergent) ?? divergent);
598
+ const leaseClause = holdsLease
599
+ ? `; I currently hold the serving lease, so the restart hands serving to ${peerNick} for that blip (a failover, named, not a surprise)`
600
+ : '';
601
+ fixLine = `Reply **fix it** and I'll switch ${feature} to ${targetValue} here on ${divergentNick} to match ${restOfPool}, then restart my own server — a ~30-second blip${leaseClause}.`;
602
+ }
603
+ else {
604
+ fixLine = `Reply **fix it** and I'll switch ${feature} to ${targetValue} on ${divergentNick} from my own hands there — no remote config-write exists, so I'll confirm here when it lands (and tell you loudly if it doesn't within a few minutes).`;
605
+ }
606
+ }
607
+ const leaveLine = `Or reply **leave it** and I'll keep this episode open without further nagging.`;
608
+ // 3. Technical detail last (secondary block).
609
+ const tech = rows.map((id) => `${dimensionOf(id)} · ${keyOf(id)} · ${renderValueClasses(id, input.nicknameOf)}`).join('\n');
610
+ const title = 'Machine coherence: my machines have drifted apart';
611
+ const description = `${summary}\n\n${fixLine}\n\n${leaveLine}\n\nTechnical detail:\n${tech}`;
612
+ return { title, summary, description };
613
+ }
614
+ }
615
+ /** The §4.2.1-i display-integrity authority: a hash over the exact proposal tuple
616
+ * (episodeId|key|targetMachine|targetValue). A reply confirms ONLY this tuple. */
617
+ function proposalHash(episodeId, key, targetMachineId, targetValue) {
618
+ return crypto.createHash('sha256').update(`${episodeId}|${key}|${targetMachineId}|${targetValue}`).digest('hex').slice(0, 16);
619
+ }
620
+ function restOfPoolExcluding(participants, exclude, nicknameOf) {
621
+ return joinNicknames([...participants].filter((m) => m !== exclude).map((m) => nicknameOf(m)));
622
+ }
623
+ // ── Pure row-identity helpers (the N1 identity is `dimension|key|sorted(id=vc)`) ──
624
+ function dimensionOf(rowIdentity) {
625
+ return rowIdentity.split('|', 1)[0] ?? '';
626
+ }
627
+ function keyOf(rowIdentity) {
628
+ return rowIdentity.split('|')[1] ?? '';
629
+ }
630
+ function participantsOf(rowIdentity) {
631
+ const tail = rowIdentity.split('|').slice(2).join('|');
632
+ if (!tail)
633
+ return [];
634
+ return tail.split(',').map((p) => p.split('=')[0]).filter(Boolean);
635
+ }
636
+ function valueClassesOf(rowIdentity) {
637
+ const tail = rowIdentity.split('|').slice(2).join('|');
638
+ const out = {};
639
+ if (!tail)
640
+ return out;
641
+ for (const p of tail.split(',')) {
642
+ const [m, v] = p.split('=');
643
+ if (m)
644
+ out[m] = v ?? '';
645
+ }
646
+ return out;
647
+ }
648
+ function renderValueClasses(rowIdentity, nicknameOf) {
649
+ const vc = valueClassesOf(rowIdentity);
650
+ return Object.entries(vc).map(([m, v]) => `${nicknameOf(m)}=${v}`).join(', ');
651
+ }
652
+ function guaranteeFor(rowIdentity) {
653
+ const dim = dimensionOf(rowIdentity);
654
+ if (dim === 'flag') {
655
+ const f = getFlagByKey(keyOf(rowIdentity));
656
+ if (f)
657
+ return f.guarantee;
658
+ }
659
+ if (dim === 'version')
660
+ return 'the two machines are running different versions of me';
661
+ if (dim === 'manifest')
662
+ return 'the two machines built the same version differently (a dirty or locally-built dist)';
663
+ if (dim === 'protocol')
664
+ return 'the two machines speak different mesh protocol versions';
665
+ return 'a cross-machine guarantee is halved';
666
+ }
667
+ function plainFeatureName(rowIdentity) {
668
+ const dim = dimensionOf(rowIdentity);
669
+ if (dim === 'flag') {
670
+ const f = getFlagByKey(keyOf(rowIdentity));
671
+ if (f)
672
+ return f.guarantee; // the plain-language framing (§4.2 point 2)
673
+ }
674
+ return keyOf(rowIdentity);
675
+ }
676
+ /** §4.2.1-ii direction: equalize toward the pool-majority value class; with no
677
+ * majority (the 2-machine case) toward the serving-lease-holder's value. */
678
+ function targetValueClassFor(rowIdentity, input) {
679
+ const vc = valueClassesOf(rowIdentity);
680
+ const counts = new Map();
681
+ for (const v of Object.values(vc))
682
+ counts.set(v, (counts.get(v) ?? 0) + 1);
683
+ let best = null;
684
+ let bestN = 0;
685
+ let tie = false;
686
+ for (const [v, n] of counts) {
687
+ if (n > bestN) {
688
+ best = v;
689
+ bestN = n;
690
+ tie = false;
691
+ }
692
+ else if (n === bestN)
693
+ tie = true;
694
+ }
695
+ if (best !== null && !tie)
696
+ return best;
697
+ // No majority → the lease holder's value (if it is a participant).
698
+ if (input.leaseHolderMachineId && vc[input.leaseHolderMachineId] !== undefined)
699
+ return vc[input.leaseHolderMachineId];
700
+ return best ?? 'the pool value';
701
+ }
702
+ /** The machine to change = a participant whose value class ≠ the target. */
703
+ function divergentMachineFor(rowIdentity, input) {
704
+ const vc = valueClassesOf(rowIdentity);
705
+ const target = targetValueClassFor(rowIdentity, input);
706
+ const off = Object.entries(vc).find(([, v]) => v !== target);
707
+ return off ? off[0] : (Object.keys(vc)[0] ?? input.selfMachineId);
708
+ }
709
+ function plainValue(valueClass) {
710
+ const map = { live: 'on', dark: 'off', 'dry-run': 'dry-run', true: 'on', false: 'off' };
711
+ return map[valueClass] ?? valueClass;
712
+ }
713
+ function joinNicknames(nicks) {
714
+ const u = uniq(nicks);
715
+ if (u.length <= 1)
716
+ return u[0] ?? '';
717
+ if (u.length === 2)
718
+ return `${u[0]} and ${u[1]}`;
719
+ return `${u.slice(0, -1).join(', ')}, and ${u[u.length - 1]}`;
720
+ }
721
+ function uniq(a) {
722
+ return [...new Set(a)];
723
+ }
724
+ //# sourceMappingURL=machineCoherenceEpisodeManager.js.map