feat: pack-hunt safety + opt-in Kali+ arsenal + capability-approval gate + tester-feedback UX
Adds three coordinated capabilities and tightens two defaults on purpose — this is safety hardening, NOT a backward-compatible drop-in (see "Tightened defaults"). Fifteen independent adversarial review passes; findings fixed and test-pinned. - Pack-hunt safety: a strict-majority adjudicator with a comment/string/regex/ raw-string/heredoc/POD-aware cite-check (language-gated across C/C++/Py/Rust/Go/ Java/JS/TS/shell/SQL/PHP/Ruby/Perl) so a hallucinated killing-guard can't refute a real finding — the cited comparison's operator must match source, and a string/regex/heredoc/generic-arglist/template-interpolation is blanked before matching. Plus a rate-limit/degraded-health tracker, a global LLM semaphore, and a PackBoard with an atomic claim CAS + snapshot-on-emit/clone-on-ingest. - Opt-in Kali+ arsenal (T3MP3ST_FULL_ARSENAL, OFF by default): 35 default tools (31 built-in + 4 external-CLI wrappers) -> 83 with the catalog armed (verify-claims CLAIM 4). Most adapters need the underlying binary installed. metasploit / hydra / bloodhound are never minted through the generic factory. - Capability-approval + spicy-action gate for the NEW dangerous arsenal only (pre-existing built-ins keep running freely): approve-once, fail-safe deny when unattended, redacted audit trail over SSE. T3MP3ST_GATE_BUILTINS=1 extends it to the spicy built-in probes. Audit redaction is a pure, unit-tested module. Tightened defaults (intentional, ON by default — NOT backward-compatible): - Egress containment: once a mission target is set, built-in networked tools refuse off-scope public hosts (not the target/subdomains, not loopback/private) with SCOPE DENIED — keeps the agent on authorized scope. - Role-specialized operator toolkits: each archetype gets a curated default tool menu (scanner -> scan tools, exploiter -> sqli/xss/etc). Tools stay registered team-wide; the per-role menu is the operator's working set. UX (from live-tester feedback): a one-click "Approve & run" modal when a standard Hunt blocks on an external-target receipt (was a dead-end); bounded-parallel benchmark file ingestion; the deterministic mission-gate chip co-located with the subjective SITREP pace narration (no more "behind schedule" next to a clean gate); and click-through finding detail + evidence in the UI (was export-only). Docs: README + INTEGRITY_LEDGER made re-derivable — honest 35/83 tool count, the egress-containment default documented, and the Cybench number reconciled to the single verify-claims-enforced 23/40 = 58% (dropped the stale/uncited 21 and 26). README voice pass: killed the underselling `Successful Exploits: 0` disqualifier (read as "never popped anything" — false, given CVE-Zero + the live disclosure pipeline); swagger now rests on receipts, not vibes. Also corrected the operator status — the downstream operators run the SAME real tool-backed ReAct loop as recon (not "orchestration stubs"); the honest limit is that the coordinated swarm is unproven (single-agent is the benchmarked path), not that they're scaffolding. Gates: 292 tests, tsc + lint clean, verify-claims 20/20, arsenal:smoke 125/0, disclosure-CLI self-test 11/11. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
5255000cb6
commit
06ffe3ef66
35 files changed
+5019
-211
No files matched your search
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* T3MP3ST Pack Hunt — PackBoard (Phase-2 shared workspace / lead-board)
|
||||
*
|
||||
* An APPEND-ONLY event log + folded projections (a leads map, live claims, per-agent
|
||||
* heartbeats), on the same EventEmitter discipline as `EvidenceVault`
|
||||
* (src/evidence/index.ts). This is the pack's shared workspace: agents post leads, claim
|
||||
* work so they don't re-tread, and endorse/refute each other — and each agent is threaded
|
||||
* a BOUNDED `situationReport()` at task start instead of the raw board.
|
||||
*
|
||||
* See the pack-hunt design. The three red-team invariants this file
|
||||
* is responsible for (§2):
|
||||
* - HT-1: `claim()` is a REAL synchronous compare-and-set — no `await` between the version
|
||||
* read and the write, so Node's single-threaded loop makes it genuinely atomic.
|
||||
* - HT-1: de-dup collapses an honest duplicate into an *endorsement* that raises `smoke`,
|
||||
* never a second lead card.
|
||||
* - HT-2: `refute()` only APPENDS a refutation vote; it never unilaterally flips `status`.
|
||||
* Flipping to refuted is the audit quorum's job (Phase-3), gated on `guardExistsInSource()`.
|
||||
*
|
||||
* The board mirrors lightweight finding *cards*; it is NOT the vault and does not gate
|
||||
* provenance. `where.targetId` is the foreign key back to the provenance-gated `Finding.id`.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Severity } from '../types/index.js';
|
||||
import type {
|
||||
AgentActivity,
|
||||
AgentStatus,
|
||||
BoardEvent,
|
||||
Lead,
|
||||
LeadProvenance,
|
||||
LeadVote,
|
||||
} from './types.js';
|
||||
|
||||
// =============================================================================
|
||||
// dedupKey — reused VERBATIM from scripts/cli-swarm.mjs (the `dedupKey` formula).
|
||||
// file::CWE::line-bucket(15). Two posts that map to the same key are the SAME lead.
|
||||
// We accept the cli-swarm finding shape ({file, cwe, line}) so a card built from a
|
||||
// specialist's JSON row keys identically to how the swarm already de-dups.
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Collision key for de-dup. Extends cli-swarm's `file::CWE::line-bucket(15)` with two fixes:
|
||||
* - vulnClass is folded in, so two DIFFERENT vuln classes at the same file:line-bucket (an XSS and
|
||||
* an SQLi a line apart, or two classes when CWE is absent) never silently shadow each other.
|
||||
* - a coordinate-less lead (no `file` — a recon/networked lead carrying only a `url`) keys on the
|
||||
* surface + class, so every url-only lead doesn't collapse into a single empty-file-bucket card.
|
||||
*/
|
||||
export function dedupKey(f: { file?: string; cwe?: string; line?: number; vulnClass?: string; url?: string }): string {
|
||||
const cls = `${(f.cwe || '').toUpperCase()}::${(f.vulnClass || '').toLowerCase()}`;
|
||||
if (f.file) return `${f.file.toLowerCase()}::${cls}::${Math.round((f.line || 0) / 15)}`;
|
||||
if (f.url) return `url::${f.url.toLowerCase()}::${cls}`;
|
||||
return `::${cls}::${Math.round((f.line || 0) / 15)}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EVENTS — typed emit, same pattern as EvidenceVaultEvents.
|
||||
// `board:event` fires on EVERY mutation (the raw log entry); the specific events are
|
||||
// conveniences for subscribers that only care about one kind.
|
||||
// =============================================================================
|
||||
|
||||
export interface PackBoardEvents {
|
||||
'board:event': BoardEvent;
|
||||
'lead:posted': Lead;
|
||||
'lead:endorsed': Lead;
|
||||
'lead:refuted': Lead;
|
||||
'lead:claimed': { lead: Lead; agentId: string };
|
||||
'lead:claim-denied': { leadId: string; agentId: string; version: number };
|
||||
'lead:claim-released': { lead: Lead; reason: 'expired' | 'released' };
|
||||
'lead:status-changed': { lead: Lead; from: Lead['status']; to: Lead['status'] };
|
||||
'agent:heartbeat': AgentStatus;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INPUT SHAPES — what a caller supplies. The board owns id / dedupKey / version /
|
||||
// smoke / timestamps / vote arrays, so callers can't forge them.
|
||||
// =============================================================================
|
||||
|
||||
/** The caller-supplied fields of a new lead. The board derives everything else. If a
|
||||
* `dedupKey` is not supplied it is computed from `where` + `cwe` via cli-swarm's formula. */
|
||||
export interface LeadInput {
|
||||
kind: Lead['kind'];
|
||||
title: string;
|
||||
where: Lead['where'];
|
||||
vulnClass: string;
|
||||
confidence: Lead['confidence'];
|
||||
provenance: LeadProvenance;
|
||||
/** CWE id (e.g. "CWE-22") — folded into the dedupKey exactly like cli-swarm does. */
|
||||
cwe?: string;
|
||||
/** Explicit override of the computed dedupKey (rare; tests / cross-surface merges). */
|
||||
dedupKey?: string;
|
||||
severity?: Lead['severity'];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONFIG — bounds that keep the situation report from ever dumping the raw board.
|
||||
// =============================================================================
|
||||
|
||||
export interface PackBoardConfig {
|
||||
/** Hard upper bound on the length of any `situationReport()` string. Never exceeded. */
|
||||
reportCharCap: number;
|
||||
/** Default number of top-smoke unclaimed leads to include when the caller omits maxLeads. */
|
||||
defaultMaxLeads: number;
|
||||
/** An agent whose last heartbeat is older than this (ms) is treated as `gone` / not live. */
|
||||
livenessWindowMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PACKBOARD_CONFIG: PackBoardConfig = {
|
||||
reportCharCap: 4000,
|
||||
defaultMaxLeads: 8,
|
||||
livenessWindowMs: 90_000,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// PACK BOARD
|
||||
// =============================================================================
|
||||
|
||||
export class PackBoard extends EventEmitter<PackBoardEvents> {
|
||||
/** The append-only log. Never mutated in place; only pushed. The single source of truth. */
|
||||
private readonly log: BoardEvent[] = [];
|
||||
|
||||
/** Folded projection: the current leads, keyed by lead id. */
|
||||
private readonly leads: Map<string, Lead> = new Map();
|
||||
|
||||
/** Fast index dedupKey → leadId, so a duplicate post collapses in O(1). */
|
||||
private readonly byDedup: Map<string, string> = new Map();
|
||||
|
||||
/** Folded projection: latest heartbeat per agent. */
|
||||
private readonly agents: Map<string, AgentStatus> = new Map();
|
||||
|
||||
private readonly cfg: PackBoardConfig;
|
||||
|
||||
/** Injected clock — defaults to Date.now, overridable so tests are deterministic. */
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(config: Partial<PackBoardConfig> = {}, now: () => number = Date.now) {
|
||||
super();
|
||||
this.cfg = { ...DEFAULT_PACKBOARD_CONFIG, ...config };
|
||||
this.now = now;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// internal: append one entry to the log + emit. The ONLY writer of `this.log`.
|
||||
// ---------------------------------------------------------------------------
|
||||
private append(
|
||||
type: BoardEvent['type'],
|
||||
actor: string,
|
||||
leadId?: string,
|
||||
data?: Record<string, unknown>,
|
||||
): BoardEvent {
|
||||
const ev: BoardEvent = { id: randomUUID(), type, actor, leadId, data, at: this.now() };
|
||||
this.log.push(ev);
|
||||
this.emit('board:event', ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// internal helpers: evidence ranking + defensive snapshots.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A real tool reproduction outranks a context guess outranks none. */
|
||||
private static provenanceRank(p: LeadProvenance): number {
|
||||
return p === 'tool' ? 2 : p === 'context' ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Severity rank (higher = worse); unknown/undefined ranks lowest. */
|
||||
private static severityRank(s?: Severity): number {
|
||||
const rank: Record<Severity, number> = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
||||
return s ? rank[s] : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonically upgrade a card's evidence from a duplicate post: adopt the incoming provenance /
|
||||
* severity ONLY when stronger than what the card already carries (never downgrade). Called on the
|
||||
* de-dup collapse so a second, better-sourced find of the same bug isn't thrown away.
|
||||
*/
|
||||
private upgradeEvidence(lead: Lead, input: { provenance: LeadProvenance; severity?: Severity }): void {
|
||||
if (PackBoard.provenanceRank(input.provenance) > PackBoard.provenanceRank(lead.provenance)) {
|
||||
lead.provenance = input.provenance;
|
||||
}
|
||||
if (input.severity && PackBoard.severityRank(input.severity) > PackBoard.severityRank(lead.severity)) {
|
||||
lead.severity = input.severity;
|
||||
}
|
||||
}
|
||||
|
||||
/** A defensive deep copy of a lead. The board NEVER hands out a live reference to its internal
|
||||
* projection — a caller mutating `version`/`status`/`claim` would bypass the CAS + quorum. */
|
||||
private snapshotLead(lead: Lead): Lead {
|
||||
return structuredClone(lead);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// postLead — de-dup via dedupKey. A duplicate collapses into an ENDORSEMENT
|
||||
// that raises smoke, NEVER a new lead card (HT-1).
|
||||
// ===========================================================================
|
||||
postLead(actor: string, input: LeadInput): Lead {
|
||||
const key =
|
||||
input.dedupKey ||
|
||||
dedupKey({
|
||||
file: input.where.file,
|
||||
cwe: input.cwe,
|
||||
line: input.where.line,
|
||||
vulnClass: input.vulnClass,
|
||||
url: input.where.url,
|
||||
});
|
||||
|
||||
const existingId = this.byDedup.get(key);
|
||||
if (existingId !== undefined) {
|
||||
const existing = this.leads.get(existingId);
|
||||
if (existing) {
|
||||
// Honest duplicate → monotonically upgrade the card with any STRONGER evidence the second
|
||||
// finder brought (never downgrade), then fold into an endorsement (chase-the-smoke). NOTE
|
||||
// (HT-2): a duplicate of a quorum-refuted card is recorded as an endorsement but does NOT
|
||||
// resurface it — a verified refutation is sticky; re-finding it must not re-open it.
|
||||
this.upgradeEvidence(existing, input);
|
||||
return this.endorse(actor, existing.id, `duplicate: ${input.title}`) ?? this.snapshotLead(existing);
|
||||
}
|
||||
}
|
||||
|
||||
const t = this.now();
|
||||
const lead: Lead = {
|
||||
id: randomUUID(),
|
||||
dedupKey: key,
|
||||
kind: input.kind,
|
||||
title: input.title,
|
||||
// Clone on INGEST too (not just on the outbound snapshot): a caller that retains and later mutates
|
||||
// the `where` object it passed in must not be able to re-point the board's internal projection —
|
||||
// that would poison situationReport() coords and desync the frozen dedupKey. Mirrors the board's
|
||||
// no-live-reference invariant on the inbound path.
|
||||
where: { ...input.where },
|
||||
vulnClass: input.vulnClass,
|
||||
confidence: input.confidence,
|
||||
status: 'open',
|
||||
smoke: 1,
|
||||
postedBy: actor,
|
||||
endorsements: [],
|
||||
refutations: [],
|
||||
provenance: input.provenance,
|
||||
version: 0,
|
||||
postedAt: t,
|
||||
updatedAt: t,
|
||||
severity: input.severity,
|
||||
};
|
||||
this.leads.set(lead.id, lead);
|
||||
this.byDedup.set(key, lead.id);
|
||||
this.append('lead:posted', actor, lead.id, { dedupKey: key, kind: lead.kind });
|
||||
this.emit('lead:posted', this.snapshotLead(lead));
|
||||
return this.snapshotLead(lead);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// claim — a REAL synchronous compare-and-set. There is NO `await` anywhere
|
||||
// between the version read and the write, so on Node's single-threaded loop
|
||||
// this is genuinely atomic: two calls racing the same expectedVersion can only
|
||||
// have ONE win (HT-1). Grants a lease; an already-held live lease blocks.
|
||||
// ===========================================================================
|
||||
claim(
|
||||
leadId: string,
|
||||
agentId: string,
|
||||
expectedVersion: number,
|
||||
leaseMs = 120_000,
|
||||
): { granted: boolean; version: number } {
|
||||
const lead = this.leads.get(leadId);
|
||||
if (!lead) return { granted: false, version: -1 };
|
||||
|
||||
// A quorum-terminal lead (confirmed / refuted / dead) is NOT claimable — a verified refutation is
|
||||
// sticky (HT-2), so re-claiming can never resurrect a killed lead back into active work.
|
||||
if (lead.status === 'refuted' || lead.status === 'confirmed' || lead.status === 'dead') {
|
||||
this.append('lead:claim-denied', agentId, lead.id, { reason: 'terminal', status: lead.status, version: lead.version });
|
||||
this.emit('lead:claim-denied', { leadId, agentId, version: lead.version });
|
||||
return { granted: false, version: lead.version };
|
||||
}
|
||||
|
||||
// --- BEGIN atomic critical section (no await past this point until write) ---
|
||||
const t = this.now();
|
||||
|
||||
// Someone else holds a live (unexpired) lease → deny, regardless of version.
|
||||
if (lead.claim && lead.claim.leaseUntil > t && lead.claim.by !== agentId) {
|
||||
this.append('lead:claim-denied', agentId, lead.id, { reason: 'held', version: lead.version });
|
||||
this.emit('lead:claim-denied', { leadId, agentId, version: lead.version });
|
||||
return { granted: false, version: lead.version };
|
||||
}
|
||||
|
||||
// Compare-and-set: the caller's view of the lead must be current.
|
||||
if (lead.version !== expectedVersion) {
|
||||
this.append('lead:claim-denied', agentId, lead.id, { reason: 'stale-version', version: lead.version });
|
||||
this.emit('lead:claim-denied', { leadId, agentId, version: lead.version });
|
||||
return { granted: false, version: lead.version };
|
||||
}
|
||||
|
||||
// Win: bump version (invalidates every other racer's expectedVersion), set the lease.
|
||||
lead.version += 1;
|
||||
lead.claim = { by: agentId, leaseUntil: t + leaseMs, version: lead.version };
|
||||
lead.updatedAt = t;
|
||||
this.setStatus(lead, 'claimed', agentId);
|
||||
// --- END atomic critical section ---
|
||||
|
||||
this.append('lead:claimed', agentId, lead.id, { version: lead.version, leaseUntil: lead.claim.leaseUntil });
|
||||
this.emit('lead:claimed', { lead: this.snapshotLead(lead), agentId });
|
||||
return { granted: true, version: lead.version };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// releaseExpiredClaims — the lease reaper (the same timeout-backstop idea that
|
||||
// reaps wedged dispatches). Frees any lease whose leaseUntil has passed and
|
||||
// returns the lead to `open` so another agent can take it.
|
||||
// ===========================================================================
|
||||
releaseExpiredClaims(now: number = this.now()): string[] {
|
||||
const released: string[] = [];
|
||||
for (const lead of this.leads.values()) {
|
||||
if (lead.claim && lead.claim.leaseUntil <= now) {
|
||||
const holder = lead.claim.by;
|
||||
delete lead.claim;
|
||||
lead.version += 1;
|
||||
lead.updatedAt = now;
|
||||
// only demote back to open if it's still merely claimed (don't clobber confirmed/refuted)
|
||||
if (lead.status === 'claimed') this.setStatus(lead, 'open', holder);
|
||||
this.append('lead:claim-released', holder, lead.id, { reason: 'expired', version: lead.version });
|
||||
this.emit('lead:claim-released', { lead: this.snapshotLead(lead), reason: 'expired' });
|
||||
released.push(lead.id);
|
||||
}
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
/** Voluntarily release a claim you hold (e.g. work finished / abandoned). No-op if you
|
||||
* don't hold the live lease. Returns the freed lead, or undefined. */
|
||||
releaseClaim(agentId: string, leadId: string): Lead | undefined {
|
||||
const lead = this.leads.get(leadId);
|
||||
if (!lead || !lead.claim || lead.claim.by !== agentId) return undefined;
|
||||
delete lead.claim;
|
||||
lead.version += 1;
|
||||
lead.updatedAt = this.now();
|
||||
if (lead.status === 'claimed') this.setStatus(lead, 'open', agentId);
|
||||
this.append('lead:claim-released', agentId, lead.id, { reason: 'released', version: lead.version });
|
||||
this.emit('lead:claim-released', { lead: this.snapshotLead(lead), reason: 'released' });
|
||||
return this.snapshotLead(lead);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// endorse — append-only vote that raises smoke (chase-the-smoke priority).
|
||||
// ===========================================================================
|
||||
endorse(actor: string, leadId: string, note?: string): Lead | undefined {
|
||||
const lead = this.leads.get(leadId);
|
||||
if (!lead) return undefined;
|
||||
const vote: LeadVote = { by: actor, note, at: this.now() };
|
||||
lead.endorsements.push(vote);
|
||||
lead.smoke += 1;
|
||||
lead.version += 1;
|
||||
lead.updatedAt = vote.at;
|
||||
this.append('lead:endorsed', actor, lead.id, { smoke: lead.smoke });
|
||||
this.emit('lead:endorsed', this.snapshotLead(lead));
|
||||
return this.snapshotLead(lead);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// refute — APPEND-ONLY. Records a refutation vote (and optional cited guard) but
|
||||
// NEVER flips status by itself (HT-2). Only the audit quorum (Phase-3), after
|
||||
// guardExistsInSource() verifies the cited guard, may set status to 'refuted'.
|
||||
// A `refute()` here is a signal, not a verdict.
|
||||
// ===========================================================================
|
||||
refute(actor: string, leadId: string, note?: string, guard?: string): Lead | undefined {
|
||||
const lead = this.leads.get(leadId);
|
||||
if (!lead) return undefined;
|
||||
const vote: LeadVote = { by: actor, note, guard, at: this.now() };
|
||||
lead.refutations.push(vote);
|
||||
lead.version += 1;
|
||||
lead.updatedAt = vote.at;
|
||||
// NOTE: status is intentionally NOT changed here. See method doc + HT-2.
|
||||
this.append('lead:refuted', actor, lead.id, { refutations: lead.refutations.length, guard: guard ?? null });
|
||||
this.emit('lead:refuted', this.snapshotLead(lead));
|
||||
return this.snapshotLead(lead);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// heartbeat — per-agent liveness projection.
|
||||
// ===========================================================================
|
||||
heartbeat(agentId: string, activity: AgentActivity, note?: string): AgentStatus {
|
||||
const status: AgentStatus = { agentId, activity, note, lastSeen: this.now() };
|
||||
this.agents.set(agentId, status);
|
||||
this.append('agent:heartbeat', agentId, undefined, { activity });
|
||||
this.emit('agent:heartbeat', status);
|
||||
return status;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// internal: status transition (the ONLY path that changes Lead.status).
|
||||
// The board only ever drives open ⇄ claimed. confirmed/refuted/dead are set by
|
||||
// the quorum through `setLeadStatus`, kept separate so the board never flips a
|
||||
// status on agreement alone.
|
||||
// ---------------------------------------------------------------------------
|
||||
private setStatus(lead: Lead, to: Lead['status'], actor: string): void {
|
||||
if (lead.status === to) return;
|
||||
const from = lead.status;
|
||||
lead.status = to;
|
||||
lead.updatedAt = this.now();
|
||||
this.append('lead:status-changed', actor, lead.id, { from, to });
|
||||
this.emit('lead:status-changed', { lead: this.snapshotLead(lead), from, to });
|
||||
}
|
||||
|
||||
/**
|
||||
* Quorum-only status setter (Phase-3 hook). The audit coordinator calls this to promote a
|
||||
* card to `confirmed` (after strict-majority + independent reproduction + gate) or `refuted`
|
||||
* (after guardExistsInSource() verified a cited guard) or retire it as `dead`. It is NOT
|
||||
* called by `endorse()`/`refute()` — those only append votes.
|
||||
*/
|
||||
setLeadStatus(actor: string, leadId: string, to: Lead['status']): Lead | undefined {
|
||||
const lead = this.leads.get(leadId);
|
||||
if (!lead) return undefined;
|
||||
lead.version += 1;
|
||||
this.setStatus(lead, to, actor);
|
||||
return this.snapshotLead(lead);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// situationReport — a BOUNDED string threaded into an agent's prompt at task
|
||||
// start. It NEVER dumps the raw board: top-K unclaimed leads by smoke, YOUR own
|
||||
// claims, refuted-guard keys to avoid, and the live agents — hard-capped to
|
||||
// cfg.reportCharCap chars. Mirrors cli-swarm's renderBoard four-section layout.
|
||||
// ===========================================================================
|
||||
situationReport(forAgentId: string, opts: { maxLeads?: number } = {}): string {
|
||||
const cap = this.cfg.reportCharCap;
|
||||
const maxLeads = Math.max(0, opts.maxLeads ?? this.cfg.defaultMaxLeads);
|
||||
const now = this.now();
|
||||
const all = Array.from(this.leads.values());
|
||||
|
||||
// ── top-K UNCLAIMED open leads, ranked by smoke (chase-the-smoke), then recency ──
|
||||
const unclaimed = all
|
||||
.filter((l) => l.status === 'open' && !(l.claim && l.claim.leaseUntil > now))
|
||||
.sort((a, b) => b.smoke - a.smoke || b.updatedAt - a.updatedAt)
|
||||
.slice(0, maxLeads);
|
||||
|
||||
// ── the leads THIS agent currently holds a live lease on ──
|
||||
const mine = all.filter((l) => l.claim && l.claim.by === forAgentId && l.claim.leaseUntil > now);
|
||||
|
||||
// ── refuted-guard keys to steer clear of (leads the quorum killed) ──
|
||||
const refuted = all.filter((l) => l.status === 'refuted');
|
||||
|
||||
// ── live agents (fresh heartbeat within the liveness window) ──
|
||||
const live = Array.from(this.agents.values())
|
||||
.filter((a) => a.activity !== 'gone' && now - a.lastSeen <= this.cfg.livenessWindowMs)
|
||||
.sort((a, b) => b.lastSeen - a.lastSeen);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`PACK BOARD — situation for ${forAgentId} (${all.length} lead(s) total)`);
|
||||
|
||||
if (unclaimed.length) {
|
||||
lines.push('OPEN LEADS — unclaimed, hottest first (claim before you dig; don\'t re-tread):');
|
||||
for (const l of unclaimed) {
|
||||
lines.push(` ? [smoke ${l.smoke}/${l.confidence}/${l.vulnClass}] ${this.coords(l)} — ${clip(l.title, 90)} (v${l.version})`);
|
||||
}
|
||||
}
|
||||
if (mine.length) {
|
||||
lines.push('YOUR CLAIMS (you hold the lease — finish or release):');
|
||||
for (const l of mine) {
|
||||
lines.push(` ◉ ${this.coords(l)} — ${clip(l.title, 90)} (lease→${l.claim?.leaseUntil ?? 0})`);
|
||||
}
|
||||
}
|
||||
if (refuted.length) {
|
||||
lines.push('REFUTED / CLEARED (quorum killed these — do NOT resurface):');
|
||||
for (const l of refuted.slice(0, maxLeads)) {
|
||||
const g = l.refutations.find((r) => r.guard)?.guard;
|
||||
lines.push(` ✗ ${this.coords(l)} — ${clip(l.title, 70)}${g ? ` (guard: ${clip(g, 60)})` : ''}`);
|
||||
}
|
||||
}
|
||||
if (live.length) {
|
||||
lines.push(`LIVE AGENTS: ${live.map((a) => `${a.agentId}:${a.activity}`).join(', ')}`);
|
||||
}
|
||||
|
||||
return boundedJoin(lines, cap);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// display coords: prefer file:line, fall back to url, then the vault FK.
|
||||
// ---------------------------------------------------------------------------
|
||||
private coords(l: Lead): string {
|
||||
if (l.where.file) return `${l.where.file}:${l.where.line ?? '?'}`;
|
||||
if (l.where.url) return l.where.url;
|
||||
if (l.where.targetId) return `finding:${l.where.targetId}`;
|
||||
return l.dedupKey;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// read-only projection accessors (for the controller / tests / SSE).
|
||||
// ===========================================================================
|
||||
|
||||
/** The append-only log (defensive DEEP copy — callers cannot mutate history or entry payloads). */
|
||||
getLog(): BoardEvent[] {
|
||||
return this.log.map((e) => structuredClone(e));
|
||||
}
|
||||
|
||||
getLead(leadId: string): Lead | undefined {
|
||||
const lead = this.leads.get(leadId);
|
||||
return lead ? this.snapshotLead(lead) : undefined;
|
||||
}
|
||||
|
||||
getAllLeads(): Lead[] {
|
||||
return Array.from(this.leads.values(), (l) => this.snapshotLead(l));
|
||||
}
|
||||
|
||||
/** Open, currently-unclaimed leads (what a fresh agent can pick up). */
|
||||
getOpenLeads(now: number = this.now()): Lead[] {
|
||||
return this.getAllLeads().filter((l) => l.status === 'open' && !(l.claim && l.claim.leaseUntil > now));
|
||||
}
|
||||
|
||||
getAgents(): AgentStatus[] {
|
||||
return Array.from(this.agents.values(), (a) => ({ ...a }));
|
||||
}
|
||||
|
||||
/** Agents whose last heartbeat is within the liveness window. */
|
||||
getLiveAgents(now: number = this.now()): AgentStatus[] {
|
||||
return this.getAgents().filter((a) => a.activity !== 'gone' && now - a.lastSeen <= this.cfg.livenessWindowMs);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// helpers — pure string bounding
|
||||
// =============================================================================
|
||||
|
||||
/** Clip a string to `n` chars with an ellipsis, so no single field blows the budget. */
|
||||
function clip(s: string, n: number): string {
|
||||
const str = String(s ?? '');
|
||||
return str.length <= n ? str : `${str.slice(0, Math.max(0, n - 1))}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join lines with '\n' but NEVER exceed `cap` chars. If the full join fits, return it.
|
||||
* Otherwise take as many whole leading lines as fit, and append a truncation marker that
|
||||
* itself is accounted for in the budget — the returned string is guaranteed <= cap.
|
||||
*/
|
||||
function boundedJoin(lines: string[], cap: number): string {
|
||||
const full = lines.join('\n');
|
||||
if (full.length <= cap) return full;
|
||||
|
||||
const marker = '\n…(truncated)';
|
||||
const budget = Math.max(0, cap - marker.length);
|
||||
const out: string[] = [];
|
||||
let used = 0;
|
||||
for (const line of lines) {
|
||||
const add = out.length === 0 ? line.length : line.length + 1; // +1 for the '\n'
|
||||
if (used + add > budget) break;
|
||||
out.push(line);
|
||||
used += add;
|
||||
}
|
||||
const joined = out.join('\n') + marker;
|
||||
// Final belt-and-suspenders: hard-slice in the pathological case of a single over-long line.
|
||||
return joined.length <= cap ? joined : joined.slice(0, cap);
|
||||
}
|
||||
Reference in new issue
Block a user