borgmcp 3.2.0 → 3.4.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.
- package/README.md +18 -3
- package/dist/agent-integration-health.d.ts +44 -0
- package/dist/agent-integration-health.d.ts.map +1 -0
- package/dist/agent-integration-health.js +284 -0
- package/dist/agent-integration-health.js.map +1 -0
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +13 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +11 -3
- package/dist/cli-help.js.map +1 -1
- package/dist/config-utils.d.ts +21 -0
- package/dist/config-utils.d.ts.map +1 -1
- package/dist/config-utils.js +186 -20
- package/dist/config-utils.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -6
- package/dist/index.js.map +1 -1
- package/dist/log-audit-core.d.ts +7 -0
- package/dist/log-audit-core.d.ts.map +1 -0
- package/dist/log-audit-core.js +80 -0
- package/dist/log-audit-core.js.map +1 -0
- package/dist/log-audit.d.ts +3 -3
- package/dist/log-audit.js +7 -110
- package/dist/log-audit.js.map +1 -1
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +6 -1
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-plugin.d.ts +47 -7
- package/dist/opencode-plugin.d.ts.map +1 -1
- package/dist/opencode-plugin.js +187 -23
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +5 -6
- package/dist/regen-format.js.map +1 -1
- package/dist/resolved-cli-config.d.ts +1 -0
- package/dist/resolved-cli-config.d.ts.map +1 -1
- package/dist/resolved-cli-config.js +1 -0
- package/dist/resolved-cli-config.js.map +1 -1
- package/dist/startup-services.d.ts +0 -1
- package/dist/startup-services.d.ts.map +1 -1
- package/dist/startup-services.js +0 -2
- package/dist/startup-services.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +1 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/dist/update-cmd.d.ts +1 -1
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +15 -8
- package/dist/update-cmd.js.map +1 -1
- package/docs/LOCAL_SERVER.md +28 -7
- package/package.json +1 -1
- package/src/agent-integration-health.ts +347 -0
- package/src/claude.ts +14 -0
- package/src/cli-help.ts +14 -3
- package/src/config-utils.ts +185 -20
- package/src/index.ts +1 -7
- package/src/log-audit-core.ts +81 -0
- package/src/log-audit.ts +6 -117
- package/src/opencode-drone.ts +6 -1
- package/src/opencode-plugin.ts +216 -22
- package/src/regen-format.ts +5 -6
- package/src/resolved-cli-config.ts +2 -0
- package/src/startup-services.ts +0 -3
- package/src/unknown-subcommand.ts +1 -0
- package/src/update-cmd.ts +18 -11
package/src/config-utils.ts
CHANGED
|
@@ -28,13 +28,13 @@ import type { LaunchAccessPaths } from './launch-access.js';
|
|
|
28
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
29
29
|
const __dirname = dirname(__filename);
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
const HOOK_COMMAND =
|
|
35
|
-
const CLEAR_REWAKE_HOOK_COMMAND =
|
|
36
|
-
const AUDIT_HOOK_COMMAND =
|
|
37
|
-
const FOREIGN_PATH_REMINDER_HOOK_COMMAND =
|
|
31
|
+
// client#394: hook commands are stable npm bin names. They intentionally stay
|
|
32
|
+
// unquoted: each is a fixed single shell token with no metacharacters. Legacy
|
|
33
|
+
// quoted bare names and install-specific absolute paths migrate to this form.
|
|
34
|
+
const HOOK_COMMAND = 'borg-regen';
|
|
35
|
+
const CLEAR_REWAKE_HOOK_COMMAND = 'borg-clear-rewake';
|
|
36
|
+
const AUDIT_HOOK_COMMAND = 'borg-log-audit';
|
|
37
|
+
const FOREIGN_PATH_REMINDER_HOOK_COMMAND = 'borg-foreign-path-reminder';
|
|
38
38
|
const MCP_COMMAND = 'borg-mcp';
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -234,8 +234,7 @@ function addSessionStartHookAt(settingsFile: string, includeClearRewake = false)
|
|
|
234
234
|
return true;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
//
|
|
238
|
-
// installations), shell-escaped canonical paths, and unescaped canonical paths.
|
|
237
|
+
// Match stable bare names plus legacy install-specific absolute forms.
|
|
239
238
|
function commandMatches(entryCommand: string, bareName: string, absolutePath: string): boolean {
|
|
240
239
|
const escaped = shellEscape(absolutePath);
|
|
241
240
|
if (entryCommand === escaped || entryCommand === absolutePath || entryCommand === bareName) return true;
|
|
@@ -254,7 +253,7 @@ function commandMatches(entryCommand: string, bareName: string, absolutePath: st
|
|
|
254
253
|
* absolute command — always owned, no marker required (fixes neutral-path
|
|
255
254
|
* false negatives that broke idempotency)
|
|
256
255
|
* (c) Foreign-install heuristic: absolute path ending in an owned basename
|
|
257
|
-
*
|
|
256
|
+
* immediately below an exact borgmcp|borg-mcp/dist package path
|
|
258
257
|
* This prevents false-positive ownership of unrelated scripts that happen
|
|
259
258
|
* to share a basename (e.g. /opt/custom-tool/regen.js). */
|
|
260
259
|
function ownedCanonical(command: string): string | null {
|
|
@@ -273,9 +272,16 @@ function ownedCanonical(command: string): string | null {
|
|
|
273
272
|
if (command === AUDIT_HOOK_COMMAND || stripped === resolveLogAuditPath()) return AUDIT_HOOK_COMMAND;
|
|
274
273
|
if (command === FOREIGN_PATH_REMINDER_HOOK_COMMAND || stripped === resolveForeignPathReminderPath()) return FOREIGN_PATH_REMINDER_HOOK_COMMAND;
|
|
275
274
|
|
|
276
|
-
// (c) Foreign-install heuristic:
|
|
277
|
-
|
|
278
|
-
|
|
275
|
+
// (c) Foreign-install heuristic: require exact path segments shaped like a
|
|
276
|
+
// package root. A substring such as /opt/borgmcp-tools/ is not ownership.
|
|
277
|
+
const segments = stripped.split('/');
|
|
278
|
+
const packageIndex = segments.length - 3;
|
|
279
|
+
const packageShaped = packageIndex >= 0 &&
|
|
280
|
+
(segments[packageIndex] === 'borgmcp' || segments[packageIndex] === 'borg-mcp') &&
|
|
281
|
+
segments[packageIndex + 1] === 'dist' &&
|
|
282
|
+
packageIndex + 2 === segments.length - 1;
|
|
283
|
+
if (stripped.startsWith('/') && packageShaped) {
|
|
284
|
+
const name = segments.at(-1) ?? '';
|
|
279
285
|
if (name === 'regen.js') return HOOK_COMMAND;
|
|
280
286
|
if (name === 'clear-rewake.js') return CLEAR_REWAKE_HOOK_COMMAND;
|
|
281
287
|
if (name === 'log-audit.js') return AUDIT_HOOK_COMMAND;
|
|
@@ -311,6 +317,7 @@ function migrateAndDedupOwnedHooks(entries: any[]): boolean {
|
|
|
311
317
|
// object across all entries. Remove only the duplicate hook objects, not
|
|
312
318
|
// entire entries (preserving unrelated siblings and entry metadata).
|
|
313
319
|
const seenCanonicals = new Set<string>();
|
|
320
|
+
const emptiedByOwnedDedup = new Set<any>();
|
|
314
321
|
for (const entry of entries) {
|
|
315
322
|
if (!Array.isArray(entry?.hooks)) continue;
|
|
316
323
|
const before = entry.hooks.length;
|
|
@@ -326,11 +333,13 @@ function migrateAndDedupOwnedHooks(entries: any[]): boolean {
|
|
|
326
333
|
return true;
|
|
327
334
|
});
|
|
328
335
|
if (entry.hooks.length !== before) changed = true;
|
|
336
|
+
if (before > 0 && entry.hooks.length === 0) emptiedByOwnedDedup.add(entry);
|
|
329
337
|
}
|
|
330
338
|
|
|
331
|
-
// Phase 3:
|
|
339
|
+
// Phase 3: remove only entries emptied by removal of duplicate Borg-owned
|
|
340
|
+
// hooks. Pre-existing empty or unusual operator entries remain byte-stable.
|
|
332
341
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
333
|
-
if (
|
|
342
|
+
if (emptiedByOwnedDedup.has(entries[i])) {
|
|
334
343
|
entries.splice(i, 1);
|
|
335
344
|
changed = true;
|
|
336
345
|
}
|
|
@@ -339,9 +348,165 @@ function migrateAndDedupOwnedHooks(entries: any[]): boolean {
|
|
|
339
348
|
return changed;
|
|
340
349
|
}
|
|
341
350
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
351
|
+
export interface RefreshManagedAgentHookConfigOptions {
|
|
352
|
+
homeDir?: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface ManagedAgentHookConfigHealth {
|
|
356
|
+
path: string;
|
|
357
|
+
status: 'absent' | 'ok' | 'stale' | 'invalid';
|
|
358
|
+
detail?: string;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function globalManagedAgentHookConfigPaths(homeDir: string): string[] {
|
|
362
|
+
return [
|
|
363
|
+
path.join(homeDir, '.claude', 'settings.json'),
|
|
364
|
+
path.join(homeDir, '.codex', 'hooks.json'),
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function realDirectory(pathname: string): boolean {
|
|
369
|
+
try {
|
|
370
|
+
const stat = fs.lstatSync(pathname);
|
|
371
|
+
return stat.isDirectory() && !stat.isSymbolicLink();
|
|
372
|
+
} catch {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Enumerate only Borg's canonical two-level managed-worktree layout. Every
|
|
379
|
+
* traversed component must be a real directory; symlinks are never followed.
|
|
380
|
+
*/
|
|
381
|
+
export function managedAgentHookConfigPaths(homeDir: string = CONFIG_HOME): string[] {
|
|
382
|
+
const paths = globalManagedAgentHookConfigPaths(homeDir);
|
|
383
|
+
const borgDir = path.join(homeDir, '.borg');
|
|
384
|
+
if (!realDirectory(borgDir)) return paths;
|
|
385
|
+
const root = path.join(borgDir, 'worktrees');
|
|
386
|
+
if (!realDirectory(root)) return paths;
|
|
387
|
+
|
|
388
|
+
for (const repo of fs.readdirSync(root, { withFileTypes: true })) {
|
|
389
|
+
const repoPath = path.join(root, repo.name);
|
|
390
|
+
if (!repo.isDirectory() || !realDirectory(repoPath)) continue;
|
|
391
|
+
for (const worktree of fs.readdirSync(repoPath, { withFileTypes: true })) {
|
|
392
|
+
const worktreePath = path.join(repoPath, worktree.name);
|
|
393
|
+
if (!worktree.isDirectory() || !realDirectory(worktreePath)) continue;
|
|
394
|
+
const claudeDir = path.join(worktreePath, '.claude');
|
|
395
|
+
if (!realDirectory(claudeDir)) continue;
|
|
396
|
+
const settingsFile = path.join(claudeDir, 'settings.local.json');
|
|
397
|
+
try {
|
|
398
|
+
if (fs.lstatSync(settingsFile).isSymbolicLink()) continue;
|
|
399
|
+
} catch {
|
|
400
|
+
// No settings file in this canonical worktree; nothing to inventory.
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
paths.push(settingsFile);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return paths;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function refreshHookFile(configPath: string): boolean {
|
|
410
|
+
if (!fs.existsSync(configPath)) return false;
|
|
411
|
+
const config = readJsonFile(configPath);
|
|
412
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
413
|
+
throw new Error('top-level value is not an object');
|
|
414
|
+
}
|
|
415
|
+
const hooks = config.hooks;
|
|
416
|
+
if (hooks === undefined) return false;
|
|
417
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) {
|
|
418
|
+
throw new Error('hooks is not an object');
|
|
419
|
+
}
|
|
420
|
+
let changed = false;
|
|
421
|
+
for (const entries of Object.values(hooks)) {
|
|
422
|
+
if (!Array.isArray(entries)) continue;
|
|
423
|
+
changed = migrateAndDedupOwnedHooks(entries) || changed;
|
|
424
|
+
}
|
|
425
|
+
if (changed) writeJsonFile(configPath, config);
|
|
426
|
+
return changed;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Heal stale Borg-owned hook commands in global agent files and canonical
|
|
431
|
+
* managed worktrees. Non-Borg hooks and noncanonical worktree roots are left
|
|
432
|
+
* untouched. Later files are still attempted when one file is invalid.
|
|
433
|
+
*/
|
|
434
|
+
export function refreshManagedAgentHookConfigs(
|
|
435
|
+
options: RefreshManagedAgentHookConfigOptions = {},
|
|
436
|
+
): string[] {
|
|
437
|
+
const homeDir = options.homeDir ?? CONFIG_HOME;
|
|
438
|
+
const refreshed: string[] = [];
|
|
439
|
+
const failures: string[] = [];
|
|
440
|
+
let configPaths = globalManagedAgentHookConfigPaths(homeDir);
|
|
441
|
+
try {
|
|
442
|
+
configPaths = managedAgentHookConfigPaths(homeDir);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
445
|
+
failures.push(`hook inventory ${path.join(homeDir, '.borg', 'worktrees')}: ${message}`);
|
|
446
|
+
}
|
|
447
|
+
for (const configPath of configPaths) {
|
|
448
|
+
try {
|
|
449
|
+
if (refreshHookFile(configPath)) refreshed.push(configPath);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
const label = configPath.endsWith(path.join('.codex', 'hooks.json'))
|
|
452
|
+
? 'Codex'
|
|
453
|
+
: 'Claude Code';
|
|
454
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
455
|
+
failures.push(`${label} ${configPath}: ${message}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (failures.length > 0) {
|
|
459
|
+
throw new Error(`Could not refresh managed agent hooks: ${failures.join('; ')}`);
|
|
460
|
+
}
|
|
461
|
+
return refreshed;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Read-only mirror of the updater's stale-command predicate. */
|
|
465
|
+
export function inspectManagedAgentHookConfigs(
|
|
466
|
+
homeDir: string = CONFIG_HOME,
|
|
467
|
+
): ManagedAgentHookConfigHealth[] {
|
|
468
|
+
let configPaths = globalManagedAgentHookConfigPaths(homeDir);
|
|
469
|
+
let inventoryIssue: ManagedAgentHookConfigHealth | null = null;
|
|
470
|
+
try {
|
|
471
|
+
configPaths = managedAgentHookConfigPaths(homeDir);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
inventoryIssue = {
|
|
474
|
+
path: path.join(homeDir, '.borg', 'worktrees'),
|
|
475
|
+
status: 'invalid',
|
|
476
|
+
detail: `inventory failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const health = configPaths.map((configPath): ManagedAgentHookConfigHealth => {
|
|
480
|
+
if (!fs.existsSync(configPath)) return { path: configPath, status: 'absent' };
|
|
481
|
+
try {
|
|
482
|
+
const config = readJsonFile(configPath);
|
|
483
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
484
|
+
return { path: configPath, status: 'invalid', detail: 'top-level value is not an object' };
|
|
485
|
+
}
|
|
486
|
+
if (config.hooks === undefined) return { path: configPath, status: 'ok' };
|
|
487
|
+
if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) {
|
|
488
|
+
return { path: configPath, status: 'invalid', detail: 'hooks is not an object' };
|
|
489
|
+
}
|
|
490
|
+
const copy = structuredClone(config.hooks);
|
|
491
|
+
let stale = false;
|
|
492
|
+
for (const entries of Object.values(copy)) {
|
|
493
|
+
if (Array.isArray(entries)) stale = migrateAndDedupOwnedHooks(entries) || stale;
|
|
494
|
+
}
|
|
495
|
+
return { path: configPath, status: stale ? 'stale' : 'ok' };
|
|
496
|
+
} catch (error) {
|
|
497
|
+
return {
|
|
498
|
+
path: configPath,
|
|
499
|
+
status: 'invalid',
|
|
500
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
if (inventoryIssue) health.push(inventoryIssue);
|
|
505
|
+
return health;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Strict canonical match. Quoted bare and legacy path forms are owned but
|
|
509
|
+
* require migration before they count as the one current command form. */
|
|
345
510
|
function isCanonicalCommand(entryCommand: string, canonical: string): boolean {
|
|
346
511
|
return entryCommand === canonical;
|
|
347
512
|
}
|
|
@@ -368,7 +533,7 @@ function hasCommandHook(entries: any[], command: string): boolean {
|
|
|
368
533
|
);
|
|
369
534
|
}
|
|
370
535
|
|
|
371
|
-
/** Strict: only the
|
|
536
|
+
/** Strict: only the current unquoted bare canonical form. */
|
|
372
537
|
function hasCanonicalCommandHook(entries: any[], command: string): boolean {
|
|
373
538
|
return entries.some((entry: any) =>
|
|
374
539
|
Array.isArray(entry?.hooks) &&
|
|
@@ -1002,7 +1167,7 @@ export function isCodexUserPromptSubmitHookRegistered(hooksPath: string = CODEX_
|
|
|
1002
1167
|
* `type: "local"`. Safe-default: any read error returns `false`.
|
|
1003
1168
|
*/
|
|
1004
1169
|
export function isOpenCodeMcpServerConfigured(
|
|
1005
|
-
configPath: string =
|
|
1170
|
+
configPath: string = path.join(borgHomeRoot(), '.config', 'opencode', 'opencode.json')
|
|
1006
1171
|
): boolean {
|
|
1007
1172
|
try {
|
|
1008
1173
|
if (!fs.existsSync(configPath)) return false;
|
package/src/index.ts
CHANGED
|
@@ -73,7 +73,7 @@ import {
|
|
|
73
73
|
pinMcpSeatIdentity,
|
|
74
74
|
} from './cubes.js';
|
|
75
75
|
import { isEntryInvocation, monitorStateRootForWorktree } from './inbox-monitor.js';
|
|
76
|
-
import {
|
|
76
|
+
import { addUserPromptSubmitHook } from './config-utils.js';
|
|
77
77
|
import {
|
|
78
78
|
humanAgo,
|
|
79
79
|
formatLogEntryMarkdown,
|
|
@@ -282,12 +282,6 @@ export async function main() {
|
|
|
282
282
|
const readinessProbe = isMcpReadinessProbe();
|
|
283
283
|
const openCodeRuntime = resolveSessionAgentKind() === 'opencode';
|
|
284
284
|
const startupServices = {
|
|
285
|
-
// Auto-register the SessionStart hook so existing users get borg-regen
|
|
286
|
-
// auto-orientation on session start without re-running borg setup. Idempotent.
|
|
287
|
-
sessionStartHook: () => {
|
|
288
|
-
addSessionStartHook();
|
|
289
|
-
},
|
|
290
|
-
|
|
291
285
|
// Auto-register the UserPromptSubmit audit hook so the drone gets a
|
|
292
286
|
// nudge if the previous assistant span used state-changing tools
|
|
293
287
|
// without calling borg_log. Domain-agnostic — knows nothing about git
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const LOG_AUDIT_NUDGE = (count: number): string =>
|
|
2
|
+
`Heads up: ${count}+ state-changing tool calls since the last \`borg_log\` post. ` +
|
|
3
|
+
'If that work was a substantive unit (a change that ships, a blocker hit, a finding ' +
|
|
4
|
+
"worth sharing), post to the cube log per your role's conventions before continuing.";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pure transcript scan shared by the Claude hook and the OpenCode plugin.
|
|
8
|
+
* Accepts both Claude JSONL entries and OpenCode SDK message records.
|
|
9
|
+
*/
|
|
10
|
+
export function evaluateLogAudit(
|
|
11
|
+
entries: readonly any[],
|
|
12
|
+
renderNudge: (count: number) => string = (count) =>
|
|
13
|
+
`Heads up: ${count}+ state-changing tool calls since the last \`borg_log\` post. ` +
|
|
14
|
+
'If that work was a substantive unit (a change that ships, a blocker hit, a finding ' +
|
|
15
|
+
"worth sharing), post to the cube log per your role's conventions before continuing.",
|
|
16
|
+
): string | null {
|
|
17
|
+
const materialTools = new Set([
|
|
18
|
+
'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash',
|
|
19
|
+
'edit', 'write', 'bash', 'apply_patch', 'exec_command',
|
|
20
|
+
'functions.exec_command', 'functions.apply_patch',
|
|
21
|
+
]);
|
|
22
|
+
const logTools = new Set(['mcp__borg__borg_log', 'borg_borg_log']);
|
|
23
|
+
const threshold = 3;
|
|
24
|
+
const maxScan = 400;
|
|
25
|
+
|
|
26
|
+
const role = (entry: any): unknown =>
|
|
27
|
+
entry?.type ?? entry?.role ?? entry?.info?.role;
|
|
28
|
+
const parts = (entry: any): any[] => {
|
|
29
|
+
const value = entry?.message?.content ?? entry?.content ?? entry?.parts ?? [];
|
|
30
|
+
return Array.isArray(value) ? value : [];
|
|
31
|
+
};
|
|
32
|
+
const isUserPrompt = (entry: any): boolean => {
|
|
33
|
+
if (role(entry) !== 'user') return false;
|
|
34
|
+
const content = entry?.message?.content ?? entry?.content ?? entry?.parts;
|
|
35
|
+
if (typeof content === 'string') return content.trim().length > 0;
|
|
36
|
+
return Array.isArray(content) && content.some((part) =>
|
|
37
|
+
part?.type === 'text' && typeof part.text === 'string' && part.text.trim().length > 0,
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
const isAssistant = (entry: any): boolean => {
|
|
41
|
+
const value = role(entry);
|
|
42
|
+
return value === 'assistant' || value === 'response_item';
|
|
43
|
+
};
|
|
44
|
+
const toolName = (part: any): string | null => {
|
|
45
|
+
if (part?.type === 'tool_use' && typeof part.name === 'string') return part.name;
|
|
46
|
+
if ((part?.type === 'tool' || part?.type === 'tool_call') && typeof part.tool === 'string') {
|
|
47
|
+
return part.tool;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let index = entries.length - 1;
|
|
53
|
+
if (index >= 0 && isUserPrompt(entries[index])) index--;
|
|
54
|
+
let material = 0;
|
|
55
|
+
let scanned = 0;
|
|
56
|
+
for (; index >= 0 && scanned < maxScan; index--, scanned++) {
|
|
57
|
+
const entry = entries[index];
|
|
58
|
+
if (!isAssistant(entry)) continue;
|
|
59
|
+
|
|
60
|
+
const payload = entry?.payload;
|
|
61
|
+
const payloadTool =
|
|
62
|
+
payload?.type === 'function_call' || payload?.type === 'custom_tool_call'
|
|
63
|
+
? payload.name
|
|
64
|
+
: null;
|
|
65
|
+
if (typeof payloadTool === 'string') {
|
|
66
|
+
if (logTools.has(payloadTool)) return material >= threshold ? renderNudge(material) : null;
|
|
67
|
+
if (materialTools.has(payloadTool)) material++;
|
|
68
|
+
if (material >= threshold) return renderNudge(material);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const content = parts(entry);
|
|
72
|
+
for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) {
|
|
73
|
+
const name = toolName(content[partIndex]);
|
|
74
|
+
if (!name) continue;
|
|
75
|
+
if (logTools.has(name)) return material >= threshold ? renderNudge(material) : null;
|
|
76
|
+
if (materialTools.has(name)) material++;
|
|
77
|
+
if (material >= threshold) return renderNudge(material);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
package/src/log-audit.ts
CHANGED
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* gets a turn of breathing room after each post.
|
|
17
17
|
*
|
|
18
18
|
* Stays generic — knows nothing about git, branches, or any project's
|
|
19
|
-
* conventions.
|
|
20
|
-
* small set of canonical mutating tool names.
|
|
21
|
-
* this project, silently exits.
|
|
19
|
+
* conventions. Its pure scan core recognizes the Borg log tool names used by
|
|
20
|
+
* Claude/Codex and OpenCode plus a small set of canonical mutating tool names.
|
|
21
|
+
* If no cube is active in this project, silently exits.
|
|
22
22
|
*
|
|
23
23
|
* Hook input arrives as JSON on stdin (Claude Code's standard hook
|
|
24
24
|
* contract). The relevant field is `transcript_path`.
|
|
@@ -28,92 +28,13 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
28
28
|
import { getActiveCube } from './cubes.js';
|
|
29
29
|
import { handleVersionFlag } from './version.js';
|
|
30
30
|
import { gateAllowsActivation } from './launch-gate.js';
|
|
31
|
-
|
|
32
|
-
const MATERIAL_TOOLS = new Set([
|
|
33
|
-
'Edit',
|
|
34
|
-
'Write',
|
|
35
|
-
'MultiEdit',
|
|
36
|
-
'NotebookEdit',
|
|
37
|
-
'Bash',
|
|
38
|
-
'apply_patch',
|
|
39
|
-
'exec_command',
|
|
40
|
-
'functions.exec_command',
|
|
41
|
-
'functions.apply_patch',
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
const LOG_TOOL = 'mcp__borg__borg_log';
|
|
45
|
-
|
|
46
|
-
// Number of state-changing tool calls since the last borg_log that the
|
|
47
|
-
// drone is allowed before the audit nudges. 1 false-positives on
|
|
48
|
-
// diagnostic Bash (git status, ls, etc.); 3 has been comfortable in
|
|
49
|
-
// dogfooding — any substantive work crosses it within a turn or two.
|
|
50
|
-
const MATERIAL_THRESHOLD = 3;
|
|
51
|
-
|
|
52
|
-
// Cap on how many transcript entries we scan backwards before giving up.
|
|
53
|
-
// Sessions with thousands of turns still resolve in milliseconds at this
|
|
54
|
-
// bound, and anything truly old is no longer "the last span the drone
|
|
55
|
-
// failed to log."
|
|
56
|
-
const MAX_SCAN = 400;
|
|
31
|
+
import { evaluateLogAudit } from './log-audit-core.js';
|
|
57
32
|
|
|
58
33
|
interface HookInput {
|
|
59
34
|
transcript_path?: string;
|
|
60
35
|
cwd?: string;
|
|
61
36
|
}
|
|
62
37
|
|
|
63
|
-
function isUserPrompt(entry: any): boolean {
|
|
64
|
-
const type = entry?.type ?? entry?.role;
|
|
65
|
-
if (type !== 'user') return false;
|
|
66
|
-
const content = entry?.message?.content ?? entry?.content;
|
|
67
|
-
if (typeof content === 'string') return content.trim().length > 0;
|
|
68
|
-
if (!Array.isArray(content)) return false;
|
|
69
|
-
// A "real" user prompt has at least one text block. A tool_result-only
|
|
70
|
-
// user message is a continuation of an assistant span, not a prompt.
|
|
71
|
-
return content.some((b: any) => b?.type === 'text');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function isAssistant(entry: any): boolean {
|
|
75
|
-
const type = entry?.type ?? entry?.role;
|
|
76
|
-
return type === 'assistant' || type === 'response_item';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
type ScanFinding =
|
|
80
|
-
| { kind: 'cooldown' } // hit a borg_log before threshold — suppress
|
|
81
|
-
| { kind: 'over_threshold'; count: number } // accumulated >= threshold
|
|
82
|
-
| { kind: 'under_threshold'; count: number }; // ran out of transcript
|
|
83
|
-
|
|
84
|
-
function scanAssistant(entry: any, state: { material: number; loggedRecently: boolean }): void {
|
|
85
|
-
const payload = entry?.payload;
|
|
86
|
-
const payloadToolName =
|
|
87
|
-
payload?.type === 'function_call' || payload?.type === 'custom_tool_call'
|
|
88
|
-
? payload.name
|
|
89
|
-
: null;
|
|
90
|
-
if (typeof payloadToolName === 'string') {
|
|
91
|
-
if (payloadToolName === LOG_TOOL) {
|
|
92
|
-
state.loggedRecently = true;
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
if (MATERIAL_TOOLS.has(payloadToolName)) state.material += 1;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const content = entry?.message?.content ?? entry?.content ?? [];
|
|
99
|
-
if (!Array.isArray(content)) return;
|
|
100
|
-
// Walk the blocks newest-first WITHIN the entry. The caller already
|
|
101
|
-
// visits entries newest-first. Counting forward within an entry would
|
|
102
|
-
// either miss post-log material work (if log is in the same entry as
|
|
103
|
-
// later material blocks) or inflate the count with pre-log work that
|
|
104
|
-
// the log already covered. Reversing here keeps "material since the
|
|
105
|
-
// last log" honest at block granularity.
|
|
106
|
-
for (let i = content.length - 1; i >= 0; i--) {
|
|
107
|
-
const block = content[i];
|
|
108
|
-
if (block?.type !== 'tool_use') continue;
|
|
109
|
-
if (block.name === LOG_TOOL) {
|
|
110
|
-
state.loggedRecently = true;
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
if (MATERIAL_TOOLS.has(block.name)) state.material += 1;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
38
|
async function readStdin(): Promise<string> {
|
|
118
39
|
if (process.stdin.isTTY) return '';
|
|
119
40
|
const chunks: Buffer[] = [];
|
|
@@ -158,40 +79,8 @@ async function main(): Promise<void> {
|
|
|
158
79
|
const lines = readFileSync(input.transcript_path, 'utf-8').split('\n').filter(Boolean);
|
|
159
80
|
if (lines.length === 0) return;
|
|
160
81
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// there, accumulate material tool calls until either we hit a borg_log
|
|
164
|
-
// (cooldown — suppress) or we cross MATERIAL_THRESHOLD (nudge). The
|
|
165
|
-
// scan stops after MAX_SCAN entries to bound work on huge sessions.
|
|
166
|
-
let i = lines.length - 1;
|
|
167
|
-
const tail = safeParse(lines[i]);
|
|
168
|
-
if (tail && isUserPrompt(tail)) i--;
|
|
169
|
-
|
|
170
|
-
const state = { material: 0, loggedRecently: false };
|
|
171
|
-
let scanned = 0;
|
|
172
|
-
for (; i >= 0 && scanned < MAX_SCAN; i--, scanned++) {
|
|
173
|
-
const entry = safeParse(lines[i]);
|
|
174
|
-
if (!entry) continue;
|
|
175
|
-
if (isAssistant(entry)) {
|
|
176
|
-
scanAssistant(entry, state);
|
|
177
|
-
// Threshold has primacy over cooldown: when both could fire on the
|
|
178
|
-
// same entry (e.g. an entry containing [log, Bash, Bash, Bash]
|
|
179
|
-
// where the reversed scan first counts 3 material blocks before
|
|
180
|
-
// hitting the log), we want the nudge — the post-log material
|
|
181
|
-
// work hasn't been logged yet.
|
|
182
|
-
if (state.material >= MATERIAL_THRESHOLD) {
|
|
183
|
-
process.stdout.write(
|
|
184
|
-
`Heads up: ${state.material}+ state-changing tool calls since the last \`borg_log\` post. ` +
|
|
185
|
-
'If that work was a substantive unit (a change that ships, a blocker hit, a finding ' +
|
|
186
|
-
"worth sharing), post to the cube log per your role's conventions before continuing.\n"
|
|
187
|
-
);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (state.loggedRecently) return; // cooldown
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
// Reached MAX_SCAN or start of transcript without finding either a
|
|
194
|
-
// log call or enough material work. Silent.
|
|
82
|
+
const nudge = evaluateLogAudit(lines.map(safeParse).filter((entry) => entry !== null));
|
|
83
|
+
if (nudge) process.stdout.write(`${nudge}\n`);
|
|
195
84
|
}
|
|
196
85
|
|
|
197
86
|
function safeParse(line: string): any | null {
|
package/src/opencode-drone.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'crypto';
|
|
|
3
3
|
import { createServer } from 'node:net';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { tmpdir } from 'os';
|
|
6
|
+
import { OPENCODE_INJECTED_ENTRY_METADATA_KEY } from './opencode-plugin.js';
|
|
6
7
|
|
|
7
8
|
const LOG_FILE = join(tmpdir(), 'borg-opencode-drone.log');
|
|
8
9
|
function log(msg: string) {
|
|
@@ -511,7 +512,11 @@ async function deliverOpenCodeEntry(
|
|
|
511
512
|
let status: number | null = null;
|
|
512
513
|
try {
|
|
513
514
|
status = await promptSession(target.id, {
|
|
514
|
-
parts: [{
|
|
515
|
+
parts: [{
|
|
516
|
+
type: 'text',
|
|
517
|
+
text: delivery.text,
|
|
518
|
+
metadata: { [OPENCODE_INJECTED_ENTRY_METADATA_KEY]: true },
|
|
519
|
+
}],
|
|
515
520
|
});
|
|
516
521
|
} catch (err) {
|
|
517
522
|
log(`entry ${delivery.entryId} submission outcome unavailable: ${err}`);
|