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
Original file line number Diff line number Diff line change
Expand Up @@ -3173,6 +3173,15 @@ soak, release, merge and live promotion retain their respective authorization.
| I. Binding and qualification integration | After C and the selected profile's qualification | Bind one exact provider lineage, field manifest, source revision, digest, and cursor; qualify explicit v0 import, ordering/archival/consumer parity, and recovery/capacity without consulting legacy state for missing fields. | Long-goal local integration requires L and does not wait for P. PostgreSQL joins only when its own P holds pass. |
| F. Promotion and cleanup | After I and explicit maintainer approval | Complete provider-first CLI routing, the lock-owning promotion orchestrator, compatibility projection outbox, post-promotion fenced export/rollback, then delete duplicate reference aggregates and flip the reviewed stage/hold declarations. | Each profile must pass C, I, and its own provider qualification; long-goal local promotion additionally requires L, and PostgreSQL requires P. |

Agent-addressed read checkpoint: Todo list filtering now joins the typed summary
batch and shares User gate/action and Agent claim addressing with quota. The
Python list predicate is retired on both legacy and canonical consumers; full
source resume/succession and post-filter counts survive display limits. This is
one L5 consumer closure, not D1 projection freshness or provider promotion. See
[read semantics](../../reference/todo-work-counts.md). Remaining caller/executor,
consumer recovery, contributor D2, capture/whole-Goal and default onboarding
boundaries retain the conditional **5–8 cohesive PR** estimate.

## Appendix D: Execution ledger

Delivery records for this RFC are files under
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2496,6 +2496,13 @@ adapter,也不依赖 PostgreSQL service 部署。
| I. Binding 与资格集成 | C 与选定 profile 的资格化完成后 | 绑定一个精确 provider lineage、field manifest、source revision、digest 与 cursor;资格化显式 v0 import、排序/归档/consumer parity 与 recovery/capacity;缺字段时不得查询 legacy state 补齐。 | 长程本地集成需要 L,不等待 P;PostgreSQL 仅在自己的 P hold 全通过后汇合。 |
| F. Promotion 与清理 | I 完成且 maintainer 显式批准后 | 完成 provider-first CLI routing、持锁 promotion orchestrator、兼容投影 outbox、晋升后 fenced export/rollback;随后删除重复 reference aggregate,并翻转经评审的 stage/hold 声明。 | 每个 profile 必须通过 C、I 与自身 provider 资格化;长程本地晋升还需 L,PostgreSQL 还需 P。 |

Agent 定向读取检查点:Todo list 筛选已进入现有 TS summary 批次,与 quota 共用
User gate/action 及 Agent claim 范围规则;legacy 和 canonical 消费者中的 Python
列表谓词已删除。完整来源上的 resume/succession 与筛选后的计数不受展示上限影响。
这只闭合 L5 的一个消费者,不代表 D1 永久新鲜度或 provider 晋升。见[读取合同](../../reference/todo-work-counts.md)。
剩余 caller/executor、consumer recovery、contributor D2、capture/整 Goal 演练和默认
onboarding 仍按 **5–8 个完整 PR** 条件估计,不能按本次修复机械递减。

## 附录 D:执行账本

本 RFC 的交付记录是 [`ledger/shared-goal-authority-state-provider-v0/`](ledger/shared-goal-authority-state-provider-v0/) 下的文件,
Expand Down
14 changes: 14 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -1741,3 +1741,17 @@ behavior.
Measured delivery records live in the [per-entry ledger](ledger/typescript-control-plane-migration-v0/).
Each entry names its delivered boundary and remaining acceptance gaps; the T1–T4
checkpoints above remain the current migration plan.

### T2 Agent-addressed read checkpoint

Todo list selection now composes with the existing typed summary-lanes batch.
The Python role/status/id/Agent predicates and independent User scope rule are
removed; legacy and promoted consumers share `todos/agent_scope.ts` with quota
and decision scope. Explicit gate scope retains precedence over execution claim,
while retained User claims now correctly restrict scoped list visibility.
Full-source resume/succession stays evaluated before selection; original array
ordinals survive filters and display limits. No extra selection runtime crossing,
new capability/provider, or Python storage migration is introduced. Python keeps
input normalization and rendering until their actual host consumers migrate.
See [the read contract](../../reference/todo-work-counts.md); broader L5/D1 and
local-default qualifications remain open.
35 changes: 35 additions & 0 deletions docs/reference/todo-work-counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,31 @@ retain their old undercount behavior; rollback does not require data migration.
T1/T2 caller closure, D1 projection recovery, D2 capacity/elapsed soak and D3
fenced whole-Goal cutover remain separate work.

## Agent-addressed reads

`todo list --agent-id` now composes selection in the same typed summary batch,
sharing scope rules with quota. For User gates, explicit `global_gate` wins,
then `blocks_agent`, then the retained `claimed_by` fallback. User actions use
`bound_agent` first and retained `claimed_by` second. Unscoped records remain
visible. Gate addressing is independent of executor exclusion: an Agent cannot
ignore an explicitly addressed human gate because another Agent owns it.
Agent work still filters by claim and exclusions. A visible row grants no
mutation or execution permission; quota retains its additional eligibility rules.

This intentionally removes other-Agent, claim-only User records from scoped
lists; the old Python list rule ignored their claim while quota honored it.
Unfiltered Goal views retain those records. There is no feature flag or provider
default change. Existing frontend/Lark manager views use the unfiltered Core
read and continue to show the whole Goal; no new configuration editor is needed.

Resume and succession are evaluated on the complete source before selection.
The typed batch filters rows without renumbering their original source indexes,
then builds lanes/counts, and only then applies display limits. Status/identity
filters do not recompute dependencies from their smaller view. The v1 internal
request composes this selection into the existing call; v0 unfiltered callers
retain their wire contract. Python decodes legacy input and renders results,
with no independent Agent-addressing rule.

## 中文说明

`work_counts` 由完整来源计算,随后才裁剪展示。Agent quota 先按原有归属、排除、
Expand All @@ -77,3 +102,13 @@ TS 统一批量 lane 分类与计数,Python 保留旧格式解码、时间适
这不改变 provider 默认值,不授予执行权限,不写回 Markdown 或 canonical 状态。
新增计数字段不进入持久化 Todo;回滚无需数据迁移。默认切换、存量迁移、D1–D3 和旧
Python writer 退出仍有各自的验收条件,不能按本 PR 合并数量推定完成。

Agent 定向列表现与 quota 共用 TS 范围规则:User gate 按 global_gate → blocks_agent →
旧 claimed_by 依次判定,User action 按 bound_agent → 旧 claimed_by 判定。无作用域的
旧记录仍可见;显式人类 gate 不会被执行者 claim/exclusion 消除。Agent 工作仍按
claim/exclusion 筛选,可见不代表获准执行。

这是有意纠正:旧列表忽略仅声明 claimed_by 的 User 记录,导致其他 Agent 的工作混入
当前列表。未筛选的整 Goal 视图仍显示这些记录。依赖和 succession 先在完整来源求值,
TS 再筛选并保留原数组位置,最后生成 lanes、计数和有界展示;筛选后的数组位置不是原
来源位置。无需新增 capability、配置、前端或 Lark 编辑入口,不增加一次筛选 RPC。
27 changes: 27 additions & 0 deletions loopx/control_plane/todos/agent_scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** Shared read addressing; visibility never grants mutation or execution. */
export interface GateScope {
global: boolean;
blocks: string | null;
claim: string | null;
}

export function gateAddressesAgent(gate: GateScope, agent: string | null): boolean {
if (gate.global) return true;
if (gate.blocks) return gate.blocks === agent;
return !gate.claim || gate.claim === agent;
}


export interface ActionScope {bound: string | null; claim: string | null}
export interface ClaimScope {claim: string | null; excluded: readonly string[]}

/** Explicit action binding wins; retained unbound actions use their claim. */
export function actionAddressesAgent(action: ActionScope, agent: string | null): boolean {
const bound = action.bound ?? action.claim;
return !agent || !bound || bound === agent;
}

/** Claim/exclusion address Agent work, not permission to disregard User gates. */
export function claimAllowsAgent(work: ClaimScope, agent: string | null): boolean {
return !agent || (!work.excluded.includes(agent) && (!work.claim || work.claim === agent));
}
2 changes: 1 addition & 1 deletion loopx/control_plane/todos/decision_scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* A consistent dependency is not approval, a lease, or a mutation receipt. */
import type {JsonObject} from "../effect_program.ts";
import {requireJsonObject, optionalNonEmptyString, requireBoolean, requireInteger} from "../runtime_decode.ts";
import {gateAddressesAgent} from "./gate_scope.ts";
import {gateAddressesAgent} from "./agent_scope.ts";
import {
TODO_DECISION_SCOPE_GRANULARITY_SET,
TODO_DECISION_SCOPE_KEY_PATTERN,
Expand Down
12 changes: 0 additions & 12 deletions loopx/control_plane/todos/gate_scope.ts

This file was deleted.

51 changes: 5 additions & 46 deletions loopx/control_plane/todos/goal_todo_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,11 @@
from .succession_warning import public_todo_summary
from .contract import (
build_todo_id,
normalize_todo_blocks_agent,
normalize_todo_bound_agent,
normalize_todo_claimed_by,
normalize_todo_excluded_agents,
normalize_todo_id,
normalize_todo_status,
)
from .todo_summary import compact_evaluated_todo_group, compact_todo_group, todo_item_status
from .todo_summary import compact_evaluated_todo_group, compact_todo_group


def empty_todo_summary(*, role: str) -> dict[str, Any]:
Expand All @@ -40,17 +37,6 @@ def empty_todo_summary(*, role: str) -> dict[str, Any]:
"first_open_items": [],
}

def _user_todo_visible_to_agent(item: dict[str, Any], agent_id: str) -> bool:
if bool(item.get("global_gate")):
return True
blocks_agent = normalize_todo_blocks_agent(item.get("blocks_agent"))
if blocks_agent:
return blocks_agent == agent_id
bound_agent = normalize_todo_bound_agent(item.get("bound_agent"))
if bound_agent:
return bound_agent == agent_id
return True

def filtered_todo_summary(
summary: dict[str, Any] | None,
*,
Expand All @@ -61,44 +47,17 @@ def filtered_todo_summary(
item_limit: int | None = None,
) -> dict[str, Any]:
items = list((summary or {}).get("items") or [])
normalized_status = normalize_todo_status(status)
if normalized_status:
items = [item for item in items if todo_item_status(item) == normalized_status]
normalized_todo_id = normalize_todo_id(todo_id) if todo_id else None
if normalized_todo_id:
items = [
item
for item in items
if normalize_todo_id(item.get("todo_id")) == normalized_todo_id
]
normalized_agent_id = normalize_todo_claimed_by(agent_id) if agent_id else None
if normalized_agent_id:
if role == "agent":
items = [
item
for item in items
if normalized_agent_id
not in normalize_todo_excluded_agents(item.get("excluded_agents"))
and (
not normalize_todo_claimed_by(item.get("claimed_by"))
or normalize_todo_claimed_by(item.get("claimed_by"))
== normalized_agent_id
)
]
elif role == "user":
items = [
item
for item in items
if _user_todo_visible_to_agent(item, normalized_agent_id)
]
selection = {"role": role, "status": normalize_todo_status(status),
"todo_id": normalize_todo_id(todo_id) if todo_id else None,
"agent_id": normalize_todo_claimed_by(agent_id) if agent_id else None}
source_section = str((summary or {}).get("source_section") or TODO_SECTION_HEADINGS[role])
return (
compact_evaluated_todo_group(
items,
source_section=source_section,
role=role,
item_limit=item_limit,
full_selection=not (normalized_status or normalized_todo_id or normalized_agent_id),
selection=selection,
)
or empty_todo_summary(role=role)
)
Expand Down
14 changes: 5 additions & 9 deletions loopx/control_plane/todos/quota_selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts";
import { requireJsonObject, requireBoolean, requireInteger, requireStringArray,
optionalNonEmptyString } from "../runtime_decode.ts";
import { projectTodoResumePlanning } from "./resume_planning.ts";
import { gateAddressesAgent } from "./gate_scope.ts";
import { gateAddressesAgent, actionAddressesAgent, claimAllowsAgent } from "./agent_scope.ts";
import { missingRequiredCapabilities } from "../agents/capability_gate.ts";

interface Row {
Expand Down Expand Up @@ -44,12 +44,8 @@ const bucket = (row: Row, agent: string) => row.claim === agent ? 0 : row.claim
function gateApplies(row: Row, agent: string | null): boolean {
return !agent || gateAddressesAgent(row, agent);
}
function actionApplies(row: Row, agent: string | null): boolean {
const bound = row.bound ?? row.claim;
return !agent || !bound || bound === agent;
}
function executableBy(row: Row, agent: string | null): boolean {
return !agent || (!row.removed && !row.excluded.includes(agent) && bucket(row, agent) !== 2);
return !agent || (!row.removed && claimAllowsAgent(row, agent));
}

/** Presentation-only claimant coverage; never changes eligible work or counts. */
Expand Down Expand Up @@ -138,8 +134,8 @@ export function projectQuotaSelection(value: unknown): JsonObject {
const gates = userMode ? source.filter(row => row.gate) : source;
const blocking = userMode ? gates.filter(row => gateApplies(row, agent)) : gates;
const otherGates = userMode ? gates.filter(row => !gateApplies(row, agent)) : [];
const actions = userMode ? source.filter(row => !row.gate && actionApplies(row, agent)) : [];
const otherActions = userMode ? source.filter(row => !row.gate && !actionApplies(row, agent)) : [];
const actions = userMode ? source.filter(row => !row.gate && actionAddressesAgent(row, agent)) : [];
const otherActions = userMode ? source.filter(row => !row.gate && !actionAddressesAgent(row, agent)) : [];
// Explicit User gate scope has already decided blocking. Claim/exclusion
// governs Agent execution, not permission to disregard that human gate.
const open = userMode ? blocking : blocking.filter(row => executableBy(row, agent));
Expand All @@ -148,7 +144,7 @@ export function projectQuotaSelection(value: unknown): JsonObject {
const scope = agent && !userMode ? claimScope(blocking, open, agent, profile, diagnostic) : null;
const monitors = open.filter(row => row.actionable && row.taskClass === "continuous_monitor");
const due = supported ? monitors.filter(row => row.due && executableBy(row, agent)) : [];
const activeVisible = (row: Row) => userMode ? (row.gate ? gateApplies(row, agent) : actionApplies(row, agent)) : executableBy(row, agent);
const activeVisible = (row: Row) => userMode ? (row.gate ? gateApplies(row, agent) : actionAddressesAgent(row, agent)) : executableBy(row, agent);
const gateFilter = otherGates.length ? {
schema_version: "agent_scoped_user_gate_filter_v0", agent_id: agent,
policy: "user todos scoped to another agent by blocks_agent or claimed_by remain visible but do not block this agent's quota lane",
Expand Down
34 changes: 31 additions & 3 deletions loopx/control_plane/todos/summary_lanes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** Read-only lane selection over one evaluated source, before display limits. */
import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts";
import type {JsonObject} from "../effect_program.ts";
import {requireBoolean, requireJsonObject, requireStringLiteral} from "../runtime_decode.ts";
import {requireBoolean, requireJsonObject, requireStringLiteral, optionalNonEmptyString, requireStringArray} from "../runtime_decode.ts";
import {gateAddressesAgent, actionAddressesAgent, claimAllowsAgent} from "./agent_scope.ts";
import {authorityUnicodeCompare} from "../coordination/authority_store_codec.ts";

export const TODO_SUMMARY_LANES = [
Expand Down Expand Up @@ -68,12 +69,37 @@ export function countTodoWork(rows: readonly WorkCountRow[], sourceOpenCount: nu
complete: complete && sourceOpenCount === rows.length, agent_id: agentId};
}

/** Filter already evaluated full-source rows. Keep their original ordinals:
* consumers must not confuse a selected position with a source identity. */
function selectRows(rows: readonly Row[], source: readonly unknown[], value: unknown): {rows: Row[]; full: boolean} {
const selection = requireJsonObject(value, "Todo read selection");
const role = requireStringLiteral(selection.role, ["user", "agent"], "selection role");
const status = selection.status == null ? null :
requireStringLiteral(selection.status, ["open", "blocked", "done", "deferred"], "selection status");
const todo = optionalNonEmptyString(selection.todo_id, "selection todo_id");
const agent = optionalNonEmptyString(selection.agent_id, "selection agent_id");
const addressed = rows.filter(row => {
const raw = requireJsonObject(source[row.ordinal], "Todo source row");
const optional = (key: string) => optionalNonEmptyString(raw[key], key);
const id = optional("todo_id");
const scope = {claim: optional("claim"), bound: optional("bound"), blocks: optional("blocks"),
global: requireBoolean(raw.global, "global"), excluded: requireStringArray(raw.excluded, "excluded")};
const visible = !agent || (role === "agent" ? claimAllowsAgent(scope, agent) :
row.taskClass === "user_gate" ? gateAddressesAgent(scope, agent) : actionAddressesAgent(scope, agent));
return (!status || row.status === status) && (!todo || id === todo) && visible;
});
return {rows: addressed, full: !status && !todo && !agent};
}

export function projectTodoSummaryLanes(value: unknown): JsonObject {
const request = requireJsonObject(value, "Todo summary lane request");
if (request.schema_version !== "todo_summary_lanes_request_v0" || !Array.isArray(request.rows)) {
if (!["todo_summary_lanes_request_v0", "todo_summary_lanes_request_v1"].includes(String(request.schema_version)) || !Array.isArray(request.rows)) {
throw new EffectRuntimeRequestError("Todo summary lane request schema mismatch");
}
const rows = request.rows.map(decodeRow), now = finite(request.observed_at, "observed_at");
const decoded = request.rows.map(decodeRow), now = finite(request.observed_at, "observed_at");
const selection = request.schema_version === "todo_summary_lanes_request_v1"
? selectRows(decoded, request.rows, request.selection) : {rows: decoded, full: true};
const rows = selection.rows;
const open = rows.filter(row => !row.done), terminal = rows.filter(row => row.done);
const deferred = terminal.filter(row => row.status === "deferred"), done = terminal.filter(row => row.status !== "deferred");
const ordered = [...open].sort(compare), orderedDeferred = [...deferred].sort(compare);
Expand All @@ -97,6 +123,8 @@ export function projectTodoSummaryLanes(value: unknown): JsonObject {
} satisfies Record<TodoSummaryLane, readonly Row[]>;
const lanes = Object.fromEntries(TODO_SUMMARY_LANES.map(key => [key, selected[key].map(row => row.ordinal)]));
return {schema_version: "todo_summary_lanes_v0", lanes,
...(request.schema_version === "todo_summary_lanes_request_v1" ? {
source_indices: rows.map(row => row.ordinal), full_selection: selection.full} : {}),
work_counts: countTodoWork(open, open.length, true)};
}

Expand Down
Loading
Loading