Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/harmonization-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Passive harmonization status catalog

Phase 4 adds a read-only `pi-rogue.harmonization-status.v1` catalog built at the bundle composition boundary. It preserves the existing registration order:

1. Advisor
2. Router
3. Orchestration
4. Context Broker

Each entry is a `FeatureStatusV1` snapshot owned by its feature. The catalog is an introspection/reporting projection only: it cannot start or stop work, select a model, mutate policy, dispatch workers, or write state.

## Privacy boundary

Status serialization validates the schema and rejects unknown/prohibited fields, including prompts, transcripts, payloads, paths, credentials, raw content, and user identifiers. Adapters expose bounded categorical state only; they do not expose state paths, model responses, or user content.

The checked-in implementation provides read-only adapters for Advisor, Router, Orchestration, and Context Broker. Adapter failures become an explicit `error` entry without forwarding exception text.

## Deterministic local report

```sh
npm run harmonization:status -- --output /tmp/pi-rogue-harmonization-status.v1.json
```

The command uses synthetic, representative status fixtures and is a schema/process check—not a production usage, spend, quality, or savings claim. Runtime collection remains local and passive. Representative runtime evidence must be sampled and reviewed separately before any consolidation or deletion decision.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"board:eval": "tsx scripts/select-board-fixtures.ts",
"budget-board:smoke": "tsx scripts/smoke-budget-board-profile.ts",
"harmonization:measure": "tsx scripts/harmonization-measurement.ts",
"harmonization:evidence": "tsx scripts/harmonization-evidence.ts"
"harmonization:evidence": "tsx scripts/harmonization-evidence.ts",
"harmonization:status": "tsx scripts/harmonization-status.ts"
},
"engines": {
"node": ">=22.19.0"
Expand Down
1 change: 1 addition & 0 deletions packages/advisor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export type {
SubagentReturnSummary,
} from "./board.js";
export { reviewWorkerResult } from "./worker-review.js";
export { advisorFeatureStatus, serializeAdvisorFeatureStatus } from "./status.js";
export type { WorkerReviewInput, WorkerReviewResult } from "./worker-review.js";
45 changes: 45 additions & 0 deletions packages/advisor/src/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";

afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});

describe("advisorFeatureStatus", () => {
it("reports built-in defaults without creating state", async () => {
const home = mkdtempSync(join(mkdtempSync(join("/tmp", "pi-rogue-advisor-status-")), "home-"));
vi.stubEnv("HOME", home);
vi.resetModules();
const { advisorFeatureStatus } = await import("./status.js");

expect(advisorFeatureStatus()).toMatchObject({
schema: "FeatureStatusV1",
feature: "advisor",
health: "unconfigured",
enabled: true,
mode: "auto",
});
expect(existsSync(join(home, ".pi", "agent", "pi-rogue"))).toBe(false);
});

it("reports disabled and malformed config without exposing paths", async () => {
const home = mkdtempSync(join("/tmp", "pi-rogue-advisor-status-"));
vi.stubEnv("HOME", home);
const dir = join(home, ".pi", "agent", "pi-rogue", "advisor");
mkdirSync(dir, { recursive: true });
const config = join(dir, "config.json");
writeFileSync(config, JSON.stringify({ mode: "off" }), "utf8");
vi.resetModules();
const { advisorFeatureStatus } = await import("./status.js");
expect(advisorFeatureStatus()).toMatchObject({ health: "disabled", enabled: false, mode: "off" });
expect(JSON.stringify(advisorFeatureStatus())).not.toContain(home);

writeFileSync(config, "{not-json", "utf8");
expect(advisorFeatureStatus().health).toBe("error");

writeFileSync(config, JSON.stringify({ mode: "../unsafe" }), "utf8");
expect(advisorFeatureStatus()).toMatchObject({ health: "error", mode: "auto" });
});
});
48 changes: 48 additions & 0 deletions packages/advisor/src/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createFeatureStatusV1, serializeFeatureStatusV1, type FeatureStatusV1 } from "@fiale-plus/pi-core";

function configPath(): string {
return join(homedir(), ".pi", "agent", "pi-rogue", "advisor", "config.json");
}

const MODES = new Set(["auto", "manual", "off"]);

function readAdvisorConfig(): { present: boolean; valid: boolean; mode?: string } {
const path = configPath();
if (!existsSync(path)) return { present: false, valid: true };
try {
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!value || typeof value !== "object" || Array.isArray(value)) return { present: true, valid: false };
const mode = (value as Record<string, unknown>).mode;
const valid = mode === undefined || (typeof mode === "string" && MODES.has(mode));
return { present: true, valid, mode: valid && typeof mode === "string" ? mode : undefined };
} catch {
return { present: true, valid: false };
}
}

/** Read-only Advisor status adapter. It never creates, migrates, or writes state. */
export function advisorFeatureStatus(): FeatureStatusV1 {
const config = readAdvisorConfig();
const mode = config.mode ?? "auto";
const enabled = mode !== "off";
const health = !config.present ? "unconfigured" : !config.valid ? "error" : !enabled ? "disabled" : "ready";
return createFeatureStatusV1({
feature: "advisor",
owner: "advisor",
health,
enabled,
mode,
summary: !config.present ? "advisor uses built-in defaults" : enabled ? "advisor is available" : "advisor is disabled",
diagnostics: {
configPresent: config.present,
configValid: config.valid,
},
});
}

export function serializeAdvisorFeatureStatus(): string {
return serializeFeatureStatusV1(advisorFeatureStatus());
}
8 changes: 7 additions & 1 deletion packages/bundle/src/context-broker-default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ function contextBrokerEnabled(): boolean {
}

export async function registerDefaultContextBroker(pi: ExtensionAPI): Promise<void> {
if (!contextBrokerEnabled()) return;
const p = pi as any;
if (!contextBrokerEnabled()) {
p.__piRogueContextBrokerStatus = { enabled: false, registered: false };
return;
}
try {
const { registerContextBrokerBeta } = await import("@fiale-plus/pi-rogue-context-broker/extension");
const durableEnv = String(process.env.PI_CONTEXT_BROKER_DURABLE ?? "").trim().toLowerCase();
Expand All @@ -20,8 +23,11 @@ export async function registerDefaultContextBroker(pi: ExtensionAPI): Promise<vo
durable,
storeDir: configuredStoreDir || join(homedir(), ".pi", "agent", "pi-rogue", "context-broker"),
});
const effective = p.__piRogueContextBrokerEffective ?? { backend: durable ? "sqlite" : "memory", durable };
p.__piRogueContextBrokerStatus = { enabled: true, registered: true, durable: effective.durable, backend: effective.backend };
} catch (error) {
p.__piRogueContextBrokerError = error;
p.__piRogueContextBrokerStatus = { enabled: true, registered: false, error: true };
console.warn("[pi-rogue] context broker registration failed; continuing without /pi-rogue-context", error);
}
}
4 changes: 4 additions & 0 deletions packages/bundle/src/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ describe("bundle extension defaults", () => {
"pi-rogue-context",
]);
expect(["advisor", "router"].some((name) => commands.has(name))).toBe(false);
expect(typeof (pi as any).__piRogueFeatureStatusCatalog).toBe("function");
expect((pi as any).__piRogueFeatureStatusCatalog({}).features.map((status: any) => status.feature)).toEqual([
"advisor", "router", "orchestration", "context-broker",
]);
});

it("honors canonical durability, store, and backend environment precedence", async () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/bundle/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { registerAdvisor } from "@fiale-plus/pi-rogue-advisor";
import { registerOrchestration } from "@fiale-plus/pi-rogue-orchestration";
import { registerRouter } from "@fiale-plus/pi-rogue-router/extension";
import { registerDefaultContextBroker } from "./context-broker-default.js";
import { createHarmonizationStatusCatalog } from "./status-catalog.js";

export async function registerBundle(pi: ExtensionAPI): Promise<void> {
const p = pi as any;
Expand All @@ -14,6 +15,9 @@ export async function registerBundle(pi: ExtensionAPI): Promise<void> {
registerOrchestration(pi);

await registerDefaultContextBroker(pi);
p.__piRogueFeatureStatusCatalog = (ctx: unknown) => createHarmonizationStatusCatalog(ctx, {
contextBroker: () => p.__piRogueContextBrokerStatus ?? { enabled: false, registered: false },
});
}

export default function bundleExtension(pi: ExtensionAPI): Promise<void> {
Expand Down
7 changes: 7 additions & 0 deletions packages/bundle/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
export { default, registerBundle } from "./extension.js";
export {
createHarmonizationStatusCatalog,
serializeHarmonizationStatusCatalog,
HARMONIZATION_STATUS_CATALOG_SCHEMA,
FEATURE_ORDER,
} from "./status-catalog.js";
export type { FeatureStatusProvider, HarmonizationStatusCatalogV1, HarmonizationStatusSources } from "./status-catalog.js";
59 changes: 59 additions & 0 deletions packages/bundle/src/status-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { createFeatureStatusV1 } from "@fiale-plus/pi-core";
import {
createHarmonizationStatusCatalog,
HARMONIZATION_STATUS_CATALOG_SCHEMA,
serializeHarmonizationStatusCatalog,
} from "./status-catalog.js";

const status = (feature: string, health: "idle" | "ready" = "idle") => createFeatureStatusV1({
feature,
owner: feature,
health,
enabled: true,
mode: health,
});

describe("harmonization status catalog", () => {
it("keeps explicit registration order and uses only passive providers", () => {
const calls: string[] = [];
const catalog = createHarmonizationStatusCatalog({}, {
advisor: () => { calls.push("advisor"); return status("advisor"); },
router: () => { calls.push("router"); return status("router", "ready"); },
orchestration: () => { calls.push("orchestration"); return status("orchestration"); },
contextBroker: () => { calls.push("context-broker"); return { enabled: true, registered: true, durable: false, backend: "memory" }; },
});

expect(calls).toEqual(["advisor", "router", "orchestration", "context-broker"]);
expect(catalog).toMatchObject({
schema: HARMONIZATION_STATUS_CATALOG_SCHEMA,
features: [
{ feature: "advisor" },
{ feature: "router", health: "ready" },
{ feature: "orchestration" },
{ feature: "context-broker", health: "ready", mode: "memory" },
],
});
});

it("converts adapter failures into an explicit error status", () => {
const catalog = createHarmonizationStatusCatalog({}, {
advisor: () => { throw new Error("raw failure must not escape"); },
});
expect(catalog.features[0]).toMatchObject({ feature: "advisor", health: "error", enabled: false, diagnostics: { adapterError: true } });
expect(JSON.stringify(catalog)).not.toContain("raw failure");
});

it("serializes only the canonical, privacy-checked catalog", () => {
const catalog = createHarmonizationStatusCatalog({}, {
advisor: () => status("advisor"),
router: () => status("router"),
orchestration: () => status("orchestration"),
contextBroker: () => ({ enabled: false, registered: false }),
});
const first = serializeHarmonizationStatusCatalog(catalog);
expect(first).toBe(serializeHarmonizationStatusCatalog(JSON.parse(first)));
expect(first).not.toContain("/");
expect(() => serializeHarmonizationStatusCatalog({ ...catalog, features: [{ ...catalog.features[0], diagnostics: { prompt: "secret" } }, ...catalog.features.slice(1)] })).toThrow(/prohibited field/);
});
});
85 changes: 85 additions & 0 deletions packages/bundle/src/status-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
createFeatureStatusV1,
serializeFeatureStatusV1,
validateFeatureStatusV1,
type FeatureStatusV1,
} from "@fiale-plus/pi-core";
import { advisorFeatureStatus } from "@fiale-plus/pi-rogue-advisor";
import { contextBrokerFeatureStatus, type ContextBrokerStatusSource } from "@fiale-plus/pi-rogue-context-broker";
import { orchestrationFeatureStatus } from "@fiale-plus/pi-rogue-orchestration";
import { routerFeatureStatus } from "@fiale-plus/pi-rogue-router";

export const HARMONIZATION_STATUS_CATALOG_SCHEMA = "pi-rogue.harmonization-status.v1" as const;

export interface HarmonizationStatusCatalogV1 {
schema: typeof HARMONIZATION_STATUS_CATALOG_SCHEMA;
features: FeatureStatusV1[];
}

export type FeatureStatusProvider = (ctx: unknown) => FeatureStatusV1;

export interface HarmonizationStatusSources {
advisor?: FeatureStatusProvider;
router?: FeatureStatusProvider;
orchestration?: FeatureStatusProvider;
contextBroker?: (ctx: unknown) => ContextBrokerStatusSource;
}

const FEATURE_ORDER = ["advisor", "router", "orchestration", "context-broker"] as const;

function errorStatus(feature: string, owner: string): FeatureStatusV1 {
return createFeatureStatusV1({
feature,
owner,
health: "error",
enabled: false,
mode: "unavailable",
summary: "status adapter failed",
diagnostics: { adapterError: true },
});
}

function callStatus(feature: string, owner: string, provider: FeatureStatusProvider | undefined, ctx: unknown): FeatureStatusV1 {
if (!provider) return createFeatureStatusV1({ feature, owner, health: "unavailable", enabled: false, mode: "unavailable", summary: "status adapter unavailable" });
try {
const status = provider(ctx);
validateFeatureStatusV1(status);
if (status.feature !== feature || status.owner !== owner) throw new Error("status identity mismatch");
return status;
} catch {
return errorStatus(feature, owner);
}
}

/** Build an ordered, read-only status catalog. Providers never receive lifecycle/control capabilities. */
export function createHarmonizationStatusCatalog(ctx: unknown, sources: HarmonizationStatusSources = {}): HarmonizationStatusCatalogV1 {
const contextStatus = (): FeatureStatusV1 => {
let contextSource: ContextBrokerStatusSource = { enabled: false, registered: false };
try {
contextSource = sources.contextBroker?.(ctx) ?? contextSource;
} catch {
contextSource = { enabled: true, registered: false, error: true };
}
return contextBrokerFeatureStatus(contextSource);
};
const statuses = [
callStatus("advisor", "advisor", sources.advisor ?? (() => advisorFeatureStatus()), ctx),
callStatus("router", "router", sources.router ?? ((value) => routerFeatureStatus(value)), ctx),
callStatus("orchestration", "orchestration", sources.orchestration ?? ((value) => orchestrationFeatureStatus(value)), ctx),
callStatus("context-broker", "context-broker", contextStatus, ctx),
];
return { schema: HARMONIZATION_STATUS_CATALOG_SCHEMA, features: statuses };
}

/** Stable, privacy-checked representation for local reports and passive evidence. */
export function serializeHarmonizationStatusCatalog(catalog: HarmonizationStatusCatalogV1): string {
if (catalog.schema !== HARMONIZATION_STATUS_CATALOG_SCHEMA) throw new Error("catalog.schema: invalid schema");
if (!Array.isArray(catalog.features) || catalog.features.length !== FEATURE_ORDER.length) throw new Error("catalog.features: invalid feature count");
catalog.features.forEach((status, index) => {
validateFeatureStatusV1(status);
if (status.feature !== FEATURE_ORDER[index]) throw new Error("catalog.features: non-canonical order");
});
return JSON.stringify({ schema: catalog.schema, features: catalog.features.map((status) => JSON.parse(serializeFeatureStatusV1(status))) });
}

export { FEATURE_ORDER };
5 changes: 5 additions & 0 deletions packages/context-broker/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,11 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
} else {
broker = createInMemoryContextBroker(brokerOptions);
}
p.__piRogueContextBrokerEffective = {
backend: effectiveBackend,
durable: durable && effectiveBackend !== "memory" && effectiveBackend !== "memory(degraded)",
degraded: effectiveBackend === "memory(degraded)",
};

const seenSourceIds = new Set<string>();
const sourceHandles = new Map<string, string>();
Expand Down
3 changes: 3 additions & 0 deletions packages/context-broker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export function sourceIdFor(input: ContextArtifactInput): string | undefined {
return sourceId;
}

export { contextBrokerFeatureStatus, serializeContextBrokerFeatureStatus } from "./status.js";
export type { ContextBrokerBackend, ContextBrokerStatusSource } from "./status.js";

export function sourceKey(sessionId: string, sourceId: string): string {
return `${sessionId}\u0000${sourceId}`;
}
Expand Down
20 changes: 20 additions & 0 deletions packages/context-broker/src/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { contextBrokerFeatureStatus, serializeContextBrokerFeatureStatus } from "./status.js";

describe("contextBrokerFeatureStatus", () => {
it("distinguishes disabled, unavailable, error, and ready states", () => {
expect(contextBrokerFeatureStatus({ enabled: false, registered: false }).health).toBe("disabled");
expect(contextBrokerFeatureStatus({ enabled: true, registered: false }).health).toBe("unavailable");
expect(contextBrokerFeatureStatus({ enabled: true, registered: false, error: true }).health).toBe("error");
expect(contextBrokerFeatureStatus({ enabled: true, registered: true, durable: true, backend: "sqlite" })).toMatchObject({ health: "ready", mode: "sqlite" });
expect(contextBrokerFeatureStatus({ enabled: true, registered: true, durable: false, backend: "memory(degraded)" })).toMatchObject({ health: "degraded", summary: "context broker is degraded" });
expect(contextBrokerFeatureStatus({ enabled: true, registered: true, backend: "/private/user/secret" }).health).toBe("error");
});

it("serializes only bounded status metadata", () => {
const serialized = serializeContextBrokerFeatureStatus({ enabled: true, registered: true, durable: false, backend: "memory" });
expect(serialized).toContain('"feature":"context-broker"');
expect(serialized).not.toContain("/");
expect(serialized).not.toContain("path");
});
});
Loading
Loading