gossipcat 0.4.19 → 0.4.20

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.
@@ -51,6 +51,7 @@ var init_mcp_context = __esm({
51
51
  skillEngine: null,
52
52
  nativeTaskMap: /* @__PURE__ */ new Map(),
53
53
  nativeResultMap: /* @__PURE__ */ new Map(),
54
+ nativeUtilityResultMap: /* @__PURE__ */ new Map(),
54
55
  nativeAgentConfigs: /* @__PURE__ */ new Map(),
55
56
  identityRegistry: /* @__PURE__ */ new Map(),
56
57
  pendingConsensusRounds: /* @__PURE__ */ new Map(),
@@ -13675,7 +13676,10 @@ function extractFrontmatterField(content, field) {
13675
13676
  if (colonIdx === -1) continue;
13676
13677
  const key = line.slice(0, colonIdx).trim();
13677
13678
  if (key !== field) continue;
13678
- const value = line.slice(colonIdx + 1).trim();
13679
+ let value = line.slice(colonIdx + 1).trim();
13680
+ if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
13681
+ value = value.slice(1, -1);
13682
+ }
13679
13683
  return value.length > 0 ? value : null;
13680
13684
  }
13681
13685
  return null;
@@ -16545,7 +16549,8 @@ ${safeSnippet}
16545
16549
  const trailing = relSegs.slice(relSegs.length - refSegs.length);
16546
16550
  return trailing.every((seg, i) => seg === refSegs[i]);
16547
16551
  }
16548
- async findFile(dir, fileName, validRoots, anchorRoot, fileRef) {
16552
+ async findFile(dir, fileName, validRoots, anchorRoot, fileRef, depth = 0) {
16553
+ if (depth >= 10) return null;
16549
16554
  try {
16550
16555
  const entries = await (0, import_promises3.readdir)(dir, { withFileTypes: true });
16551
16556
  for (const entry of entries) {
@@ -16566,7 +16571,7 @@ ${safeSnippet}
16566
16571
  return real;
16567
16572
  }
16568
16573
  if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git") {
16569
- const found = await this.findFile(fullPath, fileName, validRoots, anchorRoot, fileRef);
16574
+ const found = await this.findFile(fullPath, fileName, validRoots, anchorRoot, fileRef, depth + 1);
16570
16575
  if (found) return found;
16571
16576
  }
16572
16577
  }
@@ -25107,6 +25112,18 @@ var init_auth = __esm({
25107
25112
  }
25108
25113
  });
25109
25114
 
25115
+ // packages/relay/src/dashboard/utility-agents.ts
25116
+ function isUtilityAgent(agentId) {
25117
+ return UTILITY_AGENT_IDS.has(agentId);
25118
+ }
25119
+ var UTILITY_AGENT_IDS;
25120
+ var init_utility_agents = __esm({
25121
+ "packages/relay/src/dashboard/utility-agents.ts"() {
25122
+ "use strict";
25123
+ UTILITY_AGENT_IDS = /* @__PURE__ */ new Set(["_utility"]);
25124
+ }
25125
+ });
25126
+
25110
25127
  // packages/relay/src/dashboard/api-overview.ts
25111
25128
  function readSkillStatus(path2) {
25112
25129
  try {
@@ -25212,6 +25229,7 @@ async function overviewHandler(projectRoot, ctx2) {
25212
25229
  try {
25213
25230
  const ev = JSON.parse(line);
25214
25231
  if (ev.type === "task.created") {
25232
+ if (ev.agentId && isUtilityAgent(ev.agentId)) continue;
25215
25233
  if (ev.taskId && ev.agentId) {
25216
25234
  created.set(ev.taskId, { agentId: ev.agentId, timestamp: ev.timestamp || "" });
25217
25235
  }
@@ -25318,6 +25336,7 @@ var init_api_overview = __esm({
25318
25336
  "use strict";
25319
25337
  import_fs42 = require("fs");
25320
25338
  import_path45 = require("path");
25339
+ init_utility_agents();
25321
25340
  }
25322
25341
  });
25323
25342
 
@@ -25504,6 +25523,7 @@ function readTaskGraphByAgent(projectRoot) {
25504
25523
  }
25505
25524
  for (const [taskId, createdData] of created) {
25506
25525
  const { agentId, task, timestamp } = createdData;
25526
+ if (isUtilityAgent(agentId)) continue;
25507
25527
  if (!result.has(agentId)) {
25508
25528
  result.set(agentId, { totalTokens: 0, lastTask: null });
25509
25529
  }
@@ -25620,6 +25640,7 @@ var init_api_agents = __esm({
25620
25640
  init_skill_index();
25621
25641
  import_fs44 = require("fs");
25622
25642
  import_path47 = require("path");
25643
+ init_utility_agents();
25623
25644
  DEFAULT_SCORE = {
25624
25645
  agentId: "",
25625
25646
  accuracy: 0.5,
@@ -26292,6 +26313,7 @@ async function tasksHandler(projectRoot, query) {
26292
26313
  }
26293
26314
  const tasks = [];
26294
26315
  for (const [taskId, info] of created) {
26316
+ if (isUtilityAgent(info.agentId)) continue;
26295
26317
  const result = completed.get(taskId);
26296
26318
  tasks.push({
26297
26319
  taskId,
@@ -26314,6 +26336,7 @@ var init_api_tasks = __esm({
26314
26336
  "use strict";
26315
26337
  import_fs53 = require("fs");
26316
26338
  import_path56 = require("path");
26339
+ init_utility_agents();
26317
26340
  }
26318
26341
  });
26319
26342
 
@@ -26329,6 +26352,7 @@ async function activeTasksHandler(projectRoot) {
26329
26352
  try {
26330
26353
  const ev = JSON.parse(line);
26331
26354
  if (ev.type === "task.created" && ev.taskId) {
26355
+ if (isUtilityAgent(ev.agentId)) continue;
26332
26356
  created.set(ev.taskId, { agentId: ev.agentId || "", task: ev.task || "", timestamp: ev.timestamp || "" });
26333
26357
  } else if (ev.type === "task.completed" || ev.type === "task.failed" || ev.type === "task.cancelled") {
26334
26358
  finished.add(ev.taskId);
@@ -26357,6 +26381,7 @@ var init_api_active_tasks = __esm({
26357
26381
  "use strict";
26358
26382
  import_fs54 = require("fs");
26359
26383
  import_path57 = require("path");
26384
+ init_utility_agents();
26360
26385
  }
26361
26386
  });
26362
26387
 
@@ -43403,6 +43428,14 @@ function recordTimeoutSignal(taskId, agentId) {
43403
43428
  } catch {
43404
43429
  }
43405
43430
  }
43431
+ var UTILITY_RESULT_TTL_MS = 24 * 60 * 60 * 1e3;
43432
+ function scheduleNativeTaskEviction(intervalMs = 60 * 60 * 1e3) {
43433
+ const timer = setInterval(evictStaleNativeTasks, intervalMs);
43434
+ timer.unref();
43435
+ return {
43436
+ stop: () => clearInterval(timer)
43437
+ };
43438
+ }
43406
43439
  function evictStaleNativeTasks() {
43407
43440
  const now = Date.now();
43408
43441
  let changed = false;
@@ -43418,6 +43451,11 @@ function evictStaleNativeTasks() {
43418
43451
  changed = true;
43419
43452
  }
43420
43453
  }
43454
+ for (const [id, info] of [...ctx.nativeUtilityResultMap]) {
43455
+ if (now - info.startedAt > UTILITY_RESULT_TTL_MS) {
43456
+ ctx.nativeUtilityResultMap.delete(id);
43457
+ }
43458
+ }
43421
43459
  if (changed) persistNativeTaskMap();
43422
43460
  }
43423
43461
  function persistNativeTaskMap() {
@@ -43588,7 +43626,7 @@ async function handleNativeRelay(task_id, result, error48, agentStartedAt, relay
43588
43626
  const effectiveError = auditBlockError || error48;
43589
43627
  const effectiveResult = auditBlockError ? void 0 : error48 ? void 0 : result ? (auditPrefix + result).slice(0, 5e4) : result;
43590
43628
  ctx.nativeTaskMap.delete(task_id);
43591
- ctx.nativeResultMap.set(task_id, {
43629
+ const resultRecord = {
43592
43630
  id: task_id,
43593
43631
  agentId: taskInfo.agentId,
43594
43632
  task: taskInfo.task,
@@ -43597,7 +43635,12 @@ async function handleNativeRelay(task_id, result, error48, agentStartedAt, relay
43597
43635
  error: effectiveError || void 0,
43598
43636
  startedAt: effectiveStart,
43599
43637
  completedAt: Date.now()
43600
- });
43638
+ };
43639
+ if (taskInfo.utilityType === "skill_develop") {
43640
+ ctx.nativeUtilityResultMap.set(task_id, resultRecord);
43641
+ } else {
43642
+ ctx.nativeResultMap.set(task_id, resultRecord);
43643
+ }
43601
43644
  if (auditBlockError) error48 = auditBlockError;
43602
43645
  persistNativeTaskMap();
43603
43646
  evictStaleNativeTasks();
@@ -43634,8 +43677,9 @@ async function handleNativeRelay(task_id, result, error48, agentStartedAt, relay
43634
43677
  } catch {
43635
43678
  }
43636
43679
  }
43680
+ const completionResult = taskInfo.utilityType === "skill_develop" ? `[utility] ${taskInfo.task} \u2192 ${(result || "").length} chars` : result;
43637
43681
  try {
43638
- ctx.mainAgent.recordNativeTaskCompleted(task_id, result, error48 || void 0, elapsed ?? void 0, taskInfo.memoryQueryCalled);
43682
+ ctx.mainAgent.recordNativeTaskCompleted(task_id, completionResult, error48 || void 0, elapsed ?? void 0, taskInfo.memoryQueryCalled);
43639
43683
  } catch {
43640
43684
  }
43641
43685
  if (taskInfo.writeMode && !taskInfo.utilityType && agentId !== "_utility") {
@@ -43723,7 +43767,7 @@ async function handleNativeRelay(task_id, result, error48, agentStartedAt, relay
43723
43767
  }
43724
43768
  if (!error48 && taskInfo.utilityType) {
43725
43769
  const utilityLabel = taskInfo.utilityType === "summary" ? "cognitive-summary" : taskInfo.utilityType === "gossip" ? "gossip-publish" : taskInfo.utilityType;
43726
- const utilDurationLabel = elapsed !== null ? `${(elapsed / 1e3).toFixed(1)}s` : "duration=unknown";
43770
+ const utilDurationLabel = elapsed !== null ? `${(elapsed / 1e3).toFixed(1)}s since dispatch` : "duration=unknown";
43727
43771
  process.stderr.write(`[gossipcat] \u2705 utility \u2190 ${utilityLabel} [${task_id}] OK (${utilDurationLabel})
43728
43772
  `);
43729
43773
  }
@@ -45707,9 +45751,11 @@ async function doBoot() {
45707
45751
  };
45708
45752
  process.once("exit", cleanupPid);
45709
45753
  let shuttingDown = false;
45754
+ const eviction = scheduleNativeTaskEviction();
45710
45755
  process.once("SIGTERM", async () => {
45711
45756
  if (shuttingDown) return;
45712
45757
  shuttingDown = true;
45758
+ eviction.stop();
45713
45759
  process.stderr.write(`[relay] shutdown reason=SIGTERM pid=${process.pid}
45714
45760
  `);
45715
45761
  try {
@@ -45722,6 +45768,7 @@ async function doBoot() {
45722
45768
  process.once("SIGINT", async () => {
45723
45769
  if (shuttingDown) return;
45724
45770
  shuttingDown = true;
45771
+ eviction.stop();
45725
45772
  process.stderr.write(`[relay] shutdown reason=SIGINT pid=${process.pid}
45726
45773
  `);
45727
45774
  try {
@@ -48129,7 +48176,8 @@ ${sections.join("\n\n")}` }] };
48129
48176
  if (_utility_task_id) {
48130
48177
  const stashedMeta = _pendingSkillData.get(_utility_task_id);
48131
48178
  _pendingSkillData.delete(_utility_task_id);
48132
- const utilityResult = ctx.nativeResultMap.get(_utility_task_id);
48179
+ const utilityResult = ctx.nativeUtilityResultMap.get(_utility_task_id) ?? ctx.nativeResultMap.get(_utility_task_id);
48180
+ ctx.nativeUtilityResultMap.delete(_utility_task_id);
48133
48181
  ctx.nativeResultMap.delete(_utility_task_id);
48134
48182
  ctx.nativeTaskMap.delete(_utility_task_id);
48135
48183
  const _guardBefore = _utilityGuardSnapshots.get(_utility_task_id);
@@ -48195,6 +48243,10 @@ ${preview}` }]
48195
48243
  timeoutMs: 12e4,
48196
48244
  utilityType: "skill_develop"
48197
48245
  });
48246
+ try {
48247
+ ctx.mainAgent.recordNativeTask(taskId, "_utility", `skill_develop:${category}`);
48248
+ } catch {
48249
+ }
48198
48250
  spawnTimeoutWatcher(taskId, ctx.nativeTaskMap.get(taskId));
48199
48251
  const modelShort = ctx.nativeUtilityConfig.model;
48200
48252
  return {
package/docs/HANDBOOK.md CHANGED
@@ -114,6 +114,25 @@ The `verify-the-premise` skill (premise-verification Stage 1) auto-binds to ever
114
114
  - The change is documentation, CSS, test data adjustments, or log-string-only
115
115
  - Under 10 lines, no side effects on shared state, no security surface
116
116
 
117
+ ### When to gate merges on consensus — impact-adjacency, not just LOC
118
+
119
+ Pre-merge consensus is cheap; bugs in trust-boundary-adjacent code are expensive. Run `gossip_dispatch(mode: "consensus", ...)` on a PR **regardless of size** when the diff touches any of:
120
+
121
+ - **Shared in-memory state with lifecycle hooks** — any `Map`, `Set`, or cache where entries are added in one path and removed (or expire) in another. Race conditions and memory leaks hide here even in small diffs.
122
+ - **Background cleanup / TTL / timer logic** — scheduled eviction, session expiry, job-queue draining, or any timer whose firing sequence is observable externally. A missed edge case silently accumulates state.
123
+ - **Serialization at persistence boundaries** — code that writes or parses structured files (JSON config, YAML frontmatter, append-only logs, DB migrations). A format regression is invisible until a downstream reader breaks, and the blast radius is multiplicative.
124
+ - **Authentication / authorization boundaries** — middleware, token validation, permission checks, or any decorator that gates access. A single missing condition opens the full surface behind it.
125
+ - **Signal or event pipelines** — pub/sub routing, queue producers/consumers, or any path where message loss, duplication, or ordering is externally observable. Subtle ordering bugs are hard to reproduce after the fact.
126
+ - **Install / bootstrap / initialization paths** — postinstall hooks, config writers, seeding scripts, or startup sequences. These run once and their failures are often silent or hard to roll back.
127
+
128
+ **Why LOC alone misses these:** risk is proportional to how many concurrent systems depend on implicit assumptions, not to code size. A 40-line change to a cache eviction path or a config merge routine can introduce a memory leak or clobber user state just as easily as a 500-line feature. Let impact adjacency — not file count — trigger consensus.
129
+
130
+ **Exceptions within impact-adjacent areas:**
131
+ - Small mirror-of-existing-pattern changes (applying a previously-reviewed fix to a sibling file) don't need a fresh round; the pattern is already validated.
132
+ - Pure log-string or comment edits inside these files are fine.
133
+
134
+ **Remediation when missed:** run post-hoc consensus on the merged diff, file follow-up PRs if findings surface, and document the gap as feedback.
135
+
117
136
  ### Consensus protocol — 3 steps
118
137
 
119
138
  When you dispatch with `mode: "consensus"`, the orchestrator follows **three** steps. Phase 2 cross-review runs server-side automatically.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gossipcat",
3
- "version": "0.4.19",
3
+ "version": "0.4.20",
4
4
  "description": "Multi-agent orchestration for Claude Code — parallel review, consensus, adaptive dispatch",
5
5
  "mcpName": "io.github.ataberk-xyz/gossipcat",
6
6
  "repository": {
@@ -6,7 +6,7 @@
6
6
  * Never shipped in the package tarball — always regenerated per machine.
7
7
  */
8
8
  const { join, resolve } = require('path');
9
- const { existsSync, writeFileSync } = require('fs');
9
+ const { existsSync, readFileSync, writeFileSync, statSync } = require('fs');
10
10
 
11
11
  const scriptDir = __dirname; // .../gossipcat/scripts/
12
12
  const packageRoot = resolve(scriptDir, '..'); // .../gossipcat/
@@ -19,29 +19,95 @@ const mcpServerPath = join(packageRoot, 'dist-mcp', 'mcp-server.js');
19
19
  const isGlobal = process.env.npm_config_global === 'true';
20
20
  const isGitClone = existsSync(join(packageRoot, '.git'));
21
21
 
22
- // For git clones: skip if .mcp.json already exists (developer already set up)
22
+ // For git clones: skip writing if .mcp.json already exists. Still warn if the
23
+ // built server is older than package.json — stale-build warning is the most
24
+ // valuable signal on a developer re-running npm install after a pull.
23
25
  if (isGitClone && existsSync(join(packageRoot, '.mcp.json'))) {
26
+ if (existsSync(mcpServerPath)) {
27
+ try {
28
+ const serverMtime = statSync(mcpServerPath).mtime;
29
+ const pkgMtime = statSync(join(packageRoot, 'package.json')).mtime;
30
+ if (serverMtime < pkgMtime) {
31
+ console.log("gossipcat: dist-mcp/ is older than package.json — run 'npm run build:mcp' to rebuild");
32
+ }
33
+ } catch (_) { /* stat failure is non-fatal */ }
34
+ }
24
35
  console.log('gossipcat: .mcp.json already exists — skipping (git clone)');
25
36
  process.exit(0);
26
37
  }
27
38
 
28
39
  // For project-local installs: write .mcp.json into the consumer project root,
29
- // not into node_modules/gossipcat. Detect consumer root by walking up from
30
- // node_modules to the directory that contains it.
40
+ // not into node_modules/gossipcat. Walk up from __dirname until a package.json
41
+ // with a "workspaces" field is found OR filesystem root is reached.
31
42
  let outputDir = packageRoot; // default: package dir (global or git clone)
32
43
  if (!isGlobal && !isGitClone) {
33
- // node_modules/gossipcat node_modules → project root
34
- const nodeModulesDir = resolve(packageRoot, '..'); // node_modules/
35
- const projectRoot = resolve(nodeModulesDir, '..'); // project root
36
- if (existsSync(join(nodeModulesDir, '..', 'package.json'))) {
37
- outputDir = projectRoot;
44
+ let found = false;
45
+ // Start the walk ONE level ABOVE packageRoot so we skip gossipcat's own
46
+ // package.json (which has a "workspaces" field for its own monorepo layout
47
+ // and would otherwise match on iteration 1, writing .mcp.json into
48
+ // node_modules/gossipcat/ instead of the consumer's project root).
49
+ let dir = resolve(packageRoot, '..');
50
+ while (true) {
51
+ const pkgPath = join(dir, 'package.json');
52
+ if (existsSync(pkgPath)) {
53
+ try {
54
+ const pkg = JSON.parse(require('fs').readFileSync(pkgPath, 'utf8'));
55
+ if (pkg.workspaces) {
56
+ outputDir = dir;
57
+ found = true;
58
+ break;
59
+ }
60
+ } catch (_) { /* unparseable package.json — keep walking */ }
61
+ }
62
+ const parent = resolve(dir, '..');
63
+ if (parent === dir) break; // filesystem root
64
+ dir = parent;
65
+ }
66
+ if (!found) {
67
+ // Fallback: two-level walk-up from node_modules (original heuristic)
68
+ const nodeModulesDir = resolve(packageRoot, '..');
69
+ const projectRoot = resolve(nodeModulesDir, '..');
70
+ if (existsSync(join(projectRoot, 'package.json'))) {
71
+ outputDir = projectRoot;
72
+ } else {
73
+ outputDir = process.cwd();
74
+ }
38
75
  }
39
76
  }
40
77
 
41
78
  const mcpConfig = join(outputDir, '.mcp.json');
42
79
 
80
+ // Preserve existing user-added MCP server entries. An existing .mcp.json may
81
+ // carry servers other than gossipcat (e.g. another MCP tool the user has
82
+ // installed). Unconditional overwrite would silently clobber those entries
83
+ // on every npm install. Merge strategy:
84
+ // - Parse existing file if present. On JSON error, skip the write (don't
85
+ // destroy what we can't parse; user can fix manually).
86
+ // - Preserve every top-level field via spread, preserve every existing
87
+ // mcpServers entry, and refresh (or insert) the gossipcat entry.
88
+ let existing = {};
89
+ if (existsSync(mcpConfig)) {
90
+ try {
91
+ const raw = readFileSync(mcpConfig, 'utf8');
92
+ const parsed = JSON.parse(raw);
93
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
94
+ existing = parsed;
95
+ }
96
+ } catch (_) {
97
+ console.warn(`gossipcat: existing .mcp.json at ${mcpConfig} is malformed — skipping update to avoid clobbering user config`);
98
+ process.exit(0);
99
+ }
100
+ }
101
+
102
+ const existingServers =
103
+ existing.mcpServers && typeof existing.mcpServers === 'object' && !Array.isArray(existing.mcpServers)
104
+ ? existing.mcpServers
105
+ : {};
106
+
43
107
  const config = {
108
+ ...existing,
44
109
  mcpServers: {
110
+ ...existingServers,
45
111
  gossipcat: {
46
112
  command: 'node',
47
113
  args: [mcpServerPath],
@@ -54,14 +120,37 @@ try {
54
120
  const method = isGlobal ? 'global npm' : isGitClone ? 'git clone' : 'local install';
55
121
  console.log(`gossipcat: wrote .mcp.json (${method}) → ${mcpServerPath}`);
56
122
  } catch (e) {
123
+ // Soft-fail on write errors: global npm installs often target root-owned dirs
124
+ // where EACCES is expected. Hard-exiting here would abort `npm install -g`
125
+ // for every user without sudo — users can re-run `gossipcat setup` after.
126
+ // Regression guard: tests/cli/install-packaging.test.ts:133-140.
57
127
  console.warn(`gossipcat: postinstall could not write .mcp.json (${e.code || e.message}). Run 'gossipcat setup' after install to configure.`);
58
128
  }
59
129
 
130
+ // Staleness check: warn if dist-mcp/ is older than package.json
131
+ if (existsSync(mcpServerPath)) {
132
+ try {
133
+ const serverMtime = statSync(mcpServerPath).mtime;
134
+ const pkgMtime = statSync(join(packageRoot, 'package.json')).mtime;
135
+ if (serverMtime < pkgMtime) {
136
+ console.log("gossipcat: dist-mcp/ is older than package.json — run 'npm run build:mcp' to rebuild");
137
+ }
138
+ } catch (_) { /* stat failure is non-fatal */ }
139
+ }
140
+
60
141
  if (!existsSync(mcpServerPath)) {
61
142
  if (isGitClone) {
62
- console.log('gossipcat: dist-mcp/mcp-server.js not built yet — run: npm run build:mcp');
143
+ // Git clone: build is required — run it automatically
144
+ const { execSync } = require('child_process');
145
+ console.log('gossipcat: dist-mcp/mcp-server.js not built yet — running npm run build:mcp...');
146
+ try {
147
+ execSync('npm run build:mcp', { stdio: 'inherit', cwd: packageRoot });
148
+ } catch (e) {
149
+ console.error('gossipcat: build failed. Run "npm run build:mcp" manually to complete setup.');
150
+ process.exit(1);
151
+ }
63
152
  } else {
64
- console.error('gossipcat: FATAL — dist-mcp/mcp-server.js missing from package. Install is corrupted.');
153
+ console.error('gossipcat: FATAL — dist-mcp/mcp-server.js missing from package. Install is corrupted; reinstall with `npm install -g gossipcat` or clone the repo and run `npm install && npm run build:mcp`.');
65
154
  process.exit(1);
66
155
  }
67
156
  }