Compare commits

...
7 changed files with 538 additions and 4 deletions

No files matched your search

+2 -2
View File
@@ -31,8 +31,8 @@ lint:dead` rejects unused files, exports, and dependencies.
Set `REA_LOG_LEVEL` to `trace`, `debug`, `info`, `warn`, `error`, `fatal`, or Set `REA_LOG_LEVEL` to `trace`, `debug`, `info`, `warn`, `error`, `fatal`, or
`silent` to control structured JSON diagnostics. MCP mode defaults to `info` and `silent` to control structured JSON diagnostics. MCP mode defaults to `info` and
always writes logs to stderr so the stdio protocol remains intact. One-shot CLI always writes logs to stderr so the stdio protocol remains intact. One-shot CLI
logging is opt-in and writes to stdout when a level is configured, preserving logging is opt-in and writes to stderr when a level is configured, preserving
machine-readable command output by default. Request arguments, bridge machine-readable command output. Request arguments, bridge
authentication tokens, and environment data are redacted. authentication tokens, and environment data are redacted.
Changes that claim real Hopper behavior must also be tested against two distinct binaries: Changes that claim real Hopper behavior must also be tested against two distinct binaries:
+13
View File
@@ -431,6 +431,19 @@ Run `npx -y rea-agents --help` for direct decompilation, bounded search and
other options. `analyze` and `inspect` share the same overview workflow; other options. `analyze` and `inspect` share the same overview workflow;
`function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP. `function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP.
CLI exit status is independent of output format, full output, filtering, and
token limits:
| Status | Meaning |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The requested operation completed truthfully. Explicit unknown, bounded, or truncated evidence remains successful when the operation completed under its declared contract. |
| `1` | Argument validation, approval or policy, provider or analysis, integrity, cancellation or timeout, serialization, or output failed. Structured output retains the typed category and safe diagnostic details when available. |
Machine-readable failures remain on stdout in the requested format; incidental
diagnostics and progress use stderr. Signal termination retains the conventional
status supplied by the shell and runtime. Shell pipelines should enable
`pipefail` when the REA status must propagate through later commands.
Or install the `rea` command globally: Or install the `rea` command globally:
```bash ```bash
+153
View File
@@ -0,0 +1,153 @@
import {
createResidualUnknown,
updateResidualUnknown,
residualUnknownSchema,
type RecordUnknownInput,
type ResidualUnknown,
type UnknownStatus,
type UpdateUnknownInput,
} from "../domain/residualUnknown.js";
import { UnknownRegistryError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
export interface UnknownRegistryLimits {
readonly maxRecords: number;
readonly maxRelationships: number;
}
type UnknownRegistryMutation = Result<ResidualUnknown, UnknownRegistryError>;
type UnknownRegistryQuery = Result<
readonly ResidualUnknown[],
UnknownRegistryError
>;
type UnknownRegistryVerify = Result<
{
readonly valid: boolean;
readonly truthVerified: boolean;
readonly unknown: ResidualUnknown;
},
UnknownRegistryError
>;
/**
* Persistent, bounded registry of residual unknowns linked to evidence.
*
* Each unknown carries a stable ID derived from its question and scope,
* an immutable revision graph, and evidence-qualified resolution state.
*/
export class UnknownRegistry {
readonly #revisions = new Map<string, ResidualUnknown>();
readonly #heads = new Map<string, ResidualUnknown>();
constructor(private readonly limits: UnknownRegistryLimits) {
if (!Number.isSafeInteger(limits.maxRecords) || limits.maxRecords < 1)
throw new RangeError("maxRecords must be a positive safe integer");
if (
!Number.isSafeInteger(limits.maxRelationships) ||
limits.maxRelationships < 0
)
throw new RangeError(
"maxRelationships must be a non-negative safe integer",
);
}
recordUnknown(
input: RecordUnknownInput,
mutationEvidenceId: string,
): UnknownRegistryMutation {
let unknown: ResidualUnknown;
try {
unknown = createResidualUnknown(input, mutationEvidenceId, null);
} catch (cause: unknown) {
return err(new UnknownRegistryError("invalid-transition", { cause }));
}
if (this.#heads.has(unknown.unknown_id))
return err(new UnknownRegistryError("already-exists"));
return this.#append(unknown);
}
updateUnknown(
input: UpdateUnknownInput,
mutationEvidenceId: string,
): UnknownRegistryMutation {
const current = this.#heads.get(input.unknown_id);
if (current === undefined)
return err(new UnknownRegistryError("not-found"));
if (current.revision !== input.expected_revision)
return err(new UnknownRegistryError("revision-conflict"));
let updated: ResidualUnknown;
try {
updated = updateResidualUnknown(current, input, mutationEvidenceId);
} catch (cause: unknown) {
return err(new UnknownRegistryError("invalid-transition", { cause }));
}
return this.#append(updated);
}
listUnknowns(
filters: {
readonly status?: UnknownStatus;
readonly severity?: ResidualUnknown["severity"];
readonly domain?: string;
} = {},
): UnknownRegistryQuery {
return ok(
[...this.#heads.values()]
.filter(
(unknown) =>
(filters.status === undefined ||
unknown.status === filters.status) &&
(filters.severity === undefined ||
unknown.severity === filters.severity) &&
(filters.domain === undefined || unknown.domain === filters.domain),
)
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id)),
);
}
verifyUnknownResolution(unknownId: string): UnknownRegistryVerify {
const unknown = this.#heads.get(unknownId);
if (unknown === undefined)
return err(new UnknownRegistryError("not-found"));
return ok({
valid: unknown.status === "resolved",
truthVerified: unknown.resolution?.disposition === "verified",
unknown,
});
}
get(unknownId: string): ResidualUnknown | undefined {
const unknown = this.#heads.get(unknownId);
return unknown === undefined ? undefined : structuredClone(unknown);
}
has(unknownId: string): boolean {
return this.#heads.has(unknownId);
}
clear(): void {
this.#revisions.clear();
this.#heads.clear();
}
#append(unknown: ResidualUnknown): UnknownRegistryMutation {
const pending = this.#revisions.size + 1;
if (pending > this.limits.maxRecords)
return err(new UnknownRegistryError("limit"));
if (unknown.relationships.length > this.limits.maxRelationships)
return err(new UnknownRegistryError("limit"));
try {
residualUnknownSchema.parse(unknown);
} catch (cause: unknown) {
return err(new UnknownRegistryError("integrity", { cause }));
}
this.#revisions.set(
`${unknown.unknown_id}:${String(unknown.revision)}`,
unknown,
);
this.#heads.set(unknown.unknown_id, unknown);
return ok(unknown);
}
}
+8 -1
View File
@@ -19,6 +19,8 @@ import { importReferenceSource } from "./application/ReferenceSourceImport.js";
import { registerEvidenceCommands } from "./cliEvidenceCommands.js"; import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
import { registerProcessCommands } from "./cliProcessCommands.js"; import { registerProcessCommands } from "./cliProcessCommands.js";
import { registerInvestigationCommands } from "./cliInvestigationCommands.js"; import { registerInvestigationCommands } from "./cliInvestigationCommands.js";
import { registerUnknownCommands } from "./cliUnknownCommands.js";
import { registerBrowserCommands } from "./cliBrowserCommands.js";
import { import {
AnalysisProtocolError, AnalysisProtocolError,
PermissionRequiredError, PermissionRequiredError,
@@ -27,7 +29,7 @@ import {
import { projectReferenceSourceImportError } from "./application/ReferenceSourceImportTypes.js"; import { projectReferenceSourceImportError } from "./application/ReferenceSourceImportTypes.js";
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
import { registerPolicyCommands } from "./cliPolicyCommands.js"; import { registerPolicyCommands } from "./cliPolicyCommands.js";
import { registerBrowserCommands } from "./cliBrowserCommands.js"; import { UnknownRegistry } from "./application/UnknownRegistry.js";
/** /**
* Build the one-shot Incur CLI without starting Hopper at import time. * Build the one-shot Incur CLI without starting Hopper at import time.
@@ -71,6 +73,11 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
registerArtifactCommands(cli, logger); registerArtifactCommands(cli, logger);
registerInvestigationCommands(cli, logger); registerInvestigationCommands(cli, logger);
registerEvidenceCommands(cli, logger); registerEvidenceCommands(cli, logger);
registerUnknownCommands(
cli,
logger,
new UnknownRegistry({ maxRecords: 1_000, maxRelationships: 100 }),
);
registerReferenceSourceCommand(cli, logger); registerReferenceSourceCommand(cli, logger);
registerProcessCommands(cli, logger); registerProcessCommands(cli, logger);
registerPolicyCommands(cli, logger); registerPolicyCommands(cli, logger);
+135
View File
@@ -0,0 +1,135 @@
import { Cli, z } from "incur";
import type { UnknownRegistry } from "./application/UnknownRegistry.js";
import type { Logger } from "./logger.js";
import type {
RecordUnknownInput,
UpdateUnknownInput,
} from "./domain/residualUnknown.js";
/** Register residual-unknown registry CLI commands. */
export const registerUnknownCommands = (
cli: ReturnType<typeof Cli.create>,
_logger: Logger,
registry: UnknownRegistry,
): void => {
cli.command("unknowns", {
description: "List recorded residual unknowns with optional filters",
options: z.object({
status: z
.enum(["open", "investigating", "blocked", "contradicted", "resolved"])
.optional()
.describe("Filter by status"),
severity: z
.enum(["low", "medium", "high", "critical"])
.optional()
.describe("Filter by severity"),
domain: z.string().optional().describe("Filter by domain"),
}),
run: ({ options }): ReturnType<typeof registry.listUnknowns> => {
const filters: {
status?:
| "open"
| "investigating"
| "blocked"
| "contradicted"
| "resolved";
severity?: "low" | "medium" | "high" | "critical";
domain?: string;
} = {};
if (options.status !== undefined) filters.status = options.status;
if (options.severity !== undefined) filters.severity = options.severity;
if (options.domain !== undefined) filters.domain = options.domain;
return registry.listUnknowns(filters);
},
});
cli.command("unknown-record", {
description: "Record one approved residual unknown question",
options: z.object({
question: z.string().min(1).max(1_000).describe("The bounded question"),
severity: z
.enum(["low", "medium", "high", "critical"])
.describe("Severity of the unknown"),
domain: z.string().min(1).max(100).describe("Domain of the unknown"),
authority: z
.enum([
"shipped-artifact",
"controlled-replay",
"historical-reference",
"external-service",
"analyst-inference",
])
.describe("Required authority to resolve"),
confidence: z
.enum(["observed", "derived", "inferred"])
.describe("Required confidence level"),
evidence: z
.string()
.min(1)
.describe("Mutation evidence ID for this record operation"),
}),
run: ({ options }) =>
registry.recordUnknown(
{
question: options.question,
severity: options.severity,
domain: options.domain,
required_authority: options.authority,
required_confidence: options.confidence,
required_environment: null,
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
recommended_probes: [],
relationships: [],
approved: true,
} as RecordUnknownInput,
options.evidence,
),
});
cli.command("unknown-update", {
description: "Update one residual unknown with new evidence or status",
options: z.object({
id: z.string().describe("Unknown ID (unk_…)"),
revision: z.number().int().min(1).describe("Expected current revision"),
status: z
.enum(["open", "investigating", "blocked", "contradicted", "resolved"])
.describe("New status"),
severity: z
.enum(["low", "medium", "high", "critical"])
.describe("New severity"),
evidence: z
.string()
.min(1)
.describe("Mutation evidence ID for this update operation"),
}),
run: ({ options }) =>
registry.updateUnknown(
{
unknown_id: options.id,
expected_revision: options.revision,
approved: true,
status: options.status,
severity: options.severity,
required_authority: null,
required_confidence: "observed",
required_environment: null,
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
recommended_probes: [],
relationships: [],
resolution: null,
} as unknown as UpdateUnknownInput,
options.evidence,
),
});
cli.command("unknown-verify", {
description: "Verify the resolution state of one residual unknown",
options: z.object({
id: z.string().describe("Unknown ID (unk_…)"),
}),
run: ({ options }) => registry.verifyUnknownResolution(options.id),
});
};
+1 -1
View File
@@ -12,7 +12,7 @@ const logLevelSchema = z
export const parseLogLevel = (value: unknown): LogLevel => export const parseLogLevel = (value: unknown): LogLevel =>
logLevelSchema.parse(value); logLevelSchema.parse(value);
/** Create a JSON logger on stderr so protocol and CLI result stdout stay valid. */ /** Create a JSON logger whose stderr destination cannot corrupt structured output. */
export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger => export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger =>
pino( pino(
{ {
+226
View File
@@ -0,0 +1,226 @@
import { describe, expect, it } from "vitest";
import { UnknownRegistry } from "../src/application/UnknownRegistry.js";
const limits = { maxRecords: 100, maxRelationships: 50 };
const baseRecordInput = {
question: "What triggers the notification delegate?",
severity: "high" as const,
domain: "notifications",
required_authority: "shipped-artifact" as const,
required_confidence: "observed" as const,
required_environment: null,
supporting_evidence_ids: [] as string[],
contradicting_evidence_ids: [] as string[],
recommended_probes: [],
relationships: [],
approved: true as const,
};
describe("UnknownRegistry", () => {
it("creates a registry entry with deterministic ID and revision 1", () => {
const registry = new UnknownRegistry(limits);
const result = registry.recordUnknown(
baseRecordInput,
"ev_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
);
expect(result.ok).toBe(true);
if (!result.ok) return;
const unk = result.value;
expect(unk.unknown_id).toMatch(/^unk_[a-f0-9]{64}$/u);
expect(unk.revision).toBe(1);
expect(unk.status).toBe("open");
expect(unk.previous_revision_digest).toBeNull();
});
it("rejects duplicate creation with already-exists", () => {
const registry = new UnknownRegistry(limits);
const first = registry.recordUnknown(
baseRecordInput,
"ev_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
);
expect(first.ok).toBe(true);
const second = registry.recordUnknown(
baseRecordInput,
"ev_b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
);
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.error.reason).toBe("already-exists");
});
it("updates status and bumps revision with optimistic concurrency", () => {
const registry = new UnknownRegistry(limits);
const created = registry.recordUnknown(
baseRecordInput,
"ev_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
);
if (!created.ok) throw new Error("create failed");
const updated = registry.updateUnknown(
{
unknown_id: created.value.unknown_id,
expected_revision: 1,
approved: true,
status: "investigating",
severity: "high",
required_authority: null,
required_confidence: "observed",
required_environment: null,
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
recommended_probes: [],
relationships: [],
resolution: null,
},
"ev_b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
);
expect(updated.ok).toBe(true);
if (!updated.ok) return;
expect(updated.value.revision).toBe(2);
expect(updated.value.status).toBe("investigating");
expect(updated.value.previous_revision_digest).toBe(
created.value.revision_digest,
);
});
it("rejects update with wrong expected revision", () => {
const registry = new UnknownRegistry(limits);
const created = registry.recordUnknown(
baseRecordInput,
"ev_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
);
if (!created.ok) throw new Error("create failed");
const updated = registry.updateUnknown(
{
unknown_id: created.value.unknown_id,
expected_revision: 99,
approved: true,
status: "investigating",
severity: "high",
required_authority: null,
required_confidence: "observed",
required_environment: null,
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
recommended_probes: [],
relationships: [],
resolution: null,
},
"ev_b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
);
expect(updated.ok).toBe(false);
if (updated.ok) return;
expect(updated.error.reason).toBe("revision-conflict");
});
it("rejects update of non-existent unknown", () => {
const registry = new UnknownRegistry(limits);
const updated = registry.updateUnknown(
{
unknown_id: "unk_" + "0".repeat(64),
expected_revision: 1,
approved: true,
status: "investigating",
severity: "high",
required_authority: null,
required_confidence: "observed",
required_environment: null,
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
recommended_probes: [],
relationships: [],
resolution: null,
},
"ev_b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
);
expect(updated.ok).toBe(false);
if (updated.ok) return;
expect(updated.error.reason).toBe("not-found");
});
it("lists unknowns filtered by status and domain", () => {
const registry = new UnknownRegistry(limits);
const a = registry.recordUnknown(
{ ...baseRecordInput, domain: "notifications" },
"ev_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
);
const b = registry.recordUnknown(
{ ...baseRecordInput, question: "How does IPC work?", domain: "ipc" },
"ev_b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
);
expect(a.ok).toBe(true);
expect(b.ok).toBe(true);
const allOpen = registry.listUnknowns({ status: "open" });
expect(allOpen.ok).toBe(true);
if (!allOpen.ok) return;
expect(allOpen.value).toHaveLength(2);
const ipcOnly = registry.listUnknowns({ domain: "ipc" });
expect(ipcOnly.ok).toBe(true);
if (!ipcOnly.ok) return;
expect(ipcOnly.value).toHaveLength(1);
expect(ipcOnly.value[0]?.domain).toBe("ipc");
});
it("verifies resolution requires verified disposition", () => {
const registry = new UnknownRegistry(limits);
const created = registry.recordUnknown(
baseRecordInput,
"ev_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
);
if (!created.ok) throw new Error("create failed");
// Cannot resolve without verified evidence
const unresolved = registry.updateUnknown(
{
unknown_id: created.value.unknown_id,
expected_revision: 1,
approved: true,
status: "resolved",
severity: "high",
required_authority: null,
required_confidence: "observed",
required_environment: null,
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
recommended_probes: [],
relationships: [],
resolution: {
disposition: "verified",
rationale: "Test resolved",
evidence_ids: [],
},
},
"ev_b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
);
// The domain layer should reject resolving with verified disposition
// and no evidence ids
expect(unresolved.ok).toBe(false);
});
it("enforces record limits", () => {
const smallLimits = { maxRecords: 2, maxRelationships: 50 };
const registry = new UnknownRegistry(smallLimits);
expect(
registry.recordUnknown(
baseRecordInput,
"ev_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
).ok,
).toBe(true);
expect(
registry.recordUnknown(
{ ...baseRecordInput, question: "Second?" },
"ev_b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2",
).ok,
).toBe(true);
const third = registry.recordUnknown(
{ ...baseRecordInput, question: "Third?" },
"ev_c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3",
);
expect(third.ok).toBe(false);
if (third.ok) return;
expect(third.error.reason).toBe("limit");
});
});