fabric-council
GitHub运行多视角架构评审,通过独立角色协作与综合评估进行设计审查、计划验证及对抗性交叉检查。
Trigger Scenarios
Install
npx skills add fabric-runtime/pi-fabric --skill fabric-council -g -y
SKILL.md
Frontmatter
{
"name": "fabric-council",
"description": "Runs a bounded multi-perspective Pi Fabric council with independent reviewers and best-effort synthesis. Use for architecture choices, plans, reviews, and adversarial cross-checking.",
"disable-model-invocation": true
}
Fabric Council
Use one fabric_exec call with bounded role fan-out. Choose three to eight roles that disagree usefully rather than duplicating one another, such as correctness, security, operability, maintainability, and requirements skepticism. Pin a model to a role when you want vendor diversity as well as role diversity. Use this template only for non-sensitive work: full reports are persisted in project-shared mesh, not private session storage. Do not supply credentials, secrets, or other material that must not be shared with other project sessions; use a plain agent without mesh parking for that work. Report secret locations and remediation, never secret values. This template does not automatically sanitize reports.
type CouncilSeat = { role: string; model?: string; thinking?: FabricThinking };
type CouncilOutcome =
| { role: string; model?: string; status: "completed"; report: string }
| { role: string; model?: string; status: "failed"; error: string };
const text = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
const specs: unknown = JSON.parse(π.roles);
if (!Array.isArray(specs)) throw new Error("Council roles must be a JSON array.");
const seats: CouncilSeat[] = [];
for (const spec of specs) {
const entry = (typeof spec === "string" ? { role: spec } : spec) as Record<string, unknown> | null;
if (!entry || typeof entry !== "object") {
throw new Error("Each council role must be a string or a { role, model?, thinking? } object.");
}
const role = text(entry.role);
if (!role) {
if (typeof spec === "string") continue;
throw new Error("A council role object needs a non-empty role.");
}
if ([entry.model, entry.thinking].some((value) => value != null && typeof value !== "string")) {
throw new Error(`Council role "${role}" has a non-string model or thinking.`);
}
const thinking = text(entry.thinking);
if (thinking && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)) {
throw new Error(`Council role "${role}" has unknown thinking "${thinking}".`);
}
const seat: CouncilSeat = { role };
if (text(entry.model)) seat.model = text(entry.model);
if (text(entry.thinking)) seat.thinking = text(entry.thinking) as FabricThinking;
const earlier = seats.find((other) => other.role === role);
if (!earlier) seats.push(seat);
else if (earlier.model !== seat.model || earlier.thinking !== seat.thinking) {
throw new Error(`Council role "${role}" appears twice with different settings.`);
}
}
if (seats.length < 3 || seats.length > 8) {
throw new Error("Council requires 3–8 distinct non-empty roles.");
}
let synthesisModel = text(π.synthesis) || undefined;
if (synthesisModel || seats.some((seat) => seat.model)) {
// Match registry keys exactly before any agent runs, so a typo spends nothing.
const keys = new Map((await tools.models()).map((entry) => [entry.key.toLowerCase(), entry.key]));
const canonical = (needle: string, owner: string): string => {
const key = keys.get(needle.toLowerCase());
if (key) return key;
const id = needle.toLowerCase().split("/").pop();
const near = [...keys.values()].filter((candidate) => candidate.toLowerCase().endsWith(`/${id}`));
throw new Error(
`Council: model "${needle}" for ${owner} is not in the model registry.` +
(near.length ? ` Did you mean: ${near.slice(0, 8).join(", ")}?` : ""),
);
};
for (const seat of seats) if (seat.model) seat.model = canonical(seat.model, `role "${seat.role}"`);
if (synthesisModel) synthesisModel = canonical(synthesisModel, "synthesis");
}
await workflow.configure({
name: "Council review",
description: `${seats.length} independent perspectives with best-effort synthesis`,
});
await phase("Deliberate", { total: seats.length + 1 });
const outcomes = await parallel(
seats.map((seat) => async (): Promise<CouncilOutcome> => {
const pin = seat.model ? { model: seat.model } : {};
try {
const report = await agent(
`Act as the ${seat.role} council member. Independently analyze this task. Reports are persisted in project-shared mesh: never reproduce credentials, secret values, or private data; describe locations and remediation instead:\n\n${π.task}`,
{
label: seat.role,
tools: ["read", "grep", "find", "ls"],
...pin,
...(seat.thinking ? { thinking: seat.thinking } : {}),
},
);
return { role: seat.role, ...pin, status: "completed", report };
} catch (error) {
return {
role: seat.role,
...pin,
status: "failed",
error: error instanceof Error ? error.message : String(error),
};
}
}),
{ concurrency: seats.length },
);
const completed = outcomes.filter(
(outcome): outcome is Extract<CouncilOutcome, { status: "completed" }> =>
outcome.status === "completed",
);
const failures = outcomes.filter(
(outcome): outcome is Extract<CouncilOutcome, { status: "failed" }> =>
outcome.status === "failed",
);
const coverage = { requested: seats.length, completed: completed.length };
// Name the model each seat and the synthesis requested; an unpinned seat ran on the session default.
const pins = {
seats: seats.map((seat, index) => ({ ...seat, status: outcomes[index].status })),
...(synthesisModel ? { synthesisModel } : {}),
};
if (completed.length === 0) {
return { status: "failed", coverage, ...pins, failures, result: null };
}
// Park completed reports in mesh before synthesis: an audit trail that survives this
// program, and full-text recovery when synthesis fails or a budget runs out.
// ifVersion: 0 makes each put create-only, so a same-second council moves to the
// next key suffix instead of overwriting an earlier run's reports.
const parked = completed.map((outcome) => ({
role: outcome.role,
...(outcome.model ? { model: outcome.model } : {}),
report: outcome.report,
}));
let reports: string | { error: string } | null = null;
let parkError = "";
const reportsBase = `council/${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}`;
for (let attempt = 0; attempt < 4 && reports === null; attempt++) {
const suffix = attempt === 0 ? "" : `-${attempt + 1}`;
const reportsKey = `${reportsBase}${suffix}/reports`;
try {
await mesh.put({ key: reportsKey, value: parked, ifVersion: 0 });
reports = reportsKey;
} catch (error) {
parkError = error instanceof Error ? error.message : String(error);
// Only a same-timestamp collision retries with the next suffix; any other
// storage failure surfaces immediately as the { error } object.
if (!parkError.includes("compare-and-swap")) break;
}
}
if (reports === null) reports = { error: parkError };
if (completed.length === 1) {
return {
status: "partial",
coverage,
...pins,
failures,
result: completed[0].report,
reports,
synthesisSkipped: "Only one role completed; another agent would add no diversity.",
};
}
try {
const result = await agent(
`Synthesize these completed reports into one decision. Reject unsupported claims, preserve material disagreements, attribute each concern to its role. Do not infer the views of failed roles.\n\nTask:\n${π.task}\n\nReports:\n${JSON.stringify(completed)}`,
{
label: "council synthesis",
tools: ["read", "grep", "find", "ls"],
...(synthesisModel ? { model: synthesisModel } : {}),
},
);
return {
status: failures.length === 0 ? "success" : "partial",
coverage,
...pins,
failures,
result,
reports,
};
} catch (error) {
return {
status: "partial",
coverage,
...pins,
failures,
result: null,
synthesisError: error instanceof Error ? error.message : String(error),
reports,
fallback: completed.map((outcome) => ({
role: outcome.role,
status: "completed",
report: typeof reports === "string" ? outcome.report.slice(0, 1200) : outcome.report,
})),
};
}
Pass the task as strings.task, the JSON role array as strings.roles, and the synthesis model key as strings.synthesis; an empty strings.synthesis keeps the session default. A role is a string or { "role": "security", "model": "provider/id", "thinking": "high" }, and a role without model runs on the session default. Model keys must match a Pi registry key from tools.models() exactly, ignoring case; the skill refuses a models.aliases name or a partial id, and checks every key before any agent runs. Every result lists seats, each with its role, pinned model and thinking, and status, plus synthesisModel when one was pinned. These are the models the skill requested; keeping a run on its pinned model is the host's job. Successful synthesis returns the compact decision, coverage, seats, failures, and reports (the mesh key, or the storage error object when parking failed). Whenever at least one seat completes, the full role reports are parked to mesh under a create-only council/<timestamp>/reports key — a same-second collision parks under the next suffix rather than overwriting — and every outcome carries it as reports: check typeof reports === "string" first, because a failed mesh.put yields an { error } object instead of a key — never pass that to mesh.get or mesh.delete, and the synthesis-failure fallback then carries the full untruncated reports. Otherwise mesh.get the key for audit or to retry a budget-starved synthesis with one plain agent, and mesh.delete it when no longer needed. On synthesis failure the fallback result carries report heads plus that mesh key, not the full texts; the synthesis prompt still embeds every completed report, so synthesis input grows with total report size. One surviving role returns directly without spending another agent call.
Concurrent calls can overshoot an observational tokenBudget because usage settles afterward. Reserve at least seats.length + 1 top-level agent calls when synthesis may run, and use a finite role set rather than relying on token accounting as a hard concurrent ceiling. A budget-exhausted synthesis loses nothing: the parked reports are complete, so re-run only the synthesis step.
Status is success, partial, or failed. A partial result is usable evidence with named gaps and must not trigger an automatic whole-council rerun. If a missing perspective matters, retry that role with one plain agent using the original task and role; do not invoke a new council or rerun successful roles. Do not use a council for a lookup or a decision with no meaningful competing considerations.
Version History
-
145fb0f
Current 2026-11-07 11:28
明确报告可见性规则;修复因时间戳碰撞导致的报告存储覆盖问题,改用创建独占模式保障数据一致性;修正Python指南中的报告返回逻辑说明。
- 4286766 2026-11-02 10:34
- 88c505b 2026-09-08 19:27


