heyspex 0.1.0

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 (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Peer presence — every tool response tells the calling agent which OTHER agent
3
+ * sessions are live and what they hold ([[HS-89JR-ARLV-EPIC-peer-presence-on-every-heyspex-call]],
4
+ * owner directive 2026-08-14; capture [[HS-3XIE-GVNH-CAP-parallel-agents-are-still-stepping-on-ea]] —
5
+ * six cross-session collisions in one day because coordination data existed but
6
+ * was never pushed to the agents).
7
+ *
8
+ * Claim SSOT is the console (`GET /api/claims`) — NOT `.heyspex/active-claims.json`,
9
+ * which is stale on disk (HS-AG04). The fetch is fail-open with a short timeout,
10
+ * a positive cache (a burst of tool calls does not multiply requests) and a
11
+ * negative cache (a down console must not add timeout latency to every call).
12
+ * Presence INFORMS; leases enforce (HS-O4J0) — nothing here ever blocks a write.
13
+ *
14
+ * Pure core + thin shell (unit-test-authoring-protocol): buildPeerAgentsBlock is
15
+ * pure and fully tested against fixtures; fetchPeerAgents/appendPeerPresence are
16
+ * the fail-open shells the cli.mjs dispatch calls.
17
+ */
18
+
19
+ import { readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+
22
+ export const PEER_AGENTS_DEFAULT_LIMIT = 8;
23
+ export const PEER_DIRS_CAP = 4;
24
+ // [[HS-QQJP]] — "someone is working under spex-console/src" is nearly every file
25
+ // in this repo, so the round-2 two-segment cap does not answer "will my next
26
+ // write collide". Depth is the one direction of the four in the task that was
27
+ // not already shipped: stage (row.stage) and recency (row.last_active) already
28
+ // exist per session, and "count shape" already exists as files_touched. Depth
29
+ // alone was the gap, verified against .heyspex/wip-state.json's real shape
30
+ // (2026-08-15): most files cluster several segments deep under one project
31
+ // (e.g. companion/src/HeySpex.Companion.Core/Gather/*.cs), so a fixed two-segment
32
+ // cap throws away exactly the information a peer needs. Deepen ONLY when at
33
+ // least two files agree past the two-segment root — a lone file going deeper
34
+ // would just be a disguised file path, which the contract forbids.
35
+ export const PEER_DIR_HINT_MAX_DEPTH = 4;
36
+ // [[HS-O8YN]] — 1200ms lost the race against a real dev-server /api/claims read
37
+ // in 4 of 8 live-measured samples (2026-08-15): 2325, 2767, 1559, 845, 901,
38
+ // 1341, 920, 889 ms — 4/8 over 1200ms, worst case 2767ms. 3000ms covers every
39
+ // sample above with headroom. Do not lower this without a fresh measurement.
40
+ const DEFAULT_TIMEOUT_MS = 3000;
41
+ export const PEER_PRESENCE_DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
42
+ const CACHE_TTL_MS = 5_000;
43
+ const NEGATIVE_CACHE_MS = 30_000;
44
+
45
+ // [[HS-ZYPM]] — the 1500-char budget was stated in round 2 and certified at 1442
46
+ // against a synthetic fixture, but nothing in code ever checked it. Measured
47
+ // against the REAL .heyspex/wip-state.json ledger the same block lands at 1500 at
48
+ // six peers and 1965 at eight — and it lands on EVERY tool response, which is the
49
+ // whole reason the budget exists. A stated limit nothing checks is not a limit,
50
+ // so the render path now trims to fit and reports what it dropped.
51
+ export const PEER_BLOCK_CHAR_BUDGET = 1500;
52
+ const PEER_BLOCK_PREFIX = 'PEER AGENTS — other live sessions; avoid claiming their specs or editing their scope: ';
53
+
54
+ /**
55
+ * Directory segments for one WIP path (filename dropped), normalized to
56
+ * forward slashes. Returns null for non-strings/empty paths; [] for a
57
+ * root-level file (no directory to report — keeping its bare name would put
58
+ * a full file path in the block, which the scope-hint contract forbids,
59
+ * HS-NKMN AC2). Callers that only want the round-2 two-segment cap use
60
+ * `baseDirKey`; buildPeerAgentsBlock uses the full segment list to check
61
+ * whether a group of files agrees on a deeper common root (HS-QQJP).
62
+ */
63
+ function dirSegments(path) {
64
+ if (typeof path !== 'string') return null;
65
+ const segments = path.replace(/\\/g, '/').split('/').filter(Boolean);
66
+ if (segments.length === 0) return null;
67
+ return segments.slice(0, -1);
68
+ }
69
+
70
+ /** The round-2 grouping key: "." for root-level files, else the first two directory segments. */
71
+ function baseDirKey(segments) {
72
+ return segments.length === 0 ? '.' : segments.slice(0, 2).join('/');
73
+ }
74
+
75
+ /**
76
+ * Given every file that reduced to the same `baseDirKey`, decide whether they
77
+ * agree on a directory deeper than the two-segment default and, if so, report
78
+ * that deeper shared root instead — up to PEER_DIR_HINT_MAX_DEPTH segments.
79
+ * Requires at least two files: a lone file going deeper would just be a
80
+ * disguised file path (HS-NKMN's "never a full path" contract), and it isn't
81
+ * "agreement" if there is only one data point.
82
+ */
83
+ function deepestCommonHint(key, segsList) {
84
+ if (key === '.' || segsList.length < 2) return key;
85
+ const baseDepth = key.split('/').length;
86
+ const minLen = Math.min(...segsList.map((s) => s.length));
87
+ const cap = Math.min(PEER_DIR_HINT_MAX_DEPTH, minLen);
88
+ let depth = baseDepth;
89
+ while (depth < cap && segsList.every((s) => s[depth] === segsList[0][depth])) depth++;
90
+ return segsList[0].slice(0, depth).join('/');
91
+ }
92
+
93
+ /**
94
+ * Pure core: turn the console's claims list (+ optional wip-state) into a compact
95
+ * peer_agents block, or null when no OTHER live session exists.
96
+ *
97
+ * @param {object} opts
98
+ * @param {Array} opts.claims rows from GET /api/claims (specId, agent, sessionId, effectiveStatus, staleAt, expiresAt, lastActivityAt, currentStage, …)
99
+ * @param {object} [opts.wip] parsed .heyspex/wip-state.json ({ [specId]: { sessionId, filesTouched: [...] } })
100
+ * @param {number} [opts.nowMs] clock injection for staleness tests
101
+ * @param {string} [opts.selfSessionId] the CALLER's session — always excluded
102
+ * @param {number} [opts.limit] max rows before truncation (more_count carries the rest)
103
+ * @returns {{peer_agents: Array, more_count?: number} | null}
104
+ */
105
+ export function buildPeerAgentsBlock({ claims, wip = null, nowMs = Date.now(), selfSessionId = null, limit = PEER_AGENTS_DEFAULT_LIMIT }) {
106
+ if (!Array.isArray(claims) || claims.length === 0) return null;
107
+ const bySession = new Map();
108
+ for (const c of claims) {
109
+ if (!c || typeof c !== 'object') continue;
110
+ const sid = c.sessionId ?? null;
111
+ if (!sid || sid === selfSessionId) continue;
112
+ const staleAtMs = c.staleAt ? Date.parse(c.staleAt) : null;
113
+ const expiresAtMs = c.expiresAt ? Date.parse(c.expiresAt) : null;
114
+ if ((staleAtMs != null && staleAtMs < nowMs) || (expiresAtMs != null && expiresAtMs < nowMs)) continue;
115
+ if (c.effectiveStatus != null && c.effectiveStatus !== 'working') continue;
116
+ const row = bySession.get(sid) ?? {
117
+ _sid: sid,
118
+ _activityMs: 0,
119
+ agent: c.agent ?? 'unknown',
120
+ session: String(sid).slice(0, 8),
121
+ spec_ids: [],
122
+ stage: null,
123
+ last_active: null,
124
+ };
125
+ if (c.specId && !row.spec_ids.includes(c.specId)) row.spec_ids.push(c.specId);
126
+ if (c.currentStage) row.stage = c.currentStage;
127
+ const activityMs = c.lastActivityAt ? Date.parse(c.lastActivityAt) : null;
128
+ if (activityMs != null && activityMs > row._activityMs) {
129
+ row._activityMs = activityMs;
130
+ row.last_active = c.lastActivityAt;
131
+ }
132
+ bySession.set(sid, row);
133
+ }
134
+ if (bySession.size === 0) return null;
135
+
136
+ // Scope hints from the WIP ledger: how many files that session's work touches
137
+ // (the COUNT only, never the list — mcp-tool-response-size-budget) plus WHICH
138
+ // directories, as de-duped two-segment hints capped at PEER_DIRS_CAP (first
139
+ // seen, round-2 order preserved) with a dirs_more overflow count. Only the
140
+ // FIRST shown bucket is widened to the deepest root ALL of its files agree
141
+ // on, up to PEER_DIR_HINT_MAX_DEPTH ([[HS-QQJP]]) — deepening every shown
142
+ // bucket, plus maxing every other field at once (long agent names, the
143
+ // longest stage verb, 4-digit files_touched), measured 2231-2583 chars on
144
+ // an 8-row block, well over the 1500 budget; depth on one root per row
145
+ // against round 2's own already-certified 1442-char fixture measures 1479
146
+ // (see peer-presence.test.mjs "measured" case) — 21 chars of headroom, so
147
+ // this is deliberately spent on the single most collision-relevant root per
148
+ // row, not spread across all four. Advisory visibility only — derived from
149
+ // this same wip read (zero new round trips), never a file list, never a
150
+ // claim or lock ([[HS-45FR-A9SI-RES-parallel-agent-collision-enforcement]]
151
+ // §"Design constraint — derive scope, don't claim it").
152
+ if (wip && typeof wip === 'object') {
153
+ for (const row of bySession.values()) {
154
+ let filesTouched = 0;
155
+ const buckets = new Map(); // baseDirKey -> segments[] for every file in that bucket, in first-seen order
156
+ for (const [specId, entry] of Object.entries(wip)) {
157
+ if (!entry || typeof entry !== 'object') continue;
158
+ const matches = entry.sessionId === row._sid || row.spec_ids.includes(specId);
159
+ if (!matches || !Array.isArray(entry.filesTouched)) continue;
160
+ filesTouched = Math.max(filesTouched, entry.filesTouched.length);
161
+ for (const file of entry.filesTouched) {
162
+ const segs = dirSegments(file);
163
+ if (segs == null) continue;
164
+ const key = baseDirKey(segs);
165
+ let bucket = buckets.get(key);
166
+ if (!bucket) buckets.set(key, (bucket = []));
167
+ bucket.push(segs);
168
+ }
169
+ }
170
+ if (filesTouched > 0) row.files_touched = filesTouched;
171
+ if (buckets.size > 0) {
172
+ const dirs = [];
173
+ let i = 0;
174
+ for (const [key, segsList] of buckets) {
175
+ if (i >= PEER_DIRS_CAP) break;
176
+ dirs.push(i === 0 ? deepestCommonHint(key, segsList) : key);
177
+ i++;
178
+ }
179
+ row.dirs = dirs;
180
+ const overflow = buckets.size - dirs.length;
181
+ if (overflow > 0) row.dirs_more = overflow;
182
+ }
183
+ }
184
+ }
185
+
186
+ const rows = [...bySession.values()]
187
+ .sort((a, b) => b._activityMs - a._activityMs)
188
+ .map(({ _sid, _activityMs, ...publicRow }) => publicRow);
189
+ const capped = rows.slice(0, limit);
190
+ const moreCount = rows.length - capped.length;
191
+ return { peer_agents: capped, ...(moreCount > 0 ? { more_count: moreCount } : {}) };
192
+ }
193
+
194
+ /** Best-effort local read of the WIP ledger — null on any problem, never throws. */
195
+ export function readWipState(vaultRoot) {
196
+ try {
197
+ if (!vaultRoot) return null;
198
+ const parsed = JSON.parse(readFileSync(join(vaultRoot, '.heyspex', 'wip-state.json'), 'utf8'));
199
+ return parsed && typeof parsed === 'object' ? parsed : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ // Positive + negative caches — module-level on purpose (one MCP server process
206
+ // serves one session; a burst of tool calls shares one claims snapshot).
207
+ let claimsCache = { atMs: 0, url: null, claims: null };
208
+ let failedUntilMs = 0;
209
+ // [[HS-O8YN]] direction 3: last successfully built block + when, so a failed
210
+ // fetch can serve slightly-stale data instead of nothing (the negative cache is
211
+ // already 30s, so a stale block is strictly better than silence). Null block
212
+ // still counts as "known" — it means the last real check found no peers.
213
+ let lastGoodBlock = null;
214
+ let lastGoodAtMs = 0;
215
+ let lastGoodChecked = false;
216
+
217
+ /** Test hook: clear all caches between cases. */
218
+ export function resetPeerPresenceCache() {
219
+ claimsCache = { atMs: 0, url: null, claims: null };
220
+ failedUntilMs = 0;
221
+ lastGoodBlock = null;
222
+ lastGoodAtMs = 0;
223
+ lastGoodChecked = false;
224
+ }
225
+
226
+ async function readClaimsFromConsole(consoleUrl, fetchFn, timeoutMs, headers) {
227
+ const url = `${String(consoleUrl).replace(/\/+$/, '')}/api/claims`;
228
+ const init = { signal: AbortSignal.timeout?.(timeoutMs) };
229
+ // Cloud console gates /api/claims behind the same bearer every other console
230
+ // call sends (guardMcpAuth) — an unauthenticated probe 401s on EVERY tool
231
+ // call there, rendering the "PEER AGENTS UNAVAILABLE" line while the calls
232
+ // themselves succeed (HS-0C12-3UIK sibling finding). No headers → exactly
233
+ // the old request, so the local dev console path is byte-identical.
234
+ if (headers && Object.keys(headers).length > 0) init.headers = headers;
235
+ const res = await fetchFn(url, init);
236
+ if (!res?.ok) return null;
237
+ const body = await res.json();
238
+ return Array.isArray(body?.claims) ? body.claims : null;
239
+ }
240
+
241
+ /** Build the failure-path result: stale-but-known data beats nothing (direction 3). */
242
+ function staleResult(nowMs) {
243
+ if (lastGoodChecked) {
244
+ return { block: lastGoodBlock, ok: false, stale: true, ageMs: nowMs - lastGoodAtMs };
245
+ }
246
+ return { block: null, ok: false, stale: false, ageMs: null };
247
+ }
248
+
249
+ /**
250
+ * Internal shell: fetch live claims (cached) and build the block, reporting HOW
251
+ * the result was obtained — `ok: true` for a fresh/cached-fresh read, `ok: false`
252
+ * for a failed/timed-out read (optionally with a stale `block` served from the
253
+ * last known-good check). This is the piece that lets a caller tell "checked,
254
+ * no peers" apart from "could not check" ([[HS-O8YN]]) — fetchPeerAgents below
255
+ * collapses both failure shapes back to null for callers that only want the block.
256
+ * Fail-open — any error, non-OK response, or unreachable console never throws.
257
+ */
258
+ async function resolvePeerPresence({
259
+ consoleUrl,
260
+ selfSessionId = null,
261
+ vaultRoot = null,
262
+ fetchFn = fetch,
263
+ nowMs = Date.now(),
264
+ timeoutMs = DEFAULT_TIMEOUT_MS,
265
+ limit = PEER_AGENTS_DEFAULT_LIMIT,
266
+ cacheTtlMs = CACHE_TTL_MS,
267
+ negativeCacheMs = NEGATIVE_CACHE_MS,
268
+ /** Outbound auth header(s) for the console read (cloud bearer). Optional. */
269
+ headers = null,
270
+ } = {}) {
271
+ try {
272
+ if (!consoleUrl) return { block: null, ok: false, stale: false, ageMs: null };
273
+ let claims = null;
274
+ if (claimsCache.claims && claimsCache.url === consoleUrl && nowMs - claimsCache.atMs < cacheTtlMs) {
275
+ claims = claimsCache.claims;
276
+ } else if (nowMs < failedUntilMs) {
277
+ return staleResult(nowMs);
278
+ } else {
279
+ claims = await readClaimsFromConsole(consoleUrl, fetchFn, timeoutMs, headers);
280
+ if (claims == null) {
281
+ failedUntilMs = nowMs + negativeCacheMs;
282
+ return staleResult(nowMs);
283
+ }
284
+ claimsCache = { atMs: nowMs, url: consoleUrl, claims };
285
+ failedUntilMs = 0;
286
+ }
287
+ const block = buildPeerAgentsBlock({ claims, wip: readWipState(vaultRoot), nowMs, selfSessionId, limit });
288
+ lastGoodBlock = block;
289
+ lastGoodAtMs = nowMs;
290
+ lastGoodChecked = true;
291
+ return { block, ok: true, stale: false, ageMs: null };
292
+ } catch {
293
+ failedUntilMs = nowMs + negativeCacheMs;
294
+ return staleResult(nowMs);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Thin shell: fetch live claims (cached) and build the block. Fail-open — any
300
+ * error, non-OK response, or unreachable console returns null and arms the
301
+ * negative cache so subsequent tool calls skip the attempt entirely. Kept as a
302
+ * plain block-or-null contract for callers that don't need failure visibility;
303
+ * appendPeerPresence uses resolvePeerPresence directly instead.
304
+ */
305
+ export async function fetchPeerAgents(opts = {}) {
306
+ const { block } = await resolvePeerPresence(opts);
307
+ return block;
308
+ }
309
+
310
+ /**
311
+ * Trim a block until the line it renders to fits PEER_BLOCK_CHAR_BUDGET ([[HS-ZYPM]]).
312
+ * Pure — the caller's block is never mutated.
313
+ *
314
+ * What goes first is what costs most and is worth least. The directory hints are
315
+ * the entire overage: the same 8-row block measures 1965 chars with hints and
316
+ * 1141 without, so hints are shed before anything else, and always from whichever
317
+ * row is currently carrying the most. That way every row keeps its FIRST hint —
318
+ * the deepest, most collision-relevant one — for as long as possible, which is
319
+ * the only hint a peer usually acts on.
320
+ *
321
+ * Nothing is dropped quietly. A shed hint is added to that row's `dirs_more`, a
322
+ * dropped row is added to `more_count`, and `budget_trimmed: true` marks that the
323
+ * trim came from the budget rather than from PEER_AGENTS_DEFAULT_LIMIT.
324
+ *
325
+ * The last row is never dropped, even if it alone exceeds the budget. Rendering
326
+ * an empty peer list to save characters would tell a live agent it is alone when
327
+ * it is not, which is the exact failure [[HS-O8YN]] exists to prevent.
328
+ */
329
+ export function fitPeerAgentsBlock(block, opts = {}) {
330
+ const budget = opts.budget ?? PEER_BLOCK_CHAR_BUDGET;
331
+ const prefixLength = opts.prefixLength ?? PEER_BLOCK_PREFIX.length;
332
+ if (!block || !Array.isArray(block.peer_agents) || block.peer_agents.length === 0) return block;
333
+ if (prefixLength + JSON.stringify(block).length <= budget) return block;
334
+
335
+ const rows = block.peer_agents.map((r) => (Array.isArray(r.dirs) ? { ...r, dirs: [...r.dirs] } : { ...r }));
336
+ const current = () => {
337
+ const dropped = block.peer_agents.length - rows.length;
338
+ const out = { ...block, peer_agents: rows, budget_trimmed: true };
339
+ if (dropped > 0) out.more_count = (block.more_count ?? 0) + dropped;
340
+ return out;
341
+ };
342
+ const over = () => prefixLength + JSON.stringify(current()).length > budget;
343
+
344
+ // Ties break toward the BOTTOM of the list in both passes below. Rows arrive
345
+ // sorted most-recently-active first, and the peer most likely to collide with
346
+ // your next write is the one that just wrote something — so the quiet sessions
347
+ // give up their hints before the busy ones do.
348
+ const widestFromTail = () => {
349
+ let widest = null;
350
+ for (const r of rows) {
351
+ if (Array.isArray(r.dirs) && r.dirs.length > 1 && (widest === null || r.dirs.length >= widest.dirs.length)) widest = r;
352
+ }
353
+ return widest;
354
+ };
355
+ const lastHolder = () => {
356
+ for (let i = rows.length - 1; i >= 0; i--) {
357
+ if (Array.isArray(rows[i].dirs) && rows[i].dirs.length > 0) return rows[i];
358
+ }
359
+ return null;
360
+ };
361
+
362
+ // 1 — shed hints from the widest row, keeping at least one hint on every row.
363
+ while (over()) {
364
+ const widest = widestFromTail();
365
+ if (widest === null) break;
366
+ widest.dirs.pop();
367
+ widest.dirs_more = (widest.dirs_more ?? 0) + 1;
368
+ }
369
+
370
+ // 2 — still over: give up the last hint rows are holding, quietest first.
371
+ while (over()) {
372
+ const holder = lastHolder();
373
+ if (holder === null) break;
374
+ holder.dirs.pop();
375
+ holder.dirs_more = (holder.dirs_more ?? 0) + 1;
376
+ if (holder.dirs.length === 0) delete holder.dirs;
377
+ }
378
+
379
+ // 3 — still over: drop rows from the tail. buildPeerAgentsBlock sorts most
380
+ // recently active first, so the quietest sessions go, and more_count says how
381
+ // many are missing.
382
+ while (over() && rows.length > 1) rows.pop();
383
+
384
+ return current();
385
+ }
386
+
387
+ /** Render the block as the single text line appended to tool responses. */
388
+ export function formatPeerAgentsText(block) {
389
+ if (!block || !Array.isArray(block.peer_agents) || block.peer_agents.length === 0) return null;
390
+ return `${PEER_BLOCK_PREFIX}${JSON.stringify(fitPeerAgentsBlock(block))}`;
391
+ }
392
+
393
+ /**
394
+ * Render an explicit failure/staleness line — an agent must never read silence
395
+ * as "no peers" ([[HS-O8YN]]). "Confirmed empty" and "could not check" are
396
+ * opposite states and must never render identically. When a last-known-good
397
+ * block exists it is served WITH its age so staleness is visible, not implied.
398
+ */
399
+ function formatUnavailableText(state) {
400
+ if (state.stale && state.block && Array.isArray(state.block.peer_agents) && state.block.peer_agents.length > 0) {
401
+ const ageSec = Math.max(0, Math.round(state.ageMs / 1000));
402
+ // Same budget applies here ([[HS-ZYPM]]) — this line has a LONGER prefix than
403
+ // the normal one, so it gets less room for rows, not more.
404
+ const prefix = `PEER AGENTS UNAVAILABLE (live check failed) — showing last known state from ${ageSec}s ago, may be stale: `;
405
+ return `${prefix}${JSON.stringify(fitPeerAgentsBlock(state.block, { prefixLength: prefix.length }))}`;
406
+ }
407
+ return 'PEER AGENTS UNAVAILABLE — could not reach the console to check for other live sessions. Peer presence is unknown, not confirmed empty.';
408
+ }
409
+
410
+ /**
411
+ * Dispatch wrapper ([[HS-GMNO]]): append the peer_agents line to an MCP tool
412
+ * result. Applied on EVERY tools/call response (unlike the once-per-session seat
413
+ * banner). Absolutely fail-open — a presence failure must never break a response,
414
+ * but per [[HS-O8YN]] it must never be SILENT either: a failed/timed-out check
415
+ * always appends an explicit "unavailable" line instead of returning `result`
416
+ * unchanged, so an agent can never mistake "could not check" for "no peers".
417
+ */
418
+ export async function appendPeerPresence(result, opts = {}) {
419
+ try {
420
+ const state = await resolvePeerPresence(opts);
421
+ const line = state.ok ? formatPeerAgentsText(state.block) : formatUnavailableText(state);
422
+ if (!line) return result;
423
+ if (result && Array.isArray(result.content)) {
424
+ return { ...result, content: [...result.content, { type: 'text', text: `\n${line}` }] };
425
+ }
426
+ return result;
427
+ } catch {
428
+ return result;
429
+ }
430
+ }