brainclaw 1.7.5 → 1.9.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 (143) hide show
  1. package/README.md +28 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +139 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +502 -16
  15. package/dist/commands/init.js +123 -21
  16. package/dist/commands/loops-handlers.js +4 -0
  17. package/dist/commands/mcp-read-handlers.js +198 -29
  18. package/dist/commands/mcp.js +615 -92
  19. package/dist/commands/memory.js +21 -17
  20. package/dist/commands/migrate.js +81 -17
  21. package/dist/commands/prune.js +78 -4
  22. package/dist/commands/reflect.js +26 -20
  23. package/dist/commands/register-agent.js +57 -1
  24. package/dist/commands/repair.js +20 -0
  25. package/dist/commands/session-end.js +15 -6
  26. package/dist/commands/session-start.js +18 -1
  27. package/dist/commands/setup-security.js +39 -18
  28. package/dist/commands/setup.js +26 -27
  29. package/dist/commands/stale.js +16 -2
  30. package/dist/commands/uninstall.js +126 -34
  31. package/dist/commands/update-step.js +6 -0
  32. package/dist/commands/worktree.js +60 -0
  33. package/dist/core/actions.js +12 -3
  34. package/dist/core/agent-capability.js +11 -13
  35. package/dist/core/agent-files.js +844 -547
  36. package/dist/core/agent-integrations.js +0 -3
  37. package/dist/core/agent-inventory.js +67 -0
  38. package/dist/core/agent-registry.js +163 -29
  39. package/dist/core/agentrun-reconciler.js +33 -2
  40. package/dist/core/agentruns.js +7 -1
  41. package/dist/core/ai-agent-detection.js +31 -44
  42. package/dist/core/archival.js +15 -9
  43. package/dist/core/assignment-reconciler.js +56 -0
  44. package/dist/core/assignment-sweeper.js +127 -4
  45. package/dist/core/assignments.js +69 -11
  46. package/dist/core/bootstrap.js +233 -67
  47. package/dist/core/brainclaw-version.js +22 -0
  48. package/dist/core/candidates.js +21 -1
  49. package/dist/core/claims.js +313 -150
  50. package/dist/core/config.js +6 -1
  51. package/dist/core/context-diff.js +148 -20
  52. package/dist/core/context.js +129 -8
  53. package/dist/core/coordination.js +22 -3
  54. package/dist/core/dispatch-status.js +109 -5
  55. package/dist/core/dispatcher.js +65 -11
  56. package/dist/core/entity-operations.js +45 -24
  57. package/dist/core/entity-registry.js +31 -5
  58. package/dist/core/event-log.js +138 -21
  59. package/dist/core/events/checkpoint.js +258 -0
  60. package/dist/core/events/genesis.js +220 -0
  61. package/dist/core/events/journal.js +507 -0
  62. package/dist/core/events/materialize.js +126 -0
  63. package/dist/core/events/registry-post-image.js +110 -0
  64. package/dist/core/events/verify.js +109 -0
  65. package/dist/core/execution-adapters.js +23 -0
  66. package/dist/core/execution.js +25 -0
  67. package/dist/core/facade-schema.js +48 -0
  68. package/dist/core/gc-semantic.js +130 -5
  69. package/dist/core/handoff-snapshot.js +68 -0
  70. package/dist/core/ids.js +19 -8
  71. package/dist/core/instruction-templates.js +34 -115
  72. package/dist/core/io.js +39 -3
  73. package/dist/core/json-store.js +10 -1
  74. package/dist/core/lock.js +153 -28
  75. package/dist/core/loops/bootstrap-acquire.js +25 -1
  76. package/dist/core/loops/facade-schema.js +2 -0
  77. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  78. package/dist/core/loops/index.js +1 -0
  79. package/dist/core/loops/presets/bootstrap.js +7 -0
  80. package/dist/core/loops/store.js +17 -0
  81. package/dist/core/loops/verbs.js +24 -1
  82. package/dist/core/markdown.js +8 -76
  83. package/dist/core/mcp-command-resolution.js +245 -0
  84. package/dist/core/memory-compactor.js +5 -3
  85. package/dist/core/memory-lifecycle.js +282 -0
  86. package/dist/core/merge-risk.js +150 -0
  87. package/dist/core/messaging.js +8 -1
  88. package/dist/core/migration.js +11 -1
  89. package/dist/core/observer-mode.js +26 -0
  90. package/dist/core/operations/memory-mutation.js +90 -65
  91. package/dist/core/operations/plan.js +27 -1
  92. package/dist/core/protocol-skills.js +210 -0
  93. package/dist/core/reflection-safety.js +6 -7
  94. package/dist/core/reputation.js +84 -2
  95. package/dist/core/runtime-signals.js +71 -9
  96. package/dist/core/runtime.js +84 -1
  97. package/dist/core/schema.js +125 -0
  98. package/dist/core/security-detectors.js +125 -0
  99. package/dist/core/security-extract.js +189 -0
  100. package/dist/core/security-guard.js +107 -29
  101. package/dist/core/security-packages.js +121 -0
  102. package/dist/core/security-scoring.js +76 -9
  103. package/dist/core/security.js +34 -2
  104. package/dist/core/sequence.js +11 -2
  105. package/dist/core/setup-flow.js +141 -13
  106. package/dist/core/spawn-check.js +110 -4
  107. package/dist/core/staleness.js +109 -1
  108. package/dist/core/state.js +250 -54
  109. package/dist/core/store-resolution.js +19 -5
  110. package/dist/core/worktree.js +169 -7
  111. package/dist/facts.js +8 -8
  112. package/dist/facts.json +7 -7
  113. package/docs/PROTOCOL.md +223 -0
  114. package/docs/cli.md +11 -10
  115. package/docs/concepts/coordinator-runbook.md +129 -0
  116. package/docs/concepts/dispatch-lifecycle.md +17 -0
  117. package/docs/concepts/event-log-store-critique-A.md +333 -0
  118. package/docs/concepts/event-log-store-critique-B.md +353 -0
  119. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  120. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  121. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  122. package/docs/concepts/event-log-store.md +928 -0
  123. package/docs/concepts/identity-model-proposal.md +371 -0
  124. package/docs/concepts/memory.md +5 -4
  125. package/docs/concepts/observer-protocol.md +361 -0
  126. package/docs/concepts/parallel-merge-protocol.md +71 -0
  127. package/docs/concepts/plans-and-claims.md +43 -0
  128. package/docs/concepts/skills.md +78 -0
  129. package/docs/concepts/workspace-bootstrapping.md +61 -0
  130. package/docs/integrations/agents.md +4 -4
  131. package/docs/integrations/cline.md +10 -11
  132. package/docs/integrations/codex.md +2 -2
  133. package/docs/integrations/continue.md +5 -5
  134. package/docs/integrations/copilot.md +14 -12
  135. package/docs/integrations/openclaw.md +7 -6
  136. package/docs/integrations/overview.md +7 -7
  137. package/docs/integrations/roo.md +3 -3
  138. package/docs/integrations/windsurf.md +6 -6
  139. package/docs/mcp-schema-changelog.md +51 -20
  140. package/docs/quickstart.md +48 -47
  141. package/docs/security.md +174 -15
  142. package/docs/storage.md +4 -2
  143. package/package.json +8 -6
@@ -51,18 +51,15 @@ const DEFAULT_SURFACES = {
51
51
  ],
52
52
  'windsurf': [
53
53
  { kind: 'instructions', location: 'workspace', path: '.windsurfrules' },
54
- { kind: 'hook', location: 'workspace', path: '.windsurfrules' },
55
54
  { kind: 'mcp', location: 'machine', path: '.codeium/windsurf/mcp_config.json' },
56
55
  { kind: 'rule', location: 'workspace', path: '.windsurf/rules/brainclaw.md' },
57
56
  ],
58
57
  'cline': [
59
58
  { kind: 'instructions', location: 'workspace', path: '.clinerules/brainclaw.md' },
60
- { kind: 'hook', location: 'workspace', path: '.clinerules/brainclaw.md' },
61
59
  { kind: 'mcp', location: 'workspace', path: '.vscode/cline_mcp_settings.json' },
62
60
  ],
63
61
  'codex': [
64
62
  { kind: 'instructions', location: 'workspace', path: 'AGENTS.md' },
65
- { kind: 'hook', location: 'workspace', path: 'AGENTS.md' },
66
63
  { kind: 'mcp', location: 'machine', path: '.codex/config.toml' },
67
64
  { kind: 'skill', location: 'workspace', path: '.agents/skills/brainclaw/SKILL.md' },
68
65
  ],
@@ -336,6 +336,51 @@ const AGENT_DEFINITIONS = [
336
336
  hooks_support: false,
337
337
  instruction_file: '.continue/rules/',
338
338
  },
339
+ {
340
+ name: 'openclaw',
341
+ detect: (home, env) => {
342
+ if (env.OPENCLAW_SESSION_ID || env.OPENCLAW_AGENT) {
343
+ return { installed: true, method: 'OPENCLAW_* env' };
344
+ }
345
+ if (fs.existsSync(path.join(home, '.openclaw'))) {
346
+ return { installed: true, method: '~/.openclaw directory' };
347
+ }
348
+ return { installed: false, method: '' };
349
+ },
350
+ models: [
351
+ { name: 'model-agnostic' },
352
+ ],
353
+ native_tools: ['shell', 'file_read', 'file_write', 'file_edit'],
354
+ mcp_support: true,
355
+ mcp_config_format: '~/.openclaw/mcp.json',
356
+ skills_support: false,
357
+ rules_support: false,
358
+ hooks_support: false,
359
+ instruction_file: 'AGENTS.md',
360
+ },
361
+ {
362
+ name: 'mistral-vibe',
363
+ detect: (home, env) => {
364
+ if (env.VIBE_HOME?.trim()) {
365
+ return { installed: true, method: 'VIBE_HOME env' };
366
+ }
367
+ if (fs.existsSync(path.join(home, '.vibe'))) {
368
+ return { installed: true, method: '~/.vibe directory' };
369
+ }
370
+ return { installed: false, method: '' };
371
+ },
372
+ models: [
373
+ { name: 'devstral', context_window: 256000 },
374
+ { name: 'mistral-large', context_window: 128000 },
375
+ ],
376
+ native_tools: ['shell', 'file_read', 'file_write', 'file_edit'],
377
+ mcp_support: true,
378
+ mcp_config_format: '~/.vibe/mcp.json',
379
+ skills_support: false,
380
+ rules_support: false,
381
+ hooks_support: false,
382
+ instruction_file: 'AGENTS.md',
383
+ },
339
384
  {
340
385
  name: 'hermes',
341
386
  detect: (home, env) => {
@@ -437,6 +482,28 @@ export function loadAgentInventory() {
437
482
  return undefined;
438
483
  }
439
484
  }
485
+ /**
486
+ * Whether an agent is installed on this machine according to the saved
487
+ * inventory (the single source of truth for "installed" since the
488
+ * detection/inventory split — pln#562 step 1).
489
+ *
490
+ * Returns:
491
+ * - true / false when the inventory tracks the agent
492
+ * - undefined when no inventory exists yet or the agent is not tracked
493
+ * (callers must NOT treat undefined as "not installed")
494
+ *
495
+ * The inventory never mints identity — this is a consultation helper for
496
+ * warning paths only.
497
+ */
498
+ export function isAgentInstalledPerInventory(agentName, inventory = loadAgentInventory()) {
499
+ if (!inventory)
500
+ return undefined;
501
+ const normalized = agentName.trim().toLowerCase();
502
+ const entry = inventory.agents.find((a) => a.name === normalized);
503
+ if (!entry)
504
+ return undefined;
505
+ return entry.installed;
506
+ }
440
507
  /**
441
508
  * Render a human-readable summary of the agent inventory.
442
509
  */
@@ -2,7 +2,8 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { isKnownAgent, getCapabilityProfile } from './agent-capability.js';
5
+ import { isKnownAgent, getCapabilityProfile, resolveAgentAlias } from './agent-capability.js';
6
+ import { isAgentInstalledPerInventory } from './agent-inventory.js';
6
7
  import { detectAiAgent } from './ai-agent-detection.js';
7
8
  import { loadConfig, saveConfig } from './config.js';
8
9
  import { nowISO } from './ids.js';
@@ -10,6 +11,7 @@ import { MEMORY_DIR, memoryExists, resolveEntityDir } from './io.js';
10
11
  import { JsonStore } from './json-store.js';
11
12
  import { AgentIdentityDocumentSchema, } from './schema.js';
12
13
  import { logger } from './logger.js';
14
+ import { isObserverMode } from './observer-mode.js';
13
15
  // agents/ stays at top level in entity model (already entity-aligned)
14
16
  const TRUST_ORDER = ['observer', 'contributor', 'trusted', 'curator'];
15
17
  export class AgentIdentityResolutionError extends Error {
@@ -55,8 +57,13 @@ function agentStore(cwd, preferredDirName) {
55
57
  sort: (a, b) => a.created_at.localeCompare(b.created_at) || a.agent_name.localeCompare(b.agent_name),
56
58
  });
57
59
  }
60
+ /**
61
+ * Canonical agent name: lowercased, trimmed, and alias-resolved at the
62
+ * registry level (pln#562 step 2) — 'copilot' and 'github-copilot' are ONE
63
+ * identity, not two. All registry lookups and writes go through this.
64
+ */
58
65
  export function normalizeAgentName(agentName) {
59
- return agentName.trim().toLowerCase();
66
+ return resolveAgentAlias(agentName.trim().toLowerCase());
60
67
  }
61
68
  function normalizeCapability(capability) {
62
69
  const normalized = capability.trim().toLowerCase();
@@ -86,9 +93,47 @@ function codexHome(env = process.env) {
86
93
  const explicit = env.CODEX_HOME?.trim();
87
94
  return explicit && explicit.length > 0 ? explicit : path.join(os.homedir(), '.codex');
88
95
  }
89
- function agentKeyPath(agentId, env = process.env) {
96
+ /**
97
+ * ed25519 identity keys (pln#562 step 5).
98
+ *
99
+ * RESERVED for the federated identity model — these keys are not consumed by
100
+ * any verification path today; the identity proposal (origin signing)
101
+ * activates them. Do NOT delete them as debris.
102
+ *
103
+ * Private keys live under the NEUTRAL brainclaw home (~/.brainclaw/keys/),
104
+ * not under ~/.codex/: agent identity belongs to brainclaw, and parking
105
+ * private key material inside another vendor's config directory both
106
+ * misattributes it and exposes it to that vendor's tooling/sync.
107
+ */
108
+ function agentKeyPath(agentId) {
109
+ return path.join(os.homedir(), MEMORY_DIR, 'keys', `${agentId}.ed25519.pem`);
110
+ }
111
+ /** Pre-step-5 location (inside CODEX_HOME) — read for one-time migration. */
112
+ function legacyAgentKeyPath(agentId, env = process.env) {
90
113
  return path.join(codexHome(env), 'brainclaw', 'keys', `${agentId}.ed25519.pem`);
91
114
  }
115
+ /**
116
+ * Move a key file from the legacy ~/.codex location to the neutral path.
117
+ * Best-effort: a failed unlink leaves a duplicate, never a missing key.
118
+ */
119
+ function migrateLegacyAgentKey(agentId, env = process.env) {
120
+ const legacy = legacyAgentKeyPath(agentId, env);
121
+ const target = agentKeyPath(agentId);
122
+ if (fs.existsSync(target) || !fs.existsSync(legacy))
123
+ return;
124
+ try {
125
+ ensureParentDir(target);
126
+ fs.copyFileSync(legacy, target);
127
+ try {
128
+ fs.unlinkSync(legacy);
129
+ }
130
+ catch { /* duplicate is safe */ }
131
+ logger.debug(`Migrated agent identity key ${agentId} from ${legacy} to ${target}`);
132
+ }
133
+ catch (err) {
134
+ logger.debug('Failed to migrate legacy agent key:', err);
135
+ }
136
+ }
92
137
  function ensureParentDir(filepath) {
93
138
  const dir = path.dirname(filepath);
94
139
  if (!fs.existsSync(dir)) {
@@ -99,7 +144,8 @@ function fingerprintPublicKey(publicKey) {
99
144
  return crypto.createHash('sha256').update(publicKey).digest('hex');
100
145
  }
101
146
  function buildIdentityKey(agentId, env = process.env, forceRegenerate = false) {
102
- const filepath = agentKeyPath(agentId, env);
147
+ migrateLegacyAgentKey(agentId, env);
148
+ const filepath = agentKeyPath(agentId);
103
149
  const createdAt = nowISO();
104
150
  let publicKeyPem;
105
151
  if (!forceRegenerate && fs.existsSync(filepath)) {
@@ -152,6 +198,27 @@ export function findAgentIdentityById(agentId, cwd, preferredDirName) {
152
198
  export function registerAgentIdentity(input) {
153
199
  const normalizedCapabilities = normalizeCapabilities(input.capabilities);
154
200
  const existing = findAgentIdentityByName(input.agentName, input.cwd, input.preferredDirName);
201
+ // Observer mode (BRAINCLAW_OBSERVER=1) refuses to mint or mutate identity
202
+ // on the disk. A dashboard is not an agent — it must never auto-register
203
+ // (the 2026-06-10 leak where the VS Code extension impersonated whichever
204
+ // shell-parent agent VS Code was launched from). Return existing read-only,
205
+ // or a transient synthetic identity that callers can use without persisting.
206
+ if (isObserverMode()) {
207
+ if (existing)
208
+ return existing;
209
+ const normalizedNewName = normalizeAgentName(input.agentName);
210
+ return {
211
+ schema_version: 2,
212
+ version: 1,
213
+ agent_id: generateAgentId(),
214
+ agent_name: normalizedNewName,
215
+ created_at: nowISO(),
216
+ kind: input.kind ?? 'unknown',
217
+ trust_level: input.trustLevel ?? 'observer',
218
+ capabilities: normalizedCapabilities,
219
+ ...(input.contextProfile ? { context_profile: input.contextProfile } : {}),
220
+ };
221
+ }
155
222
  if (existing) {
156
223
  let updated = existing;
157
224
  if (input.kind && existing.kind !== input.kind) {
@@ -180,11 +247,22 @@ export function registerAgentIdentity(input) {
180
247
  }
181
248
  return updated;
182
249
  }
250
+ // Identity hardening (pln#562 step 1): a NEW identity claiming the name of
251
+ // an agent the inventory knows is NOT installed on this machine is suspect.
252
+ // Warn (the inventory is consultative) — it never blocks or mints identity.
253
+ const normalizedNewName = normalizeAgentName(input.agentName);
254
+ try {
255
+ if (isAgentInstalledPerInventory(normalizedNewName) === false) {
256
+ logger.warn(`Registering identity '${normalizedNewName}' but the agent inventory reports it is not installed on this machine. `
257
+ + 'Verify the claimed identity or refresh the inventory.');
258
+ }
259
+ }
260
+ catch { /* inventory consultation is best-effort */ }
183
261
  let created = {
184
262
  schema_version: 2,
185
263
  version: 1,
186
264
  agent_id: generateAgentId(),
187
- agent_name: normalizeAgentName(input.agentName),
265
+ agent_name: normalizedNewName,
188
266
  created_at: nowISO(),
189
267
  kind: input.kind ?? 'unknown',
190
268
  trust_level: input.trustLevel ?? 'contributor',
@@ -212,34 +290,24 @@ export function resolveCurrentAgentIdentity(cwd, preferredDirName, homeDir) {
212
290
  return byEnvName;
213
291
  }
214
292
  // Auto-detect from native agent env vars (e.g. CLAUDECODE, CURSOR_TRACE_ID, CODEX_THREAD_ID).
215
- // If detected agent is not registered, auto-register it as trusted agent.
216
293
  // This is the primary identification path for MCP servers and CLI hooks.
217
- const detected = detectAiAgent(process.env, homeDir);
294
+ //
295
+ // pln#562 step 2 — registration is an EXPLICIT act (setup selection, session
296
+ // start, dispatcher spawn). Resolution is a read path and must never mint an
297
+ // identity as a side effect; a detected-but-unregistered agent resolves to
298
+ // undefined and the caller decides whether to register explicitly.
299
+ const detected = detectAiAgent(process.env);
218
300
  if (detected) {
219
301
  // If the detected name matches an explicit env var that was already tried
220
- // and not found, don't auto-register — the caller expects a "not registered" error.
302
+ // and not found, the caller expects a "not registered" error.
221
303
  if (normalizeAgentName(detected.name) === normalizeAgentName(envAgentName)) {
222
304
  return undefined;
223
305
  }
224
306
  const byDetected = findAgentIdentityByName(detected.name, cwd, preferredDirName);
225
307
  if (byDetected)
226
308
  return byDetected;
227
- // Auto-register detected agent so it's immediately usable.
228
- // This avoids the "not registered" error for agents detected for the first time.
229
- try {
230
- const autoRegistered = registerAgentIdentity({
231
- agentName: normalizeAgentName(detected.name),
232
- kind: detected.kind,
233
- trustLevel: detected.trust_level,
234
- cwd,
235
- preferredDirName,
236
- });
237
- logger.debug(`Auto-registered detected agent: ${detected.name} (${autoRegistered.agent_id})`);
238
- return autoRegistered;
239
- }
240
- catch {
241
- // Non-fatal: registration may fail if store is read-only
242
- }
309
+ logger.debug(`Detected agent '${detected.name}' is not registered; read-path resolution does not auto-register `
310
+ + '(register via setup, session start, or dispatch).');
243
311
  }
244
312
  // config.current_agent is NOT used for identity resolution — it's a singleton global
245
313
  // that gets overwritten by whichever agent last ran register-agent --set-current.
@@ -370,10 +438,12 @@ export function resolveOrAutoRegisterAgentIdentity(options = {}) {
370
438
  catch (err) {
371
439
  if (!(err instanceof AgentIdentityResolutionError))
372
440
  throw err;
373
- // Last-resort: derive a name from explicit arg or env and auto-register.
374
- // This allows session_start to succeed even for agents not yet registered.
441
+ // Last-resort: derive a name from explicit arg, env, or runtime detection
442
+ // and auto-register. Session start is an EXPLICIT act (pln#562 step 2), so
443
+ // it is allowed to register — unlike read-path resolution, which is not.
375
444
  const candidateName = options.agentName?.trim()
376
- || (options.allowEnv !== false ? resolveEnvAgentName(options.env ?? process.env) : undefined);
445
+ || (options.allowEnv !== false ? resolveEnvAgentName(options.env ?? process.env) : undefined)
446
+ || detectAiAgent(options.env ?? process.env)?.name;
377
447
  if (!candidateName)
378
448
  throw err;
379
449
  const normalizedName = normalizeAgentName(candidateName);
@@ -467,11 +537,11 @@ export function resolveCurrentModel(cwd) {
467
537
  * Note: config.current_agent is intentionally NOT used here — it's a singleton
468
538
  * global that causes cross-agent confusion in multi-agent setups.
469
539
  */
470
- export function resolveCurrentAgentName(cwd, homeDir) {
540
+ export function resolveCurrentAgentName(cwd, _homeDir) {
471
541
  const fromEnv = (process.env.BRAINCLAW_AGENT_NAME ?? process.env.BRAINCLAW_AGENT)?.trim();
472
542
  if (fromEnv)
473
543
  return fromEnv;
474
- const detected = detectAiAgent(process.env, homeDir);
544
+ const detected = detectAiAgent(process.env);
475
545
  if (detected)
476
546
  return detected.name;
477
547
  return process.env.USER ?? process.env.USERNAME ?? 'unknown';
@@ -559,4 +629,68 @@ export function agentCanCurate(agentNameOrId, cwd) {
559
629
  const level = getAgentTrustLevel(agentNameOrId, cwd);
560
630
  return level === 'curator';
561
631
  }
632
+ // ── Debris identity cleanup (pln#562 step 2) ────────────────────────────────
633
+ /**
634
+ * Identity names known to be registration debris: test fixtures and
635
+ * model-as-identity artifacts that leaked into real stores through the old
636
+ * permissive auto-registration paths.
637
+ */
638
+ export const DEBRIS_AGENT_NAMES = ['testuser', 'contributor-bot', 'claude-sonnet'];
639
+ /**
640
+ * List identities that look like registration debris:
641
+ * - names on the known-debris list (test fixtures, model-as-identity)
642
+ * - identities stored under an alias of a canonical agent name (e.g. a
643
+ * 'copilot' document now shadowed by the registry-level alias merge)
644
+ *
645
+ * Read-only — cleanup is a separate, guarded act (removeAgentIdentity).
646
+ */
647
+ export function listDebrisAgentIdentities(cwd, preferredDirName) {
648
+ const debris = [];
649
+ for (const identity of listAgentIdentities(cwd, preferredDirName)) {
650
+ const stored = identity.agent_name.trim().toLowerCase();
651
+ if (DEBRIS_AGENT_NAMES.includes(stored)) {
652
+ debris.push({ identity, reason: `'${stored}' is a known debris identity name` });
653
+ continue;
654
+ }
655
+ const canonical = resolveAgentAlias(stored);
656
+ if (canonical !== stored) {
657
+ debris.push({
658
+ identity,
659
+ reason: `'${stored}' is an alias of '${canonical}' — superseded by the registry-level alias merge`,
660
+ });
661
+ }
662
+ }
663
+ return debris;
664
+ }
665
+ /**
666
+ * Remove a registered agent identity — guarded, never silent.
667
+ *
668
+ * Refuses unless the identity is flagged as debris (listDebrisAgentIdentities)
669
+ * or the caller passes force:true. Curator identities are never removed
670
+ * without force. Returns the removed document so callers can report exactly
671
+ * what was deleted.
672
+ */
673
+ export function removeAgentIdentity(agentNameOrId, options = {}) {
674
+ const { cwd, preferredDirName, force } = options;
675
+ const identity = findAgentIdentityById(agentNameOrId, cwd, preferredDirName)
676
+ ?? findAgentIdentityByName(agentNameOrId, cwd, preferredDirName)
677
+ // Alias-debris docs are unreachable via normalized name lookup — match the raw stored name.
678
+ ?? listAgentIdentities(cwd, preferredDirName).find((a) => a.agent_name.trim().toLowerCase() === agentNameOrId.trim().toLowerCase());
679
+ if (!identity) {
680
+ throw new AgentIdentityResolutionError(`Agent '${agentNameOrId}' not found.`, { agent_name: agentNameOrId });
681
+ }
682
+ if (!force) {
683
+ if (identity.trust_level === 'curator') {
684
+ throw new AgentTrustError(`Refusing to remove curator identity '${identity.agent_name}' without force.`, { agent_id: identity.agent_id, agent_name: identity.agent_name });
685
+ }
686
+ const isDebris = listDebrisAgentIdentities(cwd, preferredDirName)
687
+ .some((d) => d.identity.agent_id === identity.agent_id);
688
+ if (!isDebris) {
689
+ throw new AgentIdentityResolutionError(`Refusing to remove '${identity.agent_name}': not a known debris identity. Pass force to override.`, { agent_id: identity.agent_id, agent_name: identity.agent_name });
690
+ }
691
+ }
692
+ agentStore(cwd, preferredDirName).delete(identity.agent_id);
693
+ logger.debug(`Removed agent identity ${identity.agent_name} (${identity.agent_id})`);
694
+ return identity;
695
+ }
562
696
  //# sourceMappingURL=agent-registry.js.map
@@ -169,7 +169,10 @@ export function collectEvidence(run, cwd, options) {
169
169
  try {
170
170
  completed_signal = signalExists(signalRoot, run.assignment_id, 'completed');
171
171
  failed_signal = signalExists(signalRoot, run.assignment_id, 'failed');
172
- const hb = readHeartbeat(signalRoot, run.assignment_id);
172
+ // sprint 1.5: also read the worktree-local heartbeat — the only location a
173
+ // sandboxed worker can write (the project-root signal dir is outside its
174
+ // writable roots).
175
+ const hb = readHeartbeat(signalRoot, run.assignment_id, run.worktree_path);
173
176
  heartbeat_exists = hb.exists;
174
177
  if (hb.exists && hb.mtimeMs !== undefined)
175
178
  heartbeat_age_ms = now - hb.mtimeMs;
@@ -268,7 +271,28 @@ function describeEvidence(evidence) {
268
271
  return reasons.join(' + ');
269
272
  }
270
273
  // ── Synthetic event for unverified spawns ──────────────────────────────────
274
+ /**
275
+ * Per-run throttle for "delivered_but_unverified" events (pln#558 step 5).
276
+ *
277
+ * Before this throttle every reconciliation pass during the health-check
278
+ * window produced a fresh runtime_event file. With the VS Code extension
279
+ * polling kind='board' every 30s, both reconciliation passes firing per
280
+ * poll, and several non-terminal runs in flight, the store accumulated
281
+ * ~120 of these files per hour per run — all writing under the mutation
282
+ * lock, none ever surfaced to the UI.
283
+ *
284
+ * Surfacing the uncertainty once per window is enough; the event content
285
+ * is monotonic (age increases) so re-emitting adds no information.
286
+ */
287
+ const UNVERIFIED_EVENT_THROTTLE_MS = 5 * 60_000;
288
+ const lastUnverifiedEmitAt = new Map();
271
289
  function emitUnverifiedEvent(run, evidence, actor, cwd) {
290
+ const now = Date.now();
291
+ const last = lastUnverifiedEmitAt.get(run.id);
292
+ if (last !== undefined && now - last < UNVERIFIED_EVENT_THROTTLE_MS) {
293
+ return;
294
+ }
295
+ lastUnverifiedEmitAt.set(run.id, now);
272
296
  try {
273
297
  createRuntimeEvent({
274
298
  agent: actor,
@@ -551,6 +575,13 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
551
575
  // window (trp#292 — must converge HERE since the read path never routes
552
576
  // through reconcileAgentRun), giving an untrusted-pid worker ample time.
553
577
  if (evidence.age_ms >= stale) {
578
+ if (fsActiveWithin(evidence, heartbeatStale)) {
579
+ return {
580
+ run_id: run.id, action: 'no_op',
581
+ reason: `no heartbeat but fs active ${Math.round((evidence.fs_activity_age_ms ?? 0) / 1000)}s ago - working, not silent`,
582
+ evidence, previous_status: run.status, current_status: run.status,
583
+ };
584
+ }
554
585
  return failRun('silent_termination_no_evidence');
555
586
  }
556
587
  emitUnverifiedEvent(run, evidence, actor, cwd);
@@ -562,7 +593,7 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
562
593
  }
563
594
  export function sweepDeadPidRunningAgentRunsAtRead(cwd, options = {}) {
564
595
  const now = options.nowMs ?? Date.now();
565
- const minAgeMs = options.staleAfterMs ?? DEFAULT_DEAD_PID_READ_SWEEP_AGE_MS;
596
+ const minAgeMs = options.deadPidSweepCandidateAgeMs ?? DEFAULT_DEAD_PID_READ_SWEEP_AGE_MS;
566
597
  const cutoff = now - minAgeMs;
567
598
  const limit = options.limit ?? DEFAULT_DEAD_PID_READ_SWEEP_LIMIT;
568
599
  const candidates = listAgentRuns(cwd, { status: 'running' })
@@ -15,6 +15,7 @@ import { JsonStore } from './json-store.js';
15
15
  import { appendAuditEntry } from './audit.js';
16
16
  import { appendEvent } from './event-log.js';
17
17
  import { createRuntimeEvent } from './events.js';
18
+ import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
18
19
  function agentRunsDir(cwd, mode = 'read') {
19
20
  return resolveEntityDir('runs', cwd, mode);
20
21
  }
@@ -46,7 +47,12 @@ export function saveAgentRun(run, cwd) {
46
47
  getId: (item) => item.id,
47
48
  sort: (a, b) => a.created_at.localeCompare(b.created_at),
48
49
  });
49
- store.save(AgentRunSchema.parse(run));
50
+ const parsed = AgentRunSchema.parse(run);
51
+ // pln#568 (I2): journal the post-image BEFORE the projection write.
52
+ const created = !store.exists(parsed.id);
53
+ emitRegistryPostImage('agent_run', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
54
+ registryFaultPoint('after_registry_journal');
55
+ store.save(parsed);
50
56
  });
51
57
  }
52
58
  export function loadAgentRun(id, cwd) {
@@ -1,11 +1,15 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
1
  /**
5
2
  * Detects the AI coding agent running in the current environment by inspecting
6
- * environment variables and well-known config paths. Returns the first confident
3
+ * PROCESS-SCOPED environment variables ONLY. Returns the first confident
7
4
  * match, or undefined if no agent is detected.
8
5
  *
6
+ * Identity hardening (pln#562 step 1): directory-presence fallbacks
7
+ * (~/.config/opencode, ~/.gemini/antigravity, ~/.openclaw, ~/.vibe, ~/.hermes)
8
+ * were removed — a config directory proves an agent is INSTALLED on the
9
+ * machine, not that it is the agent driving THIS process. Installed-ness is
10
+ * now answered exclusively by agent-inventory (buildAgentInventory). The
11
+ * inventory never mints identity; this function never consults the disk.
12
+ *
9
13
  * Detection order (highest confidence first — agents with dedicated env vars
10
14
  * are tested before agents detected via passive/ambient env vars):
11
15
  * 1. BRAINCLAW_AGENT env var (explicit override)
@@ -14,21 +18,21 @@ import os from 'node:os';
14
18
  * 4. Windsurf (WINDSURF_SESSION_ID — set by Windsurf itself)
15
19
  * 5. Cline (CLINE_AGENT — set by Cline itself)
16
20
  * 6. GitHub Copilot (GITHUB_COPILOT_PRODUCT — passive VS Code env, tested after active agents)
17
- * 7. Codex CLI (~/.codex/ directory exists)
18
- * 8. OpenCode (OPENCODE_* env or ~/.config/opencode/)
19
- * 9. Antigravity / Gemini CLI (ANTIGRAVITY_* env or ~/.gemini/antigravity/)
21
+ * 7. Codex CLI (CODEX_THREAD_ID / CODEX_CI / …)
22
+ * 8. OpenCode (OPENCODE_*)
23
+ * 9. Antigravity / Gemini CLI (ANTIGRAVITY_*)
20
24
  * 10. Continue (CONTINUE_*)
21
25
  * 11. Roo Code (ROO_*)
22
- * 12. Hermes (HERMES_* or ~/.hermes/)
23
- * 13. OpenClaw (~/.openclaw/ or OPENCLAW_*)
26
+ * 12. OpenClaw (OPENCLAW_*)
27
+ * 13. Mistral Vibe (VIBE_HOME)
28
+ * 14. Hermes (HERMES_*)
24
29
  */
25
- export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
30
+ export function detectAiAgent(env = process.env) {
26
31
  // Explicit override
27
32
  if (env.BRAINCLAW_AGENT?.trim()) {
28
33
  return {
29
34
  name: env.BRAINCLAW_AGENT.trim(),
30
35
  kind: 'agent',
31
- trust_level: 'trusted',
32
36
  detection_source: 'BRAINCLAW_AGENT env var',
33
37
  };
34
38
  }
@@ -44,7 +48,6 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
44
48
  return {
45
49
  name: 'claude-code',
46
50
  kind: 'agent',
47
- trust_level: 'trusted',
48
51
  detection_source: source,
49
52
  };
50
53
  }
@@ -53,7 +56,6 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
53
56
  return {
54
57
  name: 'cursor',
55
58
  kind: 'agent',
56
- trust_level: 'trusted',
57
59
  detection_source: 'CURSOR_* env var',
58
60
  };
59
61
  }
@@ -62,7 +64,6 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
62
64
  return {
63
65
  name: 'windsurf',
64
66
  kind: 'agent',
65
- trust_level: 'trusted',
66
67
  detection_source: 'WINDSURF_* env var',
67
68
  };
68
69
  }
@@ -71,7 +72,6 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
71
72
  return {
72
73
  name: 'cline',
73
74
  kind: 'agent',
74
- trust_level: 'trusted',
75
75
  detection_source: 'CLINE_* env var',
76
76
  };
77
77
  }
@@ -85,7 +85,6 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
85
85
  return {
86
86
  name: 'github-copilot',
87
87
  kind: 'agent',
88
- trust_level: 'trusted',
89
88
  detection_source: env.GITHUB_COPILOT_PRODUCT ? 'GITHUB_COPILOT_PRODUCT env var' : 'GITHUB_COPILOT_TOKEN env var',
90
89
  };
91
90
  }
@@ -101,26 +100,23 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
101
100
  return {
102
101
  name: 'codex',
103
102
  kind: 'agent',
104
- trust_level: 'trusted',
105
103
  detection_source: source,
106
104
  };
107
105
  }
108
106
  // OpenCode
109
- if (env.OPENCODE_SESSION_ID || env.OPENCODE_AGENT || fs.existsSync(path.join(homeDir, '.config', 'opencode'))) {
107
+ if (env.OPENCODE_SESSION_ID || env.OPENCODE_AGENT) {
110
108
  return {
111
109
  name: 'opencode',
112
110
  kind: 'agent',
113
- trust_level: 'trusted',
114
- detection_source: env.OPENCODE_SESSION_ID || env.OPENCODE_AGENT ? 'OPENCODE_* env var' : '~/.config/opencode directory',
111
+ detection_source: 'OPENCODE_* env var',
115
112
  };
116
113
  }
117
114
  // Antigravity (Google Gemini CLI)
118
- if (env.ANTIGRAVITY_SESSION_ID || env.ANTIGRAVITY_AGENT || fs.existsSync(path.join(homeDir, '.gemini', 'antigravity'))) {
115
+ if (env.ANTIGRAVITY_SESSION_ID || env.ANTIGRAVITY_AGENT) {
119
116
  return {
120
117
  name: 'antigravity',
121
118
  kind: 'agent',
122
- trust_level: 'trusted',
123
- detection_source: env.ANTIGRAVITY_SESSION_ID || env.ANTIGRAVITY_AGENT ? 'ANTIGRAVITY_* env var' : '~/.gemini/antigravity directory',
119
+ detection_source: 'ANTIGRAVITY_* env var',
124
120
  };
125
121
  }
126
122
  // Continue.dev
@@ -128,7 +124,6 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
128
124
  return {
129
125
  name: 'continue',
130
126
  kind: 'agent',
131
- trust_level: 'trusted',
132
127
  detection_source: 'CONTINUE_* env var',
133
128
  };
134
129
  }
@@ -137,44 +132,36 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
137
132
  return {
138
133
  name: 'roo',
139
134
  kind: 'agent',
140
- trust_level: 'trusted',
141
135
  detection_source: 'ROO_* env var',
142
136
  };
143
137
  }
144
- // OpenClaw (~/.openclaw/ presence or OPENCLAW_* env)
145
- if (env.OPENCLAW_SESSION_ID || env.OPENCLAW_AGENT || fs.existsSync(path.join(homeDir, '.openclaw'))) {
138
+ // OpenClaw
139
+ if (env.OPENCLAW_SESSION_ID || env.OPENCLAW_AGENT) {
146
140
  return {
147
141
  name: 'openclaw',
148
142
  kind: 'agent',
149
- trust_level: 'trusted',
150
- detection_source: env.OPENCLAW_SESSION_ID || env.OPENCLAW_AGENT ? 'OPENCLAW_* env var' : '~/.openclaw directory',
143
+ detection_source: 'OPENCLAW_* env var',
151
144
  };
152
145
  }
153
- // Mistral Vibe — no dedicated session env var documented (per pln#489 research).
154
- // Detect via the user-level config dir (~/.vibe/) which Mistral creates on first
155
- // run, or via VIBE_HOME override. Tested last so other agents with dedicated
156
- // session env vars take precedence.
157
- if (env.VIBE_HOME || fs.existsSync(path.join(homeDir, '.vibe'))) {
146
+ // Mistral Vibe — no dedicated session env var documented (per pln#489
147
+ // research). VIBE_HOME is the only process-scoped marker; the ~/.vibe
148
+ // directory check moved to agent-inventory.
149
+ if (env.VIBE_HOME) {
158
150
  return {
159
151
  name: 'mistral-vibe',
160
152
  kind: 'agent',
161
- trust_level: 'trusted',
162
- detection_source: env.VIBE_HOME ? 'VIBE_HOME env var' : '~/.vibe directory',
153
+ detection_source: 'VIBE_HOME env var',
163
154
  };
164
155
  }
165
- // Hermes Agent — supports MCP and skills from ~/.hermes/. Detect after
166
- // editor/CLI agents with stronger session env vars to avoid stealing mixed
167
- // shells where Hermes is merely installed.
168
- if (env.HERMES_SESSION_ID || env.HERMES_AGENT || env.HERMES_HOME || fs.existsSync(path.join(homeDir, '.hermes'))) {
156
+ // Hermes Agent — detect after editor/CLI agents with stronger session env
157
+ // vars to avoid stealing mixed shells where Hermes is merely installed.
158
+ if (env.HERMES_SESSION_ID || env.HERMES_AGENT || env.HERMES_HOME) {
169
159
  return {
170
160
  name: 'hermes',
171
161
  kind: 'autonomous',
172
- trust_level: 'trusted',
173
162
  detection_source: env.HERMES_SESSION_ID || env.HERMES_AGENT
174
163
  ? 'HERMES_* env var'
175
- : env.HERMES_HOME
176
- ? 'HERMES_HOME env var'
177
- : '~/.hermes directory',
164
+ : 'HERMES_HOME env var',
178
165
  };
179
166
  }
180
167
  return undefined;