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
3 changes: 2 additions & 1 deletion .pi/extensions/nmg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import { memoryDisclosureEntries } from "../../../src/integration/search-projection.ts";
import { PI_BOARD_ACTIONS, PI_REMEMBER_ACTIONS } from "../../../src/integration/tool-contract.ts";
import { TASK_BOARD_VERDICTS } from "../../../src/core/types.ts";
import { boardEntryView } from "../../../src/core/board-entry-view.ts";
import { resolveSkillOptPolicyChannels } from "../../../src/lab/skillopt-policy.ts";
import type {
ActiveGraphBudget,
Expand Down Expand Up @@ -263,171 +264,171 @@
}
});

pi.on("before_agent_start", async (event, ctx) => {
const sessionId = ctx.sessionManager.getSessionId();
const isNewUserTurn = recallFlow.beginTurn(
sessionId,
piUserTurnKey(ctx.sessionManager, event.prompt),
);
await beginDisclosureTurn(sessionId, isNewUserTurn);
const completionNudge = popCompletionNudge(event.prompt);
// Pi re-enters before_agent_start after tool results. A completed graph may
// already exist at that point, but it must be reviewed on the next user
// turn, not consumed by an internal tool loop from the same prompt.
const pendingFeedback = isNewUserTurn ? controllerShadow.pendingFeedback(sessionId) : null;
if (pendingFeedback) await controllerShadow.feedbackNudgeShown(sessionId, pendingFeedback);
const feedbackNudge = pendingFeedback
? renderDisclosure(nmgPrompts.shadow_feedback_nudge, {
active_graph_id: pendingFeedback.activeGraphId,
semantic_task_id: pendingFeedback.semanticTaskId,
})
: "";
const pendingClaimOutcome = isNewUserTurn
? controllerShadow.pendingClaimOutcome(sessionId)
: null;
if (pendingClaimOutcome) {
await controllerShadow.claimOutcomeNudgeShown(sessionId, pendingClaimOutcome);
}
const claimOutcomeNudge = pendingClaimOutcome
? renderDisclosure(nmgPrompts.shadow_claim_outcome_nudge, {
active_graph_id: pendingClaimOutcome.activeGraphId,
semantic_task_id: pendingClaimOutcome.semanticTaskId,
memory_ids: pendingClaimOutcome.memoryIds.join(","),
})
: "";
const nudge = [completionNudge, feedbackNudge, claimOutcomeNudge].filter(Boolean).join("\n");
let reasoningCheckpoint = "";
if (labToolsEnabled) {
try {
const status = (await invokeDaemon(await connection(), "lab", {
action: "status",
capability: "reasoning_workspace",
sessionId,
})) as { activation?: unknown };
if (status.activation) {
const consumed = (await invokeDaemon(await connection(), "lab", {
action: "invoke",
capability: "reasoning_workspace",
sessionId,
operation: "consume_checkpoint",
input: { maxNodes: 24, maxChars: 6_000 },
})) as { output?: { text?: string } | null };
reasoningCheckpoint = consumed.output?.text ?? "";
}
} catch (error) {
reasoningCheckpoint = `Reasoning workspace unavailable: ${message(error)}`;
}
}
let runtimeAgContext = "";
try {
const result = (await invoke("sessionActiveGraph", {
action: "snapshot",
sessionId,
})) as {
snapshot?:
import("../../../src/core/session-active-graph.ts").SessionActiveGraphSnapshot | null;
};
runtimeAgContext = renderSessionActiveGraphSurface(result.snapshot ?? null);
} catch {
// Search and ordinary Pi operation continue without optional working state.
}
const runtimeContext = [runtimeAgContext, reasoningCheckpoint].filter(Boolean).join("\n");
const recallRequest = taskWindow.prepare(sessionId, event.prompt);
const dynamicContext = composeNmgContextMessage("", "", nudge, runtimeContext);
if (!recallRequest) {
return {
systemPrompt: composeNmgSystemPrompt(event.systemPrompt),
...(dynamicContext
? {
message: {
customType: "nmg-context",
content: dynamicContext,
display: true,
details: { count: 0 },
},
}
: {}),
};
}
try {
let context = (await invoke("search", {
query: recallRequest.query,
projectDir: projectDirectory(),
sessionId,
maxTier: Math.min(configuredAutoRecallTier(), recallRequest.maxTier) as MemoryTier,
limit: Math.min(configuredAutoRecallLimit(), recallRequest.limit),
initialEvidenceTarget: configuredInitialTarget(),
strongHitTopGap: configuredStrongHitTopGap(),
strongHitInitialTarget: configuredStrongHitInitialTarget(),
secondPass: qpp2Mode === "active",
graphHops: Math.min(1, recallRequest.graphHops),
tieredDisclosure: true,
// Tell the daemon this is an automatic recall decision: it stages the
// injected graph for online learning (explicit nmg_search stays unstaged).
autoRecall: true,
})) as MemoryContext;
if (controllerRerankMode === "active") {
context = await controllerShadow.rerank(context);
}
const fullContext = context;
if (qpp2Mode === "active") {
context = await applyLearnedFold(context, controllerShadow, qpp2RetainedMass, false);
}
const recalled = await formatDisclosedContext(sessionId, context, "header");
await controllerShadow.retrieval(fullContext, sessionId, "automatic", recalled);
// Automatic recall is still a retrieval trace. Keep it in the per-turn
// attribution window so agent_end can distinguish surfaced evidence from
// candidates that were merely injected.
agentAttributionFlow.note(sessionId, fullContext);
const recordCount = (recalled.match(/memory=/g) ?? []).length;
const searchNudge = formatSearchRecommendation(context, recommendationMode);
const recallContext = composeNmgContextMessage(
recalled,
"",
[
nudge,
searchNudge,
recordCount > 0 ? recallFeedbackAffordance(fullContext.activeGraph?.id) : "",
]
.filter(Boolean)
.join("\n"),
runtimeContext,
);
return {
systemPrompt: composeNmgSystemPrompt(event.systemPrompt),
...(recallContext
? {
message: {
customType: "nmg-context",
content: recallContext,
display: true,
details: { count: recordCount },
},
}
: {}),
};
} catch (error) {
const errorContext = composeNmgContextMessage(
"",
`NMG unavailable: ${message(error)}`,
nudge,
runtimeContext,
);
return {
systemPrompt: composeNmgSystemPrompt(event.systemPrompt),
...(errorContext
? {
message: {
customType: "nmg-context",
content: errorContext,
display: true,
details: { count: 0 },
},
}
: {}),
};
}
});

Check notice on line 431 in .pi/extensions/nmg/index.ts

View check run for this annotation

codefactor.io / CodeFactor

.pi/extensions/nmg/index.ts#L267-L431

Complex Method

// TUI 折叠开关:nmg-context 默认折叠为一个 [nmg-context] chip,避免每轮刷屏。
// /nmg-recall 切换展开(走 pi 默认渲染:label + 全文)。状态只影响后续渲染
Expand All @@ -448,210 +449,210 @@
// NMG 总二级/三级菜单。早期只有独立的 /nmg-recall 命令;现在统一收编到
// /nmg 下,保留 /nmg-recall 作为别名。状态只影响后续渲染与重开会话后的
// 历史恢复;已渲染的历史消息不重绘(pi 无公开 force-rerender)。
const nmgMenuHandler = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
const parts = args.trim().split(/\s+/).filter(Boolean);
const [sub, ...rest] = parts;
const restText = rest.join(" ");
switch (sub) {
case "recall": {
recallCollapsed = !recallCollapsed;
ctx.ui.notify(
recallCollapsed
? "nmg-context 已折叠(只显示 [nmg-context] chip)"
: "nmg-context 已展开(显示召回全文)",
"info",
);
return;
}
case "wake": {
const current = readWakeConfig();
const [wcmd, wvalRaw] = restText.split(/\s+/);
const wval = Number(wvalRaw);
switch (wcmd) {
case "on":
writeWakeConfig({ ...current, enabled: true });
ctx.ui.notify("黑板唤醒已开启(世界频道+活跃频道有新 open 条目会唤醒)", "info");
return;
case "off":
writeWakeConfig({ ...current, enabled: false });
ctx.ui.notify("黑板唤醒已关闭", "info");
return;
case "status": {
const budgetText = current.budget === 0 ? "不限制" : `${current.budget}/天`;
const cooldownText =
current.cooldownMs === 0 ? "无" : `${Math.round(current.cooldownMs / 60_000)} 分`;
ctx.ui.notify(
`黑板唤醒:${current.enabled ? "开" : "关"} · 预算 ${budgetText} · 冷却 ${cooldownText} · 轮询 ${Math.round(current.intervalMs / 1_000)} 秒 · 世界广播 ${current.worldBroadcast ? "开" : "关"}`,
"info",
);
return;
}
case "budget": {
if (!Number.isFinite(wval)) {
ctx.ui.notify("用法:/nmg wake budget N(0=不限制)", "warning");
return;
}
const budget = Math.max(0, Math.min(100, Math.round(wval)));
writeWakeConfig({ ...current, budget });
ctx.ui.notify(`黑板唤醒预算已设为 ${budget === 0 ? "不限制" : `${budget}/天`}`, "info");
return;
}
case "cooldown": {
if (!Number.isFinite(wval)) {
ctx.ui.notify("用法:/nmg wake cooldown M(0=无冷却)", "warning");
return;
}
const cooldownMs = wval === 0 ? 0 : Math.max(30_000, Math.round(wval * 60_000));
writeWakeConfig({ ...current, cooldownMs });
ctx.ui.notify(
`黑板唤醒冷却已设为 ${cooldownMs === 0 ? "无" : `${Math.round(cooldownMs / 60_000)} 分钟`}`,
"info",
);
return;
}
case "interval": {
if (!Number.isFinite(wval)) {
ctx.ui.notify("用法:/nmg wake interval S(秒,最小 5)", "warning");
return;
}
const intervalMs = Math.max(5_000, Math.round(wval * 1_000));
writeWakeConfig({ ...current, intervalMs });
ctx.ui.notify(`黑板唤醒轮询已设为 ${Math.round(intervalMs / 1_000)} 秒`, "info");
return;
}
case "world": {
// 无参切换;0 关、1 开。
const world =
wvalRaw === "0" ? false : wvalRaw === "1" ? true : !current.worldBroadcast;
writeWakeConfig({ ...current, worldBroadcast: world });
ctx.ui.notify(
world
? "已开启世界频道协作广播:协作类新条目会同时发到世界频道拉其他 agent"
: "已关闭世界频道协作广播",
"info",
);
return;
}
default: {
const enabled = !current.enabled;
writeWakeConfig({ ...current, enabled });
ctx.ui.notify(enabled ? "黑板唤醒已开启" : "黑板唤醒已关闭", "info");
}
}
return;
}
default: {
// 无参数 → 交互式选择菜单(可上下键选,不用手敲);未知子命令 → 总览提示。
if (parts.length === 0) {
await nmgInteractiveMenu(ctx);
return;
}
const wake = readWakeConfig();
const budgetText = wake.budget === 0 ? "不限制" : `${wake.budget}/天`;
const cooldownText =
wake.cooldownMs === 0 ? "无" : `${Math.round(wake.cooldownMs / 60_000)} 分`;
ctx.ui.notify(
`NMG 菜单:/nmg recall(召回折叠 ${recallCollapsed ? "开" : "关"}) · /nmg wake on|off|status|budget N|cooldown M|interval S(唤醒 ${wake.enabled ? "开" : "关"},预算 ${budgetText},冷却 ${cooldownText},轮询 ${Math.round(wake.intervalMs / 1_000)} 秒)`,
"info",
);
}
}
};

Check notice on line 560 in .pi/extensions/nmg/index.ts

View check run for this annotation

codefactor.io / CodeFactor

.pi/extensions/nmg/index.ts#L452-L560

Complex Method
// 交互式选择菜单:/nmg(无参数)弹出可上下键选的列表,避免手敲。带参数的
// 形式(/nmg recall、/nmg wake on 等)仍保留,供脚本/快捷路径直接调用。
const nmgInteractiveMenu = async (ctx: ExtensionCommandContext): Promise<void> => {
if (!ctx.hasUI || typeof ctx.ui.select !== "function") {
ctx.ui.notify(
"当前模式不支持交互菜单,可用:/nmg recall、/nmg wake on|off|status|budget N|cooldown M|interval S",
"warning",
);
return;
}
const choice = await ctx.ui.select(
"NMG 控制台",
nmgMenuOptions(recallCollapsed, readWakeConfig()),
);
if (!choice) return; // 用户取消
if (choice.startsWith("召回")) {
await nmgMenuHandler("recall", ctx);
return;
}
if (choice.startsWith("唤醒:开启/关闭")) {
const onOff = await ctx.ui.select("黑板唤醒", ["开启", "关闭"]);
if (!onOff) return;
const current = readWakeConfig();
writeWakeConfig({ ...current, enabled: onOff === "开启" });
ctx.ui.notify(onOff === "开启" ? "黑板唤醒已开启" : "黑板唤醒已关闭", "info");
return;
}
if (choice.startsWith("唤醒:世界广播")) {
const onOff = await ctx.ui.select("世界频道协作广播", ["开启", "关闭"]);
if (!onOff) return;
const current = readWakeConfig();
writeWakeConfig({ ...current, worldBroadcast: onOff === "开启" });
ctx.ui.notify(
onOff === "开启"
? "已开启世界频道协作广播:协作类新条目会同时发到世界频道拉其他 agent"
: "已关闭世界频道协作广播",
"info",
);
return;
}
if (choice.startsWith("唤醒:参数设置")) {
// 挡位选择(select 预设值,避免随意输入溢出/无效);自由数字只走命令形式
// (/nmg wake budget N 等,已有 clamp)。
const param = await ctx.ui.select("唤醒参数", ["每日上限", "冷却", "轮询"]);
if (!param) return;
const current = readWakeConfig();
if (param === "每日上限") {
const option = await ctx.ui.select("每日唤醒上限", ["不限制", "3/天", "8/天", "15/天"]);
if (!option) return;
const budget = option === "不限制" ? 0 : Number(option.split("/")[0]);
writeWakeConfig({ ...current, budget });
ctx.ui.notify(`黑板唤醒预算已设为 ${budget === 0 ? "不限制" : `${budget}/天`}`, "info");
} else if (param === "冷却") {
const option = await ctx.ui.select("冷却", ["无", "10 分钟", "30 分钟", "1 小时"]);
if (!option) return;
const cooldownMs =
option === "无"
? 0
: option === "10 分钟"
? 600_000
: option === "30 分钟"
? 1_800_000
: 3_600_000;
writeWakeConfig({ ...current, cooldownMs });
ctx.ui.notify(
`黑板唤醒冷却已设为 ${cooldownMs === 0 ? "无" : `${Math.round(cooldownMs / 60_000)} 分钟`}`,
"info",
);
} else if (param === "轮询") {
const option = await ctx.ui.select("轮询", ["5 秒", "30 秒", "60 秒", "5 分钟"]);
if (!option) return;
const intervalMs =
option === "5 秒"
? 5_000
: option === "30 秒"
? 30_000
: option === "60 秒"
? 60_000
: 300_000;
writeWakeConfig({ ...current, intervalMs });
ctx.ui.notify(`黑板唤醒轮询已设为 ${Math.round(intervalMs / 1_000)} 秒`, "info");
}
return;
}
{
const wake = readWakeConfig();
const budgetText = wake.budget === 0 ? "不限制" : `${wake.budget}/天`;
const cooldownText =
wake.cooldownMs === 0 ? "无" : `${Math.round(wake.cooldownMs / 60_000)} 分`;
ctx.ui.notify(
`NMG:召回折叠 ${recallCollapsed ? "开" : "关"} · 唤醒 ${wake.enabled ? "开" : "关"}(预算 ${budgetText},冷却 ${cooldownText},轮询 ${Math.round(wake.intervalMs / 1_000)} 秒)`,
"info",
);
}
};

Check notice on line 655 in .pi/extensions/nmg/index.ts

View check run for this annotation

codefactor.io / CodeFactor

.pi/extensions/nmg/index.ts#L563-L655

Complex Method
// 菜单项带当前状态,进子菜单前就看得见:召回折叠/展开、唤醒开/关、参数当前值。
// 预算/冷却支持 0(不限制/无),轮询保持最小 5 秒(0 无意义)。
const nmgMenuOptions = (
Expand Down Expand Up @@ -2998,7 +2999,7 @@
entryIds: [entry.id],
})) as { delivered: string[]; suppressed: boolean };
if (worldCheck.delivered.includes(entry.id)) return false;
const excerpt = entry.content.length > 140 ? `${entry.content.slice(0, 140)}…` : entry.content;
const excerpt = boardEntryView(entry.content, 140);
const label = kindLabel(entry.kind);
const broadcast = `[NMG board 协作广播] 频道 ${entry.taskId} 有 #${entry.id} 未认领的${label}(open):${excerpt}。有空的 agent 可用 nmg_board read taskId=${entry.taskId} 查看详情、claim 认领处理。`;
await invoke("taskBoard", {
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

108 changes: 108 additions & 0 deletions docs/decisions/proposed/2026-09-20-the-program-answers-legality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# The program answers legality, and nothing else

[中文](2026-09-20-the-program-answers-legality.zh-CN.md)

**Status:** proposed
**Relates to:** [Mechanism, not policy](2026-09-21-mechanism-not-policy.md), [Board governance and capability addressing](../implemented/2026-09-06-board-governance-addressing.md), [Name the collaboration protocol and its task-unit sub-protocol](../implemented/2026-09-20-name-the-collaboration-protocol.md), [Task unit semantics](../../design/task-unit-semantics.md), [The contract's obligations](../../design/task-unit-semantics-obligations.md)

## Problem

The primitives have names, and the shared layer can already compute the legal set: an ordered legal set,
that set cut to the declared slot budget, and a refusal reason per unit when a claim is checked. What no
document states is **whose decision each step is**. The cost of that gap is observable: every caller
that wants to drive a run has to redraw the boundary for itself, so both recorded failure modes come
back - a program that picks the person for the agents, which is the scheduler the naming decision
records as the word this model deliberately did not become, and agents that cannot tell what the program
guarantees, so they ask for what they actually need in the only terms available: more slots, stranger
ordering.

[The obligations ledger](../../design/task-unit-semantics-obligations.md) says where the boundary must
not move - no second task body, no dedicated tool, no dedicated channel - but it does not say what the
program's answer is. Neither does the board's correctness line - reviewable finalize, authentic
content, scope isolation, capability addressing ([board governance and capability
addressing](../implemented/2026-09-06-board-governance-addressing.md)) - which governs how an entry is
treated once it exists, not who decides what happens next.

## Proposal

Three owners, three kinds of statement. The program's share is exactly one: **it answers legality**.

- **The protocol owns** what the primitives are (already named; this record does not restate them), the
definition and criteria of legality, which constraints exist **by name**, and the two structural
rules that are what "legal" means here and therefore cannot be negotiated: a claim (lease plus
attempt fence) is the only arbiter of who holds a unit, and a deliverer never judges its own
delivery.
- **The accompanying program owns** computing the ordered legal set from the declared plan and current
facts, cutting it to the declared slot budget, and stating for each unit why it is legal or not -
reusing the reasons it already refuses claims with. It chooses nobody, adopts nothing, judges
nothing, and wakes nobody.
- **Agents own the arrangement**: the plan's content, the order they agree on, who takes which unit,
who adopts a run, who judges. All of it lands as board facts, so the arrangement is readable and
auditable instead of being implied by a program's choice.

**Constraints are named by the protocol and enabled by the plan.** A preference such as repair-first is
neither a program policy nor merely advice: it is a named constraint that a plan - or the adopter
declaring it - enables, and the legality answer is computed under the enabled set. A constraint that is
not enabled must be genuinely absent from the answer, or the declaration is decoration.

**Legality is asked, not published.** It is a function of current facts, so a query returns the ordered
legal set with a reason per unit, changing no state. A published handoff on the board is a different
thing: by the board's own protocol it occupies a serial slot. Fusing the two into one action would make
asking a question consume a slot.

## Plan

1. This record: write down the division of labour and the standing of constraints. No product surface.
2. Make legality readable through an existing board action (the action name lives in the shared
tool contract, its description is generated from the prompt source), keeping the read free of state.
3. Promote the preferences that currently live as shared planning policy - repair-first first - into
named constraints a plan declares, which is what makes the "disabled means absent" criterion true.

Each step is verifiable on its own; none of them requires a driver, a wake, or a new tool.

## Alternatives considered

- **Let the program select the next unit** (today's `next()` used as policy). Rejected: a bound is not a
decision - it does not choose among legal successors - and a program that picks the person is a
scheduler again, which the naming decision records as the word this model deliberately did not become.
- **Let the program store state only, with agents asserting their own legality.** Rejected: "legal"
would then be whatever the loudest caller says, and the two structural rules (one holder per claim,
no self-judging) would lose their home.
- **Demote preferences to advice.** Rejected: the arms compare runs on the premise that the same plan
plus the same facts yield the same answer, and advice that may be ignored removes exactly that.
- **Publish the legal set as a board offering** (make the query a handoff). Rejected: it makes a
question consume a serial slot, and withdrawing an offer is the board protocol's business.
- **Give the arrangement its own tool or channel.** Rejected: the ledger forbids it; the primitives must
take effect through an ordinary handoff.
- **Keep repair-first as hidden program policy.** Rejected: same class as a program that picks the
person.

## Acceptance criteria

- The answer is per unit, ordered, and carries the reason for each legal or refused unit.
- The answer names no person, no adopter and no judge.
- Asking changes no state: no entry, no claim, no slot, no wake. Asking twice on an unchanged store
returns the same answer, and the store's version is unchanged.
- A constraint that is not enabled has no effect on the answer: enabling and disabling the same named
constraint on one plan yields different legal sets.
- The declared slot budget still cuts the answer, and declaring more than one slot still requires a
declared handoff target.
- The two structural rules hold in the answer's own terms: a second claim on a held unit is refused,
and a deliverer cannot judge.
- Who adopted, who claimed and who judged exist only as board facts; no program field asserts them.
- The ledger's prohibition still holds: no new tool, no dedicated channel, no second task body.

## Risks

- With no constraint declared, a wide legal set can read as permission to fan out, recreating the
"no discussion, everything at once" problem somewhere new. Mitigation: the slot budget stays a
declared cap rather than a social one.
- Repair-first currently lives as policy inside the shared planning code, so making it a declared
constraint is a change rather than a rename; until it moves, the answer is computed under an implicit
constraint, which is the one place this proposal is not yet true.
- Reasons become an interface: a caller that parses a reason string creates a second source of truth.
Reasons are for people and logs; any caller decision must go through a claim or an explicit field.
- With agents free to choose order, the arms' equal-instrument premise depends on each run recording
which constraints were enabled; an unrecorded set makes two runs incomparable.
- A query invites polling. Polling is cheap, and a caller that asks and then acts can still race - the
claim remains the arbiter, which is the point.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 程序只回答合法性,别的都不管

[English](2026-09-20-the-program-answers-legality.md)

**Status:** proposed
**Relates to:** [机制,不是策略](2026-09-21-mechanism-not-policy.zh-CN.md)、[黑板治理与能力寻址](../implemented/2026-09-06-board-governance-addressing.zh-CN.md)、[给协作协议及其任务单元子协议命名](../implemented/2026-09-20-name-the-collaboration-protocol.zh-CN.md)、[任务单元语义](../../design/task-unit-semantics.md)、[契约的义务](../../design/task-unit-semantics-obligations.md)

## 问题

元语有了名字,共享层也已经能算出合法集合:有序合法集合、按声明槽位预算切一刀、认领被拒时给出每个单元的理由。但**没有任何一份文档在说"每一步是谁的决定"**。这个缺口有可观察的代价:任何想驱动一次 run 的调用者都得自己重新划一遍边界,于是两种已被记录在案的失败模式都回来了——程序顺手替 agent 挑人(也就是命名决策里记为"这个模型刻意没有变成"的那个词:调度器),以及 agent 不知道程序到底给什么保证,只能用唯一能用的说法去要它真正需要的东西:更多槽位、更怪的顺序。

[义务台账](../../design/task-unit-semantics-obligations.md)写明了边界不能往哪边动——不得再有第二个任务体、专用工具、专用频道——但它没有回答程序给出的那个答案是什么。黑板那条正确性线也没有:可复核的终结、内容真实性、作用域隔离、能力寻址([黑板治理与能力寻址](../implemented/2026-09-06-board-governance-addressing.zh-CN.md))管的是一个条目在存在之后怎么被对待,不是下一步该由谁定。

## 提案

三个所有者,三类陈述。程序那一份只有一条:**它回答合法性**。

- **协议拥有**:元语是什么(已被命名,本文不复述)、合法性的定义与判据、**约束的具名清单**(有哪些约束可以启用),以及两条结构性规则——它们就是"合法"在这里的含义,因此不可协商:认领(租约 + attempt 围栏)是"谁持有某个单元"的唯一仲裁;交付者不裁决自己的交付。
- **配套程序拥有**:按声明的计划与当下事实算出有序合法集合,按声明的槽位预算切一刀,并给出每个单元合法或不合法的理由——复用它现在拒绝认领时用的那些理由。它不挑人、不收编、不裁决、不唤醒。
- **agent 拥有安排本身**:计划的内容、他们商定的顺序、谁拿哪个单元、谁收编一次 run、谁裁决。所有这些都落成黑板上的事实,所以安排是可读、可审的,而不是由程序的选择暗示出来的。

**约束由协议具名、由计划启用。** 修复优先这类偏好既不是程序策略,也不只是建议:它是一条具名约束,由计划(或声明它的收编者)启用,合法性答案在启用集合下计算。没被启用的约束必须在答案里真的不生效,否则这个声明就是装饰。

**合法性被问,不被发布。** 它是当下事实的函数,所以查询返回有序合法集合,外加每个单元的理由,且不改变任何状态。黑板上发布出去的 handoff 是另一回事:按黑板自己的协议,它占用一个串行槽位。把两个合成一个动作,就会让"问一句"花掉一个槽位。

## 计划

1. 本文:写下分工与约束的地位。不动产品面。
2. 让合法性可读——走已有的黑板动作(动作名在共享 tool contract 里,描述由 prompt 源生成),并保持这次读取不写状态。
3. 把目前活在共享规划策略里的偏好,首先是修复优先,提升为由计划声明的具名约束;这一步才让"未启用即不生效"这条验收标准成立。

每一步都能单独验证;没有一步需要驱动器、唤醒或新工具。

## 考虑过的替代方案

- **让程序挑下一个单元**(把现在的 `next()` 当策略用)。拒绝:一个上限不是决定——它不在合法后继里做选择;而挑人的程序就是调度器,命名决策已把"调度器"记为这个模型刻意没有变成的词。
- **程序只存状态,合法性由 agent 各自声明**。拒绝:那么"合法"就是嗓门最大的调用者说的;两条结构性规则(一个认领只有一个持有者、交付者不能自裁)会失去家。
- **把偏好降成建议**。拒绝:arm 之间的可比性建立在"同一计划加同样事实给出同一答案"上,而可被无视的建议恰好拿走这一点。
- **把合法集合作为黑板 offer 发布**(把查询做成 handoff)。拒绝:这样"问一句"要占串行槽位,而且撤回 offer 是黑板协议的事。
- **为这套安排新开工具或频道**。拒绝:台账已禁;元语必须经普通交接生效。
- **保留修复优先作为隐藏的程序策略**。拒绝:与"程序挑人"同属一类。

## 验收标准

- 答案按单元给出、有序,并带每个单元合法或不合格的理由。
- 答案里不出现人、收编者或裁决者。
- 问不改变状态:不产生条目、认领、槽位或唤醒。在未变更的 store 上问两次得到同一答案,且版本未变。
- 未启用的约束对答案没有影响:同一计划上启用与停用同一条具名约束,合法集合不同。
- 声明的槽位预算仍然切这个答案;声明多于一个槽位仍然要求声明 handoff 目标。
- 两条结构性规则在答案自身的术语里成立:对已持有的单元再次认领被拒;交付者不能裁决。
- 谁收编、谁认领、谁裁决只作为黑板事实存在,没有任何程序字段声称它们。
- 台账的禁令仍然成立:没有新工具、没有专用频道、没有第二个任务体。

## 风险

- 一条约束都不声明时,宽的合法集合可能被读成"可以一起上"的许可,把"没商量同时并行"的问题换个地方重演。缓解:槽位预算仍是声明的上限,而不是社会约定。
- 修复优先现在住在共享规划代码里当策略,所以把它变成声明式约束是一次改动,不只是改名;搬完之前,答案是在一条隐含约束下算出来的——这是本提案唯一还不成立的地方。
- 理由一旦成为接口,解析理由字符串的调用者就造出了第二个事实源。理由是给人看和进日志的;调用者的任何决定都必须走认领或一个显式字段。
- agent 自由选序之后,arm 的 equal-instrument 前提取决于每次 run 记录了自己启用了哪些约束;没记录约束集合,两次 run 就不可比。
- 查询会招来轮询。轮询便宜,而"先问后动"仍可能撞车——认领仍是仲裁者,这正是设计意图。
Loading
Loading