Skip to content
Merged
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 @@ -138,6 +138,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
const lastAuthoritativeSubagentConfigurationRef = useRef<WorkspaceGoalSubagentConfiguration | null>(null);
const verifiedSubagentBaselineRef = useRef<WorkspaceGoalSubagentConfiguration | null>(null);
const [todoAgentId, setTodoAgentId] = useState(agents.find((agent) => agent.available)?.agentId ?? "codex");
const [todoPriority, setTodoPriority] = useState("");
const [todoResumeWhen, setTodoResumeWhen] = useState("");
const closeRef = useRef<HTMLButtonElement>(null);
const drawerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -607,6 +608,21 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
summary: t("drawer.reassignSummary", { task: selection.item.text }),
})} type="button">{t("timeline.review")}</button>
</label>
<label className="personal-inline-agent-select">{t("drawer.taskPriority")}
<select aria-label={t("drawer.taskPriority")} value={todoPriority} onChange={(event) => setTodoPriority(event.target.value)}>
<option value="">{t("drawer.taskPriorityChoose")}</option>
{["P0", "P1", "P2", "P3", "P4"].map((priority) => <option key={priority} value={priority}>{priority}</option>)}
<option value="clear">{t("drawer.taskPriorityClear")}</option>
</select>
<button className="personal-secondary-action" disabled={!todoPriority} onClick={() => void callbacks.onPreviewAction?.({
actionKind: "todo.update",
context: {goal_id: selection.item.goalId, kind: "todo", todo_id: selection.item.todoId},
idempotencyKey: `workspace-todo-${selection.item.todoId}-priority-${todoPriority}-${Date.now().toString(36)}`,
normalizedParameters: {goal_id: selection.item.goalId, todo_id: selection.item.todoId, operation: "edit",
...(todoPriority === "clear" ? {clear_priority: true} : {priority: todoPriority})},
summary: `${t("drawer.taskPriority")}: ${todoPriority === "clear" ? t("drawer.taskPriorityClear") : todoPriority}`,
})} type="button">{t("timeline.review")}</button>
</label>
<strong>{t("drawer.taskDeferUntil")}</strong>
<label className="personal-inline-agent-select personal-inline-resume-when">{t("drawer.taskDeferUntil")}
<input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export function GoalTasksView({
.filter((todo) => todo.goalId === goal.goalId)
.map((todo) => ({ ...todo, goalTitle: goal.title }));
const priorityRank = (todo: WorkspaceGoal["agentTodos"][number]) =>
todo.priority === "P0" ? 0 : todo.priority === "P1" ? 1 : todo.priority === "P2" ? 2 : 3;
todo.priority === "P0" ? 0 : todo.priority === "P1" ? 1 : todo.priority === "P2" ? 2 : todo.priority === "P3" ? 3 : todo.priority === "P4" ? 4 : 50;
const agentLanes = useMemo(() => {
const lanes = new Map((goal.agentLanes ?? []).map((lane) => [lane.agentId, lane]));
for (const todo of goal.agentTodos) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,9 @@ const en = {
"drawer.taskDeferPlaceholder": "For example, resume_at:2026-09-14T09:00:00+08:00",
"drawer.taskDeferReview": "Review defer",
"drawer.taskDeferSupported": "Supports todo_done, pr_merged, capacity_available, and timezone-aware resume_at conditions.",
"drawer.taskPriority": "Priority",
"drawer.taskPriorityChoose": "Choose priority",
"drawer.taskPriorityClear": "No priority",
"drawer.taskDeferUntil": "Defer until",
"drawer.taskDetails": "Todo details",
"drawer.taskInfo": "Task information",
Expand Down Expand Up @@ -1432,6 +1435,9 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"drawer.taskDeferPlaceholder": "例如 resume_at:2026-09-14T09:00:00+08:00",
"drawer.taskDeferReview": "检查暂缓",
"drawer.taskDeferSupported": "支持 todo_done、pr_merged、capacity_available 与带时区的 resume_at 条件。",
"drawer.taskPriority": "优先级",
"drawer.taskPriorityChoose": "选择优先级",
"drawer.taskPriorityClear": "不设优先级",
"drawer.taskDeferUntil": "暂缓至",
"drawer.taskDetails": "Todo 详情",
"drawer.taskInfo": "任务信息",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2759,6 +2759,15 @@ The typed TypeScript `projection_delivery` union also owns the distinction
between mutation intent (`pending`/`not_required`) and provider readback
(`delivered`/`current`); unknown states fail closed before acknowledgement.

Priority intent now follows the same admitted create/update transaction on File,
SQLite and PostgreSQL. Explicit set/clear, omission and conflicting legacy text
are resolved by `todos/priority.ts`; Python reads share the generated grammar.
Markdown remains compatible display, while native records retain matching
priority/title. CLI and reviewed Chat edits preserve CAS and historical retry
identity. Real backend readback and a disposable clone of a long-lived local
Goal qualify this bounded change. See the [caller contract](../../project-agent-todo-contract.md#priority-intent).
This does not change provider defaults or close the remaining promotion gates.

Presentation is canonical at the projection layer, not in the domain record.
`source_section` and `index` are the v0 wire shape's display coordinates, while
native records derive the same display section from role/archive state and use
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2188,6 +2188,13 @@ Python 生产 caller 直接从 `todos/todo_semantics.py` 导入;`todos/project
mutation intent(`pending`/`not_required`)与 provider readback(`delivered`/`current`);
未知状态在 acknowledgement 之前 fail closed。

优先级意图现接入 File、SQLite、PostgreSQL 既有的准入 create/update 事务。
显式设置/清除、参数缺省及与旧文字前缀的冲突由 `todos/priority.ts` 处理,Python
读取共享生成的语法。Markdown 保留兼容展示,native record 保存一致的 priority/title。
CLI 与经过审阅的 Chat 编辑保留 CAS 和历史重试身份。真实后端回读及长期本地 Goal 的
一次性隔离副本验证这条边界,见[调用合同](../../project-agent-todo-contract.md#priority-intent)。
这不改变 provider 默认,也不关闭其余 promotion 门禁。

展示语义属于 projection 层,而不是 domain record。`source_section` 与 `index` 是 v0
wire shape 的展示坐标;native record 根据 role/archive state 推导相同的展示 section,
并以时间戳和 Todo identity 做确定性回退,不制造假的持久 index。因此即使 wire shape
Expand Down
15 changes: 13 additions & 2 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,23 @@ remaining lifecycle writers have migrated.

The same stage also removes duplicated Python read policy around that boundary.
Task-class resolution, title-aware actionability, dependency readiness, agent
eligibility, priority ordering, and canonical Todo read records now have one
Python semantic owner while TypeScript remains the transaction owner. The old
eligibility and canonical Todo read records have one Python semantic owner
while TypeScript remains the transaction owner. Priority authoring and ordering
now share `todos/priority.ts`; Python compatibility readers consume its generated
vocabulary and legacy grammar instead of maintaining independent patterns. The old
projection module is an import-only compatibility facade. This keeps the
replacement-first rule intact: compatibility remains available, but it cannot
silently become a second semantic implementation.

The priority-intent slice connects CLI add/update/clear, reviewed Chat edits and
the Dashboard selector to the existing typed Todo transaction. Text-only edits
preserve priority; conflicting declarations fail before writes. P3/P4 ordering,
legacy decorated labels and successor inheritance share the same owner. See the
[caller contract](../../project-agent-todo-contract.md#priority-intent). Real CLI
File/SQLite readback, isolated PostgreSQL and the shared complex fixture qualify
this boundary. It retires duplicate priority knowledge, not the remaining T1/T3
callers, Python compatibility IO or the D1–D3 default-cutover gates.

Native update now composes `todos/public_update.ts` for a bounded nonterminal
planning intent (status, evidence/reason, resume/clear and successor links),
against the same complete canonical head used for authority checks and CAS.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,19 @@ fixture 覆盖。这是一个已完成的交付阶段,不代表 Markdown 晋
lifecycle writer 已全部迁移。

同一阶段也删除了该边界周围重复的 Python read policy。task-class 解析、识别 title
的 actionable 判断、依赖就绪、Agent eligibility、priority 排序和 canonical Todo
read record 现在只有一个 Python 语义 owner,而 TypeScript 仍是事务 owner。旧
的 actionable 判断、依赖就绪、Agent eligibility 和 canonical Todo read record
只有一个 Python 语义 owner,TypeScript 仍是事务 owner。优先级写入意图与排序现共用
`todos/priority.ts`;Python 兼容读取使用生成的词表和旧格式语法,不再独立维护模式。旧
projection 模块只保留 import-only 兼容 facade。这样继续遵守 replacement-first:
兼容路径仍可用,但不能静默形成第二份语义实现。

优先级意图这一批将 CLI add/update/clear、经过审阅的 Chat 编辑和 Dashboard 选择器
接入既有 typed Todo 事务。仅改文字保留优先级,冲突声明在写入前拒绝。P3/P4 排序、
历史装饰标签和 successor 继承使用同一 owner。见
[调用合同](../../project-agent-todo-contract.md#priority-intent)。真实 CLI File/SQLite
回读、隔离 PostgreSQL 和共享复杂 fixture 验证这条边界。本批删除重复的优先级知识,
不代表其余 T1/T3 caller、Python 兼容 IO 或 D1–D3 默认切换门禁已经完成。

Native update 通过 `todos/public_update.ts` 组合非终态 planning intent,在同一份
完整 canonical head 上校验权限并 CAS。独立 intent 命名空间保留 text/note 限制
及旧回执指纹。v2 transport 携带 lifecycle grant、authority reason、registry 来源
Expand Down
39 changes: 39 additions & 0 deletions docs/project-agent-todo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,45 @@ continue it; when it needs to fan out, split, supersede, or create successor
work, it writes the new task through the LoopX todo lifecycle and lets the board
sync catch up.

## Priority intent

Priority is an explicit scheduling field, with `P0` highest and `P4` lowest.
Create or edit it independently of the task description:

```bash
loopx todo add --goal-id <goal-id> --role agent --priority P1 --text '<agent action>'
loopx todo update --goal-id <goal-id> --todo-id <todo-id> --priority P3
loopx todo update --goal-id <goal-id> --todo-id <todo-id> --clear-priority
```

Pass the registered `--agent-id` on updates when the Goal has multiple Agents.
The existing authoring, ownership, lease and review requirements still apply.
Chat `todo.create` accepts `priority`; reviewed `todo.update` edits accept
`priority` or `clear_priority`. The task management panel uses that reviewed
update path for its priority selector. Priority never grants eligibility,
capabilities, a claim or quota; missing priority is allowed and sorts after P4.
The generated agent authoring hint shows an explicit P1 example, not a global
default or a prerequisite for same-turn binding.

Omitting priority from an ordinary text edit preserves the current priority.
Use `--clear-priority` to remove it. Legacy `--text '[P2] Task'` remains supported;
a supplied prefix edits priority for old callers. An explicit parameter and a
conflicting prefix are rejected before writing, as are simultaneous set/clear
instructions. Words such as `P0` inside ordinary prose have no scheduling meaning.
Historical decorated prefixes such as `[P2-review]` read as P2; an edit renders
the normalized `[P2]` prefix. P3/P4 participate in ordering and repair suggestions
without being silently promoted to P1. Existing repair-suggestion and historical
event-replay defaults retain their own contracts; they do not impose a default
on new unprioritized Todos.

`todos/priority.ts` owns authoring intent and ordering. The generated coordination
contract supplies its vocabulary and legacy grammar to Python read adapters.
Markdown keeps the compatible `[Pn] description` display; native authority records
also carry canonical priority/title. File, SQLite and PostgreSQL updates use the
existing admitted head, CAS and operation receipt. A retry retains the original
intent identity and returns its historical result; changing priority under the
same operation ID is a conflict. No provider promotion or receipt rewrite occurs.

## Write Contract

For a caller-owned runtime that already registered its Goal/Agent, generate the
Expand Down
10 changes: 10 additions & 0 deletions examples/personal-workspace-browser/typed-actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1637,6 +1637,16 @@ export const typedActionsScenario = {
await taskRow.click();
taskManagement = page.locator("details.personal-task-management");
await taskManagement.locator("summary").click();
await page.getByLabel("优先级", {exact: true}).selectOption("P4");
await page.screenshot({path: resolve(outputDir, "todo-priority-edit.png"), fullPage: false, animations: "disabled"});
await taskManagement.locator("label", {has: page.getByLabel("优先级", {exact: true})}).getByRole("button").click();
await page.getByText("确认执行").waitFor({state: "visible"});
const priorityEdit = api.actionPreviews.findLast(preview => preview.action_kind === "todo.update" && preview.normalized_parameters.priority === "P4");
if (!priorityEdit || priorityEdit.normalized_parameters.text !== undefined) throw new Error("Priority edit must be structured, without a text rewrite");
await page.getByRole("button", {name: "关闭", exact: true}).click();
await taskRow.click();
taskManagement = page.locator("details.personal-task-management");
await taskManagement.locator("summary").click();
await page.getByLabel("Todo 暂缓恢复条件").fill("pr_merged:huangruiteng/loopx#3399");
await page.screenshot({ path: resolve(outputDir, "todo-defer-resume-condition.png"), fullPage: false, animations: "disabled" });
await taskManagement.locator(".personal-inline-resume-when").getByRole("button", { name: "检查暂缓" }).click();
Expand Down
1 change: 1 addition & 0 deletions examples/project/project-agent-adoption-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ def main() -> int:
assert approved_quota["todo_write_hint"]["agent_todo_command_template"].startswith(
f"loopx todo add --goal-id {GOAL_ID} --role agent "
), approved_quota
assert "--priority P1" in approved_quota["todo_write_hint"]["agent_todo_command_template"], approved_quota

packet = run_cli(root, registry_path, "review-packet", "--goal-id", GOAL_ID, "--scan-root", str(project))
assert packet["ok"] is True, packet
Expand Down
7 changes: 7 additions & 0 deletions loopx/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,13 +541,15 @@ def _add_review_todo(
registry_path: Path,
goal_id: str,
text: str,
priority: str | None = None,
dry_run: bool,
) -> dict[str, Any]:
return add_goal_todo(
registry_path=registry_path,
goal_id=goal_id,
role="agent",
text=_normalize_todo_text(text),
priority=priority,
task_class="advancement_task",
action_kind=CHAT_TODO_ACTION_KIND,
dry_run=dry_run,
Expand All @@ -559,11 +561,13 @@ def build_todo_review_preview(
registry_path: Path,
goal_id: str,
text: str,
priority: str | None = None,
) -> dict[str, Any]:
payload = _add_review_todo(
registry_path=registry_path,
goal_id=goal_id,
text=text,
priority=priority,
dry_run=True,
)
compact = _compact_todo_payload(payload, applied=False)
Expand All @@ -576,12 +580,14 @@ def apply_todo_review_preview(
registry_path: Path,
goal_id: str,
text: str,
priority: str | None = None,
preview_id: str,
) -> dict[str, Any]:
current_preview = _add_review_todo(
registry_path=registry_path,
goal_id=goal_id,
text=text,
priority=priority,
dry_run=True,
)
if not preview_id or preview_id != _todo_preview_fingerprint(current_preview):
Expand All @@ -596,6 +602,7 @@ def apply_todo_review_preview(
registry_path=registry_path,
goal_id=goal_id,
text=text,
priority=priority,
dry_run=False,
)
compact = _compact_todo_payload(applied, applied=True)
Expand Down
15 changes: 14 additions & 1 deletion loopx/chat_action_normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def _normalize(
allowed={
"goal_id",
"text",
"priority",
"agent_id",
"endpoint_id",
"start_execution",
Expand All @@ -229,6 +230,8 @@ def _normalize(
"goal_id": goal_id,
"text": _text(values.get("text"), field="text", limit=400),
}
if "priority" in values:
result["priority"] = values["priority"]
if values.get("endpoint_id"):
endpoint_id = _opaque(values.get("endpoint_id"), field="endpoint_id")
result["endpoint_id"] = endpoint_id
Expand Down Expand Up @@ -261,6 +264,8 @@ def _normalize(
"goal_id",
"todo_id",
"text",
"priority",
"clear_priority",
"status",
"note",
"agent_id",
Expand Down Expand Up @@ -301,6 +306,8 @@ def _normalize(
result["status"] = status
if values.get("note"):
result["note"] = _text(values["note"], field="note", limit=600)
if "priority" in values:
result["priority"] = values["priority"]
if values.get("endpoint_id"):
endpoint_id = _opaque(values["endpoint_id"], field="endpoint_id")
result["endpoint_id"] = endpoint_id
Expand All @@ -322,6 +329,12 @@ def _normalize(
if not isinstance(values["no_followup"], bool):
raise ValueError("no_followup must be true or false")
result["no_followup"] = values["no_followup"]
if "clear_priority" in values:
if not isinstance(values["clear_priority"], bool):
raise ValueError("clear_priority must be true or false")
result["clear_priority"] = values["clear_priority"]
if operation != "edit" and ("priority" in result or result.get("clear_priority")):
raise ValueError("priority changes require todo.update operation=edit")
required_by_operation = {
"reassign": "agent_id",
"defer": "resume_when",
Expand All @@ -333,7 +346,7 @@ def _normalize(
if operation == "block" and not result.get("note"):
raise ValueError("todo.update block requires note")
if operation == "edit" and len(result) == 3:
raise ValueError("todo.update requires text, status, or note")
raise ValueError("todo.update requires text, priority, status, or note")
return result
if action_kind == "run.correct":
values = self._allowed_parameters(
Expand Down
Loading
Loading