diff --git a/README.en.md b/README.en.md index 8540752..9799dca 100644 --- a/README.en.md +++ b/README.en.md @@ -24,6 +24,8 @@ QuietHarness does not include a task database, background automation, or a team If you are exploring how multiple long-lived owners/workers can share canonical state, use semantic handoffs, reject false completion, and continue the same task after worker failover, see the experimental [OPC Company Layer](labs/opc-company/README.en.md). It is a governance lab in this same repository, not a QuietHarness installation requirement. +If your agents can already keep working but keep interrupting you for the wrong reasons, see the experimental [Attention Inbox Lab](labs/attention-inbox/README.md). It keeps routine completions and internal waiting states quiet, and surfaces only concrete human decisions or meaningful risks. It is a derived attention layer over existing task/receipt state, not another queue or scheduler. + ## Try it inside one isolated project ```bash diff --git a/README.md b/README.md index 1268d0c..28678c1 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ QuietHarness 不会给每个请求套一层仪式,也不是要求你复制我 如果你关心多个长期 Owner / Worker 如何共享 canonical state、做 semantic handoff、拒绝假完成并在换 worker 后继续同一任务,可以看实验性的 [OPC Company Layer](labs/opc-company/README.md)。它是同一仓库里的治理实验层,不是 QuietHarness 的安装前提。 +如果你的 Agent 已经能持续工作,但经常因为错误的 `waiting_for_user`、常规完成或重复状态打断你,可以看实验性的 [Attention Inbox Lab](labs/attention-inbox/README.md)。它只把真正的人工决策和有意义的风险送到注意力入口;任务事实、调度和执行仍留在现有系统里,不另造一套队列或控制面。 + ## 先在一个隔离项目试用 ```bash @@ -273,6 +275,7 @@ templates/ # 当前三端小 Core docs/ # 我的系统架构、任务、收尾与日报边界 examples/first-success/ # 单客户端隔离试用与行为验收 examples/leo-system/ # 脱敏的真实结构示例 +labs/attention-inbox/ # 实验性的 Agent 注意力协议与策略 scripts/ # 盘点、安装、验证 tests/ # 隔离 HOME 冒烟测试 media/launch-v3/ # 系统地图与发布素材 diff --git a/labs/attention-inbox/PRODUCT.md b/labs/attention-inbox/PRODUCT.md new file mode 100644 index 0000000..4851279 --- /dev/null +++ b/labs/attention-inbox/PRODUCT.md @@ -0,0 +1,59 @@ +# Product wedge: One Inbox for Agent Attention + +## Positioning + +**Your agents work everywhere. You only need one inbox.** + +The wedge is not another multi-agent framework. It is the boundary between autonomous work and human attention. + +## User promise + +Agents continue routine work silently. The user is interrupted only when: + +1. a concrete human decision is required; or +2. a meaningful risk needs awareness. + +Everything else is suppressed or left in history. + +## V0 scope + +- a portable event contract +- deterministic escalation +- evidence-aware rendering +- duplicate suppression +- adapters for existing agent/task systems + +Not V0: + +- another canonical task database +- another scheduler +- another orchestration engine + +## Public-core boundary + +The public layer can include: + +- protocol/schema +- local reference logic +- adapters +- basic inbox policy +- synthetic fixtures +- conformance tests + +Future product work can focus on reliable delivery, cross-device history, team routing, and observability without changing the event contract. + +## Activation metric + +Do not optimize for GitHub stars first. + +A useful pilot user should satisfy all three in one week: + +- connects at least 2 agents/workers; +- receives at least 5 meaningful inbox events; +- completes at least 3 decisions from phone without opening the laptop. + +## Differentiation + +Coding-agent workspaces optimize how agents work together. + +Attention Inbox optimizes when a human must re-enter the loop. diff --git a/labs/attention-inbox/README.md b/labs/attention-inbox/README.md new file mode 100644 index 0000000..2be9a93 --- /dev/null +++ b/labs/attention-inbox/README.md @@ -0,0 +1,122 @@ +# Attention Inbox Lab + +A small, deterministic human-attention layer for AI agents. + +The problem is not that agents cannot keep working. The problem is that humans get interrupted by the wrong things: routine completions, internal `waiting_for_user` flags, stale status changes, or duplicate alerts. Meanwhile real decisions and real risks can be buried. + +Attention Inbox turns agent/task events into one of three outcomes: + +```text +SUPPRESS +RISK_ALERT +ACTION_REQUIRED +``` + +It is deliberately **not** a task database, scheduler, queue, router, or new control plane. Canonical task state stays where it already lives. This lab is only a derived attention surface over existing task/receipt/event outputs. + +## 10-minute first success + +Requirements: Python 3 only. + +From `labs/attention-inbox`: + +```bash +python3 scripts/inbox.py check fixtures/decision-required.json +# exit 0 +# ATTENTION_EVENT_VALID + +python3 scripts/inbox.py render fixtures/internal-waiting.json +# exit 0 +# SUPPRESS reason=no_concrete_user_decision + +python3 scripts/inbox.py render fixtures/decision-required.json +# exit 0 +# ACTION_REQUIRED task=synthetic-release gate=publish_release + +python3 scripts/inbox.py render fixtures/risk-alert.json +# exit 0 +# RISK_ALERT task=synthetic-worker-health severity=high + +python3 scripts/inbox.py render fixtures/completion.json +# exit 0 +# SUPPRESS reason=routine_completion + +python3 scripts/inbox.py batch fixtures/events.jsonl +# exit 0 +# output contains one duplicate suppression token + +python3 tests/run.py +# exit 0 +# ATTENTION_INBOX_TEST_OK +``` + +## Core rule + +`waiting_for_user`, `blocked`, or similar internal statuses are not proof that a user decision exists. + +An event becomes `ACTION_REQUIRED` only when `decision.required=true` **and** the event contains a concrete gate and a concrete question/action. Otherwise it stays suppressed unless it is a meaningful risk alert. + +## Event shape + +See [`schema/inbox-event.schema.json`](schema/inbox-event.schema.json). + +The reference event has: + +- stable `event_id` +- canonical `task_ref` +- source/owner +- event `kind` +- severity +- concise summary +- evidence references +- optional concrete `decision` +- deterministic `dedupe_key` +- source revision/freshness + +The schema intentionally does not store credentials, private payloads, or execution tokens. + +## Adapter contract + +Any agent, CLI, daemon, MCP adapter, or workflow may emit this event shape. The adapter owns translation; Attention Inbox owns only validation, escalation, rendering, and dedupe. + +Recommended flow: + +```text +Agent / existing worker + ↓ +existing receipt / task writeback + ↓ +adapter → AttentionEvent + ↓ +Attention Inbox policy + ├─ SUPPRESS + ├─ RISK_ALERT + └─ ACTION_REQUIRED + ↓ +existing notification / phone surface + ↓ +human decision + ↓ +existing owner/writeback/resume path +``` + +The final resume/writeback remains part of the existing system. This lab must not invent another execution path. + +## Public boundary + +Safe to open-source: + +- schema +- policy +- synthetic fixtures +- reference CLI +- tests + +Keep private: + +- production routing +- push/relay credentials +- machine identities +- real task data +- account/wallet/trading execution +- private memory diff --git a/labs/attention-inbox/examples/adapter-output.jsonl b/labs/attention-inbox/examples/adapter-output.jsonl new file mode 100644 index 0000000..026338b --- /dev/null +++ b/labs/attention-inbox/examples/adapter-output.jsonl @@ -0,0 +1,2 @@ +{"schema_version":1,"event_id":"evt-example-1","task_ref":"task-123","source":"codex-adapter","owner":"products","kind":"blocked","severity":"low","summary":"Worker is paused internally; no user decision exists.","occurred_at":"2026-10-06T15:00:00Z","dedupe_key":"task-123:pause","source_revision":"rev-8","decision":{"required":false}} +{"schema_version":1,"event_id":"evt-example-2","task_ref":"task-456","source":"claude-adapter","owner":"products","kind":"decision_required","severity":"medium","summary":"Validated artifact reached an external publish gate.","occurred_at":"2026-10-06T15:02:00Z","dedupe_key":"task-456:publish","source_revision":"rev-3","evidence":[{"type":"artifact","ref":"artifact://release-review"}],"decision":{"required":true,"gate_id":"public_publish","question":"Publish the validated artifact?","allowed_actions":["approve_publish","hold"]}} diff --git a/labs/attention-inbox/fixtures/completion.json b/labs/attention-inbox/fixtures/completion.json new file mode 100644 index 0000000..eb392f6 --- /dev/null +++ b/labs/attention-inbox/fixtures/completion.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "event_id": "evt-done-001", + "task_ref": "synthetic-background-sync", + "source": "synthetic-background-worker", + "owner": "daily_rhythm", + "kind": "completion", + "severity": "low", + "summary": "Routine background sync completed successfully.", + "occurred_at": "2026-10-06T14:03:00Z", + "dedupe_key": "synthetic-background-sync:done", + "source_revision": 11, + "evidence": [], + "decision": {"required": false} +} diff --git a/labs/attention-inbox/fixtures/decision-required.json b/labs/attention-inbox/fixtures/decision-required.json new file mode 100644 index 0000000..d065902 --- /dev/null +++ b/labs/attention-inbox/fixtures/decision-required.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "event_id": "evt-decision-001", + "task_ref": "synthetic-release", + "source": "synthetic-codex-worker", + "owner": "product_distribution", + "kind": "decision_required", + "severity": "medium", + "summary": "Release artifact is validated and ready for a public publish gate.", + "occurred_at": "2026-10-06T14:00:00Z", + "dedupe_key": "synthetic-release:publish_release", + "source_revision": 4, + "evidence": [ + {"type": "artifact", "ref": "./artifacts/release-review.md", "label": "validated review"} + ], + "decision": { + "required": true, + "gate_id": "publish_release", + "question": "Publish the validated release publicly?", + "allowed_actions": ["approve_publish", "hold"] + } +} diff --git a/labs/attention-inbox/fixtures/events.jsonl b/labs/attention-inbox/fixtures/events.jsonl new file mode 100644 index 0000000..aed4fa2 --- /dev/null +++ b/labs/attention-inbox/fixtures/events.jsonl @@ -0,0 +1,3 @@ +{"schema_version":1,"event_id":"evt-batch-1","task_ref":"synthetic-release","source":"synthetic-worker","owner":"product_distribution","kind":"decision_required","severity":"medium","summary":"Validated release reached public gate.","occurred_at":"2026-10-06T14:10:00Z","dedupe_key":"synthetic-release:publish","source_revision":1,"decision":{"required":true,"gate_id":"publish_release","question":"Publish now?","allowed_actions":["approve_publish","hold"]}} +{"schema_version":1,"event_id":"evt-batch-2","task_ref":"synthetic-release","source":"synthetic-worker","owner":"product_distribution","kind":"decision_required","severity":"medium","summary":"Duplicate transport delivery.","occurred_at":"2026-10-06T14:10:01Z","dedupe_key":"synthetic-release:publish","source_revision":1,"decision":{"required":true,"gate_id":"publish_release","question":"Publish now?","allowed_actions":["approve_publish","hold"]}} +{"schema_version":1,"event_id":"evt-batch-3","task_ref":"synthetic-routine","source":"synthetic-worker","owner":"daily_rhythm","kind":"completion","severity":"low","summary":"Routine work completed.","occurred_at":"2026-10-06T14:11:00Z","dedupe_key":"synthetic-routine:done","source_revision":3,"decision":{"required":false}} diff --git a/labs/attention-inbox/fixtures/internal-waiting.json b/labs/attention-inbox/fixtures/internal-waiting.json new file mode 100644 index 0000000..eaf025e --- /dev/null +++ b/labs/attention-inbox/fixtures/internal-waiting.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "event_id": "evt-wait-001", + "task_ref": "synthetic-research", + "source": "synthetic-worker", + "owner": "research_dd", + "kind": "blocked", + "severity": "low", + "summary": "Worker emitted an internal waiting_for_user-like state but no concrete decision exists.", + "occurred_at": "2026-10-06T14:01:00Z", + "dedupe_key": "synthetic-research:internal-wait", + "source_revision": 2, + "evidence": [], + "decision": {"required": false} +} diff --git a/labs/attention-inbox/fixtures/risk-alert.json b/labs/attention-inbox/fixtures/risk-alert.json new file mode 100644 index 0000000..47fafea --- /dev/null +++ b/labs/attention-inbox/fixtures/risk-alert.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "event_id": "evt-risk-001", + "task_ref": "synthetic-worker-health", + "source": "synthetic-host-sentinel", + "owner": "personal_ops", + "kind": "risk_alert", + "severity": "high", + "summary": "Execution host disk usage crossed the configured risk threshold.", + "occurred_at": "2026-10-06T14:02:00Z", + "dedupe_key": "synthetic-worker-health:disk-high", + "source_revision": 9, + "evidence": [ + {"type": "receipt", "ref": "./artifacts/host-health.json", "label": "health receipt"} + ], + "decision": {"required": false} +} diff --git a/labs/attention-inbox/schema/inbox-event.schema.json b/labs/attention-inbox/schema/inbox-event.schema.json new file mode 100644 index 0000000..8ae1d97 --- /dev/null +++ b/labs/attention-inbox/schema/inbox-event.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.invalid/attention-inbox/inbox-event.schema.json", + "title": "AttentionEvent", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "event_id", + "task_ref", + "source", + "owner", + "kind", + "severity", + "summary", + "occurred_at", + "dedupe_key", + "source_revision" + ], + "properties": { + "schema_version": {"const": 1}, + "event_id": {"type": "string", "minLength": 1}, + "task_ref": {"type": "string", "minLength": 1}, + "source": {"type": "string", "minLength": 1}, + "owner": {"type": "string", "minLength": 1}, + "kind": { + "enum": ["decision_required", "blocked", "failure", "risk_alert", "completion", "info"] + }, + "severity": {"enum": ["low", "medium", "high", "critical"]}, + "summary": {"type": "string", "minLength": 1, "maxLength": 500}, + "occurred_at": {"type": "string", "minLength": 1}, + "dedupe_key": {"type": "string", "minLength": 1}, + "source_revision": {"type": ["string", "integer"]}, + "evidence": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "ref"], + "properties": { + "type": {"enum": ["artifact", "receipt", "log", "diff", "url", "other"]}, + "ref": {"type": "string", "minLength": 1}, + "label": {"type": "string"} + } + } + }, + "decision": { + "type": "object", + "additionalProperties": false, + "required": ["required"], + "properties": { + "required": {"type": "boolean"}, + "gate_id": {"type": "string"}, + "question": {"type": "string"}, + "allowed_actions": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + } + } + } + } +} diff --git a/labs/attention-inbox/scripts/inbox.py b/labs/attention-inbox/scripts/inbox.py new file mode 100644 index 0000000..de34394 --- /dev/null +++ b/labs/attention-inbox/scripts/inbox.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +import argparse +import json +import sys + +KINDS = {"decision_required", "blocked", "failure", "risk_alert", "completion", "info"} +SEVERITIES = {"low", "medium", "high", "critical"} +REQUIRED = { + "schema_version", "event_id", "task_ref", "source", "owner", "kind", + "severity", "summary", "occurred_at", "dedupe_key", "source_revision" +} + + +def load_json(path: str): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def validate_event(event): + errors = [] + missing = sorted(REQUIRED - set(event)) + if missing: + errors.append("missing=" + ",".join(missing)) + if event.get("schema_version") != 1: + errors.append("schema_version") + if event.get("kind") not in KINDS: + errors.append("kind") + if event.get("severity") not in SEVERITIES: + errors.append("severity") + for key in ("event_id", "task_ref", "source", "owner", "summary", "occurred_at", "dedupe_key"): + if key in event and (not isinstance(event[key], str) or not event[key].strip()): + errors.append(key) + decision = event.get("decision") + if decision is not None: + if not isinstance(decision, dict) or "required" not in decision or not isinstance(decision.get("required"), bool): + errors.append("decision") + return errors + + +def concrete_decision(event): + decision = event.get("decision") or {} + if decision.get("required") is not True: + return False + if not str(decision.get("gate_id", "")).strip(): + return False + if not str(decision.get("question", "")).strip(): + return False + actions = decision.get("allowed_actions") + return isinstance(actions, list) and len(actions) > 0 and all(isinstance(a, str) and a.strip() for a in actions) + + +def classify(event): + if concrete_decision(event): + return "ACTION_REQUIRED", "concrete_decision" + + if event.get("kind") == "risk_alert" and event.get("severity") in {"high", "critical"}: + return "RISK_ALERT", "meaningful_risk" + + if event.get("kind") == "failure" and event.get("severity") in {"high", "critical"}: + return "RISK_ALERT", "meaningful_failure" + + if event.get("kind") == "completion": + return "SUPPRESS", "routine_completion" + + if event.get("kind") in {"blocked", "decision_required"}: + return "SUPPRESS", "no_concrete_user_decision" + + return "SUPPRESS", "no_attention_needed" + + +def render(event): + outcome, reason = classify(event) + task = event["task_ref"] + if outcome == "ACTION_REQUIRED": + gate = event["decision"]["gate_id"] + return f"ACTION_REQUIRED task={task} gate={gate}" + if outcome == "RISK_ALERT": + return f"RISK_ALERT task={task} severity={event['severity']}" + return f"SUPPRESS reason={reason}" + + +def cmd_check(path): + event = load_json(path) + errors = validate_event(event) + if errors: + for err in errors: + print(f"ATTENTION_EVENT_INVALID {err}") + return 1 + print("ATTENTION_EVENT_VALID") + return 0 + + +def cmd_render(path): + event = load_json(path) + errors = validate_event(event) + if errors: + for err in errors: + print(f"ATTENTION_EVENT_INVALID {err}") + return 1 + print(render(event)) + return 0 + + +def cmd_batch(path): + seen = set() + invalid = 0 + with open(path, "r", encoding="utf-8") as f: + for lineno, line in enumerate(f, 1): + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + print(f"ATTENTION_EVENT_INVALID line={lineno} json") + invalid += 1 + continue + errors = validate_event(event) + if errors: + print(f"ATTENTION_EVENT_INVALID line={lineno} {';'.join(errors)}") + invalid += 1 + continue + dedupe_identity = (event["dedupe_key"], str(event["source_revision"])) + if dedupe_identity in seen: + print(f"SUPPRESS reason=duplicate event={event['event_id']}") + continue + seen.add(dedupe_identity) + print(render(event)) + return 1 if invalid else 0 + + +def main(): + parser = argparse.ArgumentParser(description="Reference Attention Inbox policy CLI") + sub = parser.add_subparsers(dest="command", required=True) + for name in ("check", "render", "batch"): + p = sub.add_parser(name) + p.add_argument("path") + args = parser.parse_args() + if args.command == "check": + return cmd_check(args.path) + if args.command == "render": + return cmd_render(args.path) + return cmd_batch(args.path) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/labs/attention-inbox/tests/run.py b/labs/attention-inbox/tests/run.py new file mode 100644 index 0000000..5ce0871 --- /dev/null +++ b/labs/attention-inbox/tests/run.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "inbox.py" +spec = importlib.util.spec_from_file_location("attention_inbox", SCRIPT) +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) + + +def load(name): + return mod.load_json(str(ROOT / "fixtures" / name)) + + +def expect(condition, message): + if not condition: + raise AssertionError(message) + + +def main(): + decision = load("decision-required.json") + waiting = load("internal-waiting.json") + risk = load("risk-alert.json") + completion = load("completion.json") + + for event in (decision, waiting, risk, completion): + expect(mod.validate_event(event) == [], f"invalid fixture: {event['event_id']}") + + expect(mod.render(decision) == "ACTION_REQUIRED task=synthetic-release gate=publish_release", "decision should escalate") + expect(mod.render(waiting) == "SUPPRESS reason=no_concrete_user_decision", "internal waiting must not masquerade as approval") + expect(mod.render(risk) == "RISK_ALERT task=synthetic-worker-health severity=high", "high risk should surface") + expect(mod.render(completion) == "SUPPRESS reason=routine_completion", "routine completion should stay quiet") + + fake = dict(decision) + fake["decision"] = {"required": True, "gate_id": "publish_release", "question": "Publish?", "allowed_actions": []} + expect(mod.render(fake) == "SUPPRESS reason=no_concrete_user_decision", "decision without actions is not concrete") + + print("ATTENTION_INBOX_TEST_OK") + + +if __name__ == "__main__": + main()