Commit 043d6536a85026cc8fb14b9abb0c2542bce4e82c

Authored by bboonnee
1 parent 836e16f6

H: unified Role Package abstraction (roles/<role_id>/role.json)

Implements docs/todo/role-package-abstraction-design.md. Requirements
review (docs/todo/graph-engineering-open-items.md's H section) had already
corrected the requirements doc's own dependency analysis before this;
scope confirmed with user 2026-08-06: relax all 4 org_node CHECK
constraints in one pass, not staged.

Format is JSON, not YAML: 05 §5.7's pure-stdlib constraint rules out
PyYAML, and unlike Skill files (whose YAML frontmatter only Claude Code
itself ever parses, never this codebase's own Python), role.json genuinely
needs to be read by agent_profile.py -- json.load is the zero-risk choice
over a hand-rolled parser.

role.json is a REFERENCE manifest, not a content copy: identity.soul_md_path/
agents_md_path still point at the untouched agents/<role>/SOUL.md/AGENTS.md
files. This is what makes "deployed output must stay identical" close to
automatic -- render_agent_definition()'s body-assembly logic doesn't
change at all, only the frontmatter's data source does (hardcoded
FRONTMATTER dict -> parsed role.json). Verified byte-for-byte: diffed the
old (git HEAD) render_agent_definition() output against the new one for
all three roles, zero differences.

agent_profile.py: ORG_NODES is now discovered by scanning roles/ for
role.json files (_discover_role_ids()) instead of a hardcoded 3-tuple;
FRONTMATTER/_DESCRIPTIONS/_MIGRATED_FROM_PROFILE dicts are gone. Every
public function takes an optional roles_dir override specifically so a
test can prove "a new role needs zero code changes" against a throwaway
fixture directory (NewRoleWithoutCodeChangeTest) rather than asserting it
by inspection.

schema.py: skill.owner_org_node/task.assigned_org_node/work_graph_run.org_node/
agent_profile.org_node relaxed from CHECK(x IN ('planner','coder','validator'))
to CHECK(x != ''). Real blast radius found during design (bigger than the
requirements doc's own risk note): task/work_graph_run/skill are
referenced by several other tables' foreign keys, but since only the
parent tables' own CHECK text changes (child FK definitions are untouched),
only these 4 parent tables need rebuilding -- followed SQLite's documented
procedure for schema changes under active foreign keys (foreign_keys=OFF
for the duration, rebuild, foreign_key_check to confirm nothing went
dangling, foreign_keys=ON). migrate_relax_org_node_constraints() depends
on D's migrate_add_work_graph_fork_columns() having run (called
unconditionally, idempotent) so work_graph_run's rebuild has the right
column set either way. ux_work_graph_run_active and task's 3 indexes
verified to survive the rebuild.

work_graph_run_query.py's own separately-hardcoded _VALID_ORG_NODES
(a real dependency the requirements doc's risk section missed) now reuses
agent_profile.ORG_NODES instead of maintaining a second copy.

Explicitly NOT done (scoped out during requirements review, not an
oversight): frontend/app.js's 4 hardcoded 3-role spots (route regex, sort
order, dropdown, lookup object) -- a new role still won't be recognized by
the PH3 console. A `role` table with real FK referential integrity was
also not built; this uses the simpler "just relax the enum" approach.

Full src suite (623 tests) + supervisor suite (125 tests) pass with zero
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/todo/graph-engineering-open-items.md
@@ -120,7 +120,7 @@ @@ -120,7 +120,7 @@
120 120
121 ## H. 统一 Role Package 抽象层 121 ## H. 统一 Role Package 抽象层
122 122
123 -**状态**:✅ 已确认(需求评审通过,2026-08-06)→ 需求文档 `docs/todo/role-package-abstraction-requirements.md`(注:文档正文里自己写的路径少了 `todo/` 一段,实际文件在这个路径下) 123 +**状态**:✅ 已实现(2026-08-06)→ 需求文档 `docs/todo/role-package-abstraction-requirements.md` + 设计文档 `docs/todo/role-package-abstraction-design.md`
124 124
125 **来源**:不属于三份 Graph Engineering 对照笔记,是用户对照 Agentic Enterprise 产品目标("role + context + skills + applications + workflows + permissions + acceptance criteria;all essentially are context for intelligent computing")单独提出的差距检查,2026-08-06。 125 **来源**:不属于三份 Graph Engineering 对照笔记,是用户对照 Agentic Enterprise 产品目标("role + context + skills + applications + workflows + permissions + acceptance criteria;all essentially are context for intelligent computing")单独提出的差距检查,2026-08-06。
126 126
@@ -133,6 +133,17 @@ @@ -133,6 +133,17 @@
133 133
134 其余判断(`role` 表外键 vs 自由文本+应用层校验、applications 维度最小定义、迁移步骤、workflows 字段怎么接 E)维持文档第 8 节"留待详细设计阶段回答"不变,本次评审不抢先拍板。 134 其余判断(`role` 表外键 vs 自由文本+应用层校验、applications 维度最小定义、迁移步骤、workflows 字段怎么接 E)维持文档第 8 节"留待详细设计阶段回答"不变,本次评审不抢先拍板。
135 135
  136 +**实现结果(2026-08-06,FR-3 范围问题跟用户确认过——4 张表一次性全改,不分阶段)**:
  137 +
  138 +- **载体格式选了 JSON,不是 YAML**:05 §5.7 纯 stdlib 约束排除了 PyYAML;Skill 文件的 YAML frontmatter 之所以能用是因为 graphlemon 自己的代码从来不解析它(只有 Claude Code 自己解析),但 Role Package 需要被 `agent_profile.py` 真正读懂,手写 YAML 子集解析器风险高,`json.load` 零风险。`roles/{planner,coder,validator}/role.json` 三个文件落地,覆盖七个维度。
  139 +- **role.json 是"引用清单",不是内容复制**:`identity.soul_md_path`/`agents_md_path` 原样指向没动过的 `agents/<role>/SOUL.md`/`AGENTS.md`——这个决定让"部署产物必须不变"这条验收标准(标准 2)几乎自动成立:`render_agent_definition()` 的 body 拼接逻辑一行没变,只有 frontmatter 字段的来源从硬编码字典变成解析 `role.json`。**逐字节验证过**:直接用 `git show HEAD:src/agent_profile.py` 跑旧代码、跟新代码的 `render_agent_definition()` 输出做 diff,三个角色全部逐字节相同。
  140 +- **`agent_profile.py` 改造**:`ORG_NODES` 从硬编码三元组变成扫描 `roles/` 目录发现(`_discover_role_ids()`),`FRONTMATTER`/`_DESCRIPTIONS`/`_MIGRATED_FROM_PROFILE` 三个硬编码字典全部删除。三个公开函数都加了 `roles_dir` 可选参数,专门用来在测试里证明"新增角色不用改代码"——测试往临时目录塞一个新角色的 `role.json`,直接调用这三个**未经修改**的函数,确认新角色能被发现、渲染、部署(`NewRoleWithoutCodeChangeTest`)。
  141 +- **Schema:4 张表全改**——`skill.owner_org_node`/`task.assigned_org_node`/`work_graph_run.org_node`/`agent_profile.org_node` 的 `CHECK (x IN ('planner','coder','validator'))` 全部放开成 `CHECK (x != '')`。评审阶段发现的真实改动面比原始风险描述更细:`task`/`work_graph_run`/`skill` 被多张表外键引用,但因为改的是父表、子表的 `REFERENCES` 定义本身不需要变,只需要重建这 4 张父表本身,不需要动任何子表——按 SQLite 官方文档给的流程(`PRAGMA foreign_keys=OFF` → 重建 → `PRAGMA foreign_key_check` 校验无悬空外键 → `PRAGMA foreign_keys=ON`)写了 `migrate_relax_org_node_constraints()`,依赖 D 的 `migrate_add_work_graph_fork_columns()` 先跑(内部会自动调用,幂等)。`ux_work_graph_run_active`(D 依赖的分区唯一索引)、`task` 的三个索引,重建后都验证过仍然生效。
  142 +- **顺手修了评审阶段发现的遗漏依赖**:`work_graph_run_query.py::_VALID_ORG_NODES` 改成直接引用 `agent_profile.ORG_NODES`,不再自己维护一份硬编码三元组。
  143 +- **明确没做的部分(如实记录,评审阶段已经说清楚边界)**:`frontend/app.js` 的 4 处硬编码三角色枚举(路由正则、排序数组、下拉列表、查找对象)本轮不改——新增一个角色之后 PH3 控制台依然认不出它,这是刻意保留的已知缺口,不是遗漏;`role` 表外键完整性方案本轮也没做,用的是更简单的"放开枚举"。
  144 +
  145 +`python3 -m unittest discover -s src`(623 tests)+ `discover -s supervisor`(125 tests)全绿,零回归。至此 Graph Engineering 系列 A/B/D/E/F/G/H 全部完成,C 已确认不做。
  146 +
136 --- 147 ---
137 148
138 ## 工作量与优先级评估(2026-08-06) 149 ## 工作量与优先级评估(2026-08-06)
@@ -143,7 +154,7 @@ @@ -143,7 +154,7 @@
143 | **E** 动态 Work Graph 设计能力 | 让 Coder/Validator 也能规划自己的图,Planning 提示词识别可并行节点 | ✅ **已实现**(2026-08-06,580 测试全绿) | L | 已完成 | G(已满足) | 154 | **E** 动态 Work Graph 设计能力 | 让 Coder/Validator 也能规划自己的图,Planning 提示词识别可并行节点 | ✅ **已实现**(2026-08-06,580 测试全绿) | L | 已完成 | G(已满足) |
144 | **F** 三视角评审团 fan-out | `judge_root_cause` 从 Python 函数改成 Work Graph 菱形拓扑 | ✅ **已实现**(2026-08-06,586 测试全绿) | M | 已完成 | 无(G 已满足) | 155 | **F** 三视角评审团 fan-out | `judge_root_cause` 从 Python 函数改成 Work Graph 菱形拓扑 | ✅ **已实现**(2026-08-06,586 测试全绿) | M | 已完成 | 无(G 已满足) |
145 | **B** loop-until-dry Skill | Validator 未知规模扫描收敛能力 | ✅ **已实现**(2026-08-06,587 测试全绿) | S/M | 已完成 | 无 | 156 | **B** loop-until-dry Skill | Validator 未知规模扫描收敛能力 | ✅ **已实现**(2026-08-06,587 测试全绿) | S/M | 已完成 | 无 |
146 -| **H** Role Package 抽象层 | 统一 role/context/skills/applications/workflows/permissions/acceptance 打包格式 | ✅ 需求评审通过(2026-08-06),待设计 | XL | P2(战略级,不阻塞其他项) | workflows 字段依赖 E 的产出(已满足) | 157 +| **H** Role Package 抽象层 | 统一 role/context/skills/applications/workflows/permissions/acceptance 打包格式 | ✅ **已实现**(2026-08-06,623 测试全绿) | XL | 已完成 | 无(E 已满足) |
147 | **D** Time-travel 调试 | 回到历史节点分叉重跑 | ✅ **已实现**(2026-08-06,608 测试全绿) | L | 已完成 | 无 | 158 | **D** Time-travel 调试 | 回到历史节点分叉重跑 | ✅ **已实现**(2026-08-06,608 测试全绿) | L | 已完成 | 无 |
148 | C 按节点选模型 | — | ⛔ 已确认不做 | — | — | — | 159 | C 按节点选模型 | — | ⛔ 已确认不做 | — | — | — |
149 | A 术语对照 | — | ✅ 已完成 | — | — | — | 160 | A 术语对照 | — | ✅ 已完成 | — | — | — |
docs/todo/role-package-abstraction-design.md 0 → 100644
  1 +# 统一 Role Package 抽象层——详细设计(SDD)
  2 +
  3 +**日期**: 2026-08-06
  4 +**性质**: 详细设计,对应需求文档 `docs/todo/role-package-abstraction-requirements.md`(条目 H,需求评审已通过,见 `docs/todo/graph-engineering-open-items.md` H 节的评审记录)。回答该需求文档第 8 节的开放问题。
  5 +**范围确认(用户 2026-08-06)**:FR-3 的 4 张 CHECK 约束表一次性全改,不分阶段。
  6 +**状态**: 设计草案,实施中。
  7 +
  8 +## 1. Role Package 载体格式:JSON,不是 YAML
  9 +
  10 +需求文档 FR-2 的开放问题是"单文件 vs 目录约定",但更根本的约束是 05 §5.7 的纯 stdlib 限制——graphlemon 自己的代码不能依赖 PyYAML。`skills/<name>/SKILL.md` 现有的 YAML frontmatter 之所以能用,是因为**graphlemon 自己的代码从来没有解析过它**(`skill_query.py`/`skill_update.py` 只把 SKILL.md 当不透明文本读写,真正解析 `skills:`/`tools:` frontmatter 的是 Claude Code 自己,不是这个仓库的 Python 代码)。Role Package 不一样:`agent_profile.py` 需要真正**读懂**它的结构化字段(哪些 skills、哪些 tools、memory 配置……),手写一个 YAML 子集解析器风险高、容易在多行字符串/嵌套列表这类边界情况上出 bug——纯 stdlib 的 `json` 模块能零风险做到同样的事。
  11 +
  12 +**决定**:`roles/<role_id>/role.json`,纯 JSON,`json.load` 直接解析。
  13 +
  14 +## 2. role.json 是"引用清单",不是内容复制
  15 +
  16 +需求文档 FR-1 自己的措辞已经暗示了这一点——"context(memory 配置 + 运行时 context 组装规则的**引用**)"、"skills(**对应**现有 `skills:` frontmatter)"、"acceptance criteria(**引用**)"——七个维度里大多数本来就该是指针,不是把内容整个复制一份。
  17 +
  18 +具体到 role/identity 维度:`agents/<role>/SOUL.md`(+ validator 的 `AGENTS.md`)**原样保留,不删除、不合并、不改名**——role.json 只存这两个文件的相对路径引用(`identity.soul_md_path`/`identity.agents_md_path`)。这个决定直接让验收标准 2("迁移后部署产物逐字节一致")几乎自动成立:`render_agent_definition()` 的 body 拼接逻辑(`SOUL.md` 内容 + `AGENTS.md` 内容)完全不变,唯一变化的是 frontmatter 字段的**来源**从硬编码 `FRONTMATTER` 字典变成解析 `role.json`。
  19 +
  20 +```json
  21 +{
  22 + "role_id": "validator",
  23 + "description": "graphlemon Validator: independently verifies a task, PASS/FAIL/INCONCLUSIVE, read-only on source.",
  24 + "migrated_from_profile": "qa",
  25 + "identity": {
  26 + "soul_md_path": "agents/validator/SOUL.md",
  27 + "agents_md_path": "agents/validator/AGENTS.md"
  28 + },
  29 + "context": { "memory": "project" },
  30 + "model": "claude-sonnet-5",
  31 + "skills": ["qa-task-validation", "unattended-agent-operations", "design-dynamic-work-graph", "unknown-scope-defect-sweep"],
  32 + "applications": { "mcp_servers": [] },
  33 + "workflows": { "dynamic_planning": "generic" },
  34 + "permissions": {
  35 + "tools": ["Read", "Bash", "Grep", "Glob"],
  36 + "disallowed_tools": ["Edit", "Write"],
  37 + "notes": [
  38 + { "constraint": "不提交、推送、reset、清理或覆盖用户工作树", "enforced": "prose_only" }
  39 + ]
  40 + },
  41 + "acceptance_criteria": { "ref": "docs/sdd/06-验收与测试锚点.md" }
  42 +}
  43 +```
  44 +
  45 +- **applications**(FR-6,"应该"):`mcp_servers` 列表,允许为空——满足"这个维度存在、字段长什么样、允许为空",不要求真实集成(非目标 #4)。
  46 +- **workflows**:接 E 的产出(非目标 #2 明确不重复设计 E 已经做的事,这里只是一个指向枚举)。`planner` 是 `"canonical_doc_chain"`(`work_graph_plan.py` 里 `org_node == "planner"` 走的固定链),`coder`/`validator` 是 `"generic"`(E 的 `_GENERIC_PROMPT_TEMPLATE`/`generic_node_prompt_builder` 路径)。
  47 +- **permissions.notes**(验收标准 4):Validator 的 Bash 子命令限定失效那条已知漏洞,诚实标注 `"enforced": "prose_only"`,不假装是工具层强制——非目标 #3 明确不在本文档修复这个漏洞本身,只要求如实反映现状。
  48 +
  49 +## 3. `agent_profile.py` 改造:`ORG_NODES` 从硬编码元组变成目录发现
  50 +
  51 +```python
  52 +_ROLES_SOURCE_DIR = os.path.join(_REPO_ROOT, "roles")
  53 +
  54 +def _discover_role_ids(roles_dir=None):
  55 + """扫描 roles/<role_id>/role.json 是否存在,role_id 就是目录名。
  56 + roles_dir 可覆盖(测试用真实文件系统 fixture 验证'新增角色不用改代码'
  57 + 这条验收标准,不需要真的往这个仓库的 roles/ 目录里塞测试数据)。"""
  58 + roles_dir = roles_dir or _ROLES_SOURCE_DIR
  59 + if not os.path.isdir(roles_dir):
  60 + return ()
  61 + return tuple(sorted(
  62 + name for name in os.listdir(roles_dir)
  63 + if os.path.isfile(os.path.join(roles_dir, name, "role.json"))
  64 + ))
  65 +
  66 +ORG_NODES = _discover_role_ids()
  67 +```
  68 +
  69 +`FRONTMATTER`/`_DESCRIPTIONS`/`_MIGRATED_FROM_PROFILE` 三个硬编码字典全部删除,替换成 `_load_role(role_id, roles_dir=None)`(`json.load` 解析对应 `role.json`)。`render_agent_definition()`/`deploy_agent_definitions()`/`record_agent_profiles()` 都接受一个可选 `roles_dir` 参数(默认 `None` 即用真实目录),这是验收标准 1 能被自动化测试验证的关键——测试往一个临时目录写一个新角色的 `role.json`,不改任何 Python 代码或 `roles_dir` 之外的任何东西,直接调用这三个未经修改的函数,确认新角色能被发现、渲染、部署。
  70 +
  71 +`ORG_NODES` 这个名字保留不改(`org_node_trigger.py` 等外部模块已经在用 `from src.agent_profile import ORG_NODES`,改名会不必要地扩大改动面),但注释说明它现在是数据驱动的,不再是硬编码元组。
  72 +
  73 +## 4. Schema:4 张表的 CHECK 约束怎么改
  74 +
  75 +**决定的形状**:`CHECK (x IN ('planner', 'coder', 'validator'))` → `CHECK (x != '')`——仍然是数据库层面的最小完整性保证(非空字符串),但不再枚举具体值。不是完全去掉 CHECK(那样连"总不能是空字符串"这点基本保证都没有了),也不是新建一张 `role` 表做外键(需求文档 §8 留的另一个选项)——本设计选择更简单的那条路径:4 张表全改,但改的目标是"放开枚举"而不是"建立引用完整性",避免这次改动同时背上"外键完整性设计"这个新的复杂度。
  76 +
  77 +**真实的改动面**(这是这次评审最重要的发现,比需求文档原始风险描述更细):`task`/`work_graph_run`/`skill` 三张表被 `work_node_execution`/`artifact`/`bug_record`/`execution_event` 等多张表通过外键引用;`agent_profile` 没有任何表引用它。SQLite 不支持 `ALTER TABLE ... DROP CONSTRAINT`,改 CHECK 约束必须用"建新表复制数据删旧表改名"的标准操作——但因为改的是**父表**、外键定义本身(`REFERENCES task(task_id)` 这类文本)**不需要跟着变**,所以只需要重建这 4 张父表本身,不需要动任何一张子表。SQLite 官方文档给的正确流程("Making Other Kinds Of Table Schema Changes")是:
  78 +
  79 +1. `PRAGMA foreign_keys = OFF`(关闭本连接的外键校验,只在这次迁移期间)
  80 +2. 开事务
  81 +3. 对每张表:建同结构、新 CHECK 的 `<table>_new` → `INSERT INTO <table>_new SELECT * FROM <table>` → `DROP TABLE <table>` → `ALTER TABLE <table>_new RENAME TO <table>` → 重建这张表原有的索引
  82 +4. `PRAGMA foreign_key_check` 确认没有产生悬空外键
  83 +5. 提交事务
  84 +6. `PRAGMA foreign_keys = ON`
  85 +
  86 +`migrate_relax_org_node_constraints(conn)` 一个函数按这个流程处理全部 4 张表(一次性开一个事务,不是 4 个独立迁移各自开关 `foreign_keys`),每张表重建前用 `sqlite_master` 里的 CREATE TABLE 文本判断是否已经是新约束,已经是就跳过(幂等,`migrate_expand_artifact_types` 的同款前置检查模式)。`SCHEMA_SQL` 里的 4 处 `CHECK (... IN ('planner', 'coder', 'validator'))` 直接改成 `CHECK (... != '')`,新数据库不需要迁移。
  87 +
  88 +**索引重建清单**(重建表之后必须重新建的,`DROP TABLE` 会连带丢失表自己的索引):
  89 +- `skill`:无额外索引。
  90 +- `task`:`idx_task_status`、`idx_task_assigned_org_node`、`idx_task_project_id`。
  91 +- `work_graph_run`:`ux_work_graph_run_active`(分区唯一索引,D 已经依赖这条索引的语义,重建后必须原样保留)。
  92 +- `agent_profile`:PK 本身即索引,无需额外重建。
  93 +
  94 +## 5. `work_graph_run_query.py::_VALID_ORG_NODES`(评审时发现的遗漏依赖)
  95 +
  96 +改成直接引用 `agent_profile.ORG_NODES`,不再自己维护一份硬编码三元组——这条不在需求文档原始 FR 列表里,是评审阶段核实代码时发现的真实遗漏(`docs/todo/graph-engineering-open-items.md` H 节评审记录已经点名),顺手一起改掉。
  97 +
  98 +## 6. 明确不做的部分(如实记录,呼应评审阶段的结论)
  99 +
  100 +- **`frontend/app.js` 的 4 处硬编码三角色枚举**(路由正则、`ORG_NODE_ORDER` 排序数组、任务创建表单下拉、`byOrgNode` 查找对象)——评审阶段已经确认这些不在验收标准 1 的范围内(验收标准 1 明确"这个新角色不需要真的接入 Org Graph 执行"),本轮不改。新增一个角色之后,PH3 控制台依然认不出它,这是本轮已知、刻意保留的缺口,不是遗漏。
  101 +- **`role` 表 + 外键完整性**(§4 提到的另一个选项)——本轮用更简单的"放开枚举"实现,不建立真正的引用完整性约束。
  102 +- **`org_node_trigger.py` 校验逻辑本身**——它已经用 `from src.agent_profile import ORG_NODES` 做校验,`ORG_NODES` 变成数据驱动之后这条校验自动跟着数据驱动,不需要改这个文件。
  103 +- **permissions 维度的 Bash 子命令限定漏洞本身**——诚实标注(§2),不修复(需求文档非目标 #3)。
  104 +
  105 +## 7. 回答需求文档第 8 节开放问题
  106 +
  107 +- 载体格式:单文件 `role.json`(不是目录多文件),JSON 不是 YAML(§1)。
  108 +- CHECK 约束改造方式:放开成 `!= ''`,不建 `role` 表外键(§4)。
  109 +- applications 最小定义:`{"mcp_servers": []}`(§2)。
  110 +- 迁移步骤:三个角色各自的 `role.json` 直接引用现有 `agents/<role>/SOUL.md`/`AGENTS.md`,不复制内容(§2),验证方式是 `test_agent_profile.py` 现有测试断言部署产物不变。
  111 +- Role Package 跟 E 的接口:`workflows.dynamic_planning` 字段,`"canonical_doc_chain"` / `"generic"` 二选一(§2)。
roles/coder/role.json 0 → 100644
  1 +{
  2 + "role_id": "coder",
  3 + "description": "graphlemon Coder: implements one task's code + tests against the Planner's SDD.",
  4 + "migrated_from_profile": "code",
  5 + "identity": {
  6 + "soul_md_path": "agents/coder/SOUL.md",
  7 + "agents_md_path": null
  8 + },
  9 + "context": {
  10 + "memory": "project"
  11 + },
  12 + "model": "claude-sonnet-5",
  13 + "skills": [
  14 + "code-task-development",
  15 + "code-agent-engineering",
  16 + "debugging-toolkit",
  17 + "software-planning-execution",
  18 + "design-dynamic-work-graph"
  19 + ],
  20 + "applications": {
  21 + "mcp_servers": []
  22 + },
  23 + "workflows": {
  24 + "dynamic_planning": "generic"
  25 + },
  26 + "permissions": {
  27 + "tools": ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
  28 + "disallowed_tools": [],
  29 + "notes": []
  30 + },
  31 + "acceptance_criteria": {
  32 + "ref": "docs/sdd/06-验收与测试锚点.md"
  33 + }
  34 +}
roles/planner/role.json 0 → 100644
  1 +{
  2 + "role_id": "planner",
  3 + "description": "graphlemon Planner: turns a goal into BRD/PRD/SDD/Benchmark artifacts. Never writes business code.",
  4 + "migrated_from_profile": "planning",
  5 + "identity": {
  6 + "soul_md_path": "agents/planner/SOUL.md",
  7 + "agents_md_path": null
  8 + },
  9 + "context": {
  10 + "memory": "project"
  11 + },
  12 + "model": "claude-sonnet-5",
  13 + "skills": [
  14 + "planning-requirement-expansion",
  15 + "project-brd-planning",
  16 + "project-prd-planning",
  17 + "project-sdd-planning",
  18 + "project-unit-decomposition",
  19 + "review-blocker-verification"
  20 + ],
  21 + "applications": {
  22 + "mcp_servers": []
  23 + },
  24 + "workflows": {
  25 + "dynamic_planning": "canonical_doc_chain"
  26 + },
  27 + "permissions": {
  28 + "tools": ["Read", "Write", "Grep", "Glob"],
  29 + "disallowed_tools": [],
  30 + "notes": []
  31 + },
  32 + "acceptance_criteria": {
  33 + "ref": "docs/sdd/06-验收与测试锚点.md"
  34 + }
  35 +}
roles/validator/role.json 0 → 100644
  1 +{
  2 + "role_id": "validator",
  3 + "description": "graphlemon Validator: independently verifies a task, PASS/FAIL/INCONCLUSIVE, read-only on source.",
  4 + "migrated_from_profile": "qa",
  5 + "identity": {
  6 + "soul_md_path": "agents/validator/SOUL.md",
  7 + "agents_md_path": "agents/validator/AGENTS.md"
  8 + },
  9 + "context": {
  10 + "memory": "project"
  11 + },
  12 + "model": "claude-sonnet-5",
  13 + "skills": [
  14 + "qa-task-validation",
  15 + "unattended-agent-operations",
  16 + "design-dynamic-work-graph",
  17 + "unknown-scope-defect-sweep"
  18 + ],
  19 + "applications": {
  20 + "mcp_servers": []
  21 + },
  22 + "workflows": {
  23 + "dynamic_planning": "generic"
  24 + },
  25 + "permissions": {
  26 + "tools": ["Read", "Bash", "Grep", "Glob"],
  27 + "disallowed_tools": ["Edit", "Write"],
  28 + "notes": [
  29 + {
  30 + "constraint": "不提交、推送、reset、清理或覆盖用户工作树",
  31 + "enforced": "prose_only",
  32 + "detail": "scoped Bash(...) disallowedTools entries silently disable Bash entirely in Claude Code subagent frontmatter (confirmed 2026-07-31, see agent_profile.py's prior FRONTMATTER comment) -- this constraint relies on AGENTS.md prose, not a tool-level block."
  33 + }
  34 + ]
  35 + },
  36 + "acceptance_criteria": {
  37 + "ref": "docs/sdd/06-验收与测试锚点.md"
  38 + }
  39 +}
src/agent_profile.py
@@ -19,92 +19,66 @@ agent_profile (01-M 1.9) is a singleton per org_node, has no target-project @@ -19,92 +19,66 @@ agent_profile (01-M 1.9) is a singleton per org_node, has no target-project
19 reference -- it tracks graphlemon's own canonical definition, not a per-deploy 19 reference -- it tracks graphlemon's own canonical definition, not a per-deploy
20 record, so deploy_agent_definitions() doesn't touch it; record_agent_profiles() 20 record, so deploy_agent_definitions() doesn't touch it; record_agent_profiles()
21 does, separately. 21 does, separately.
  22 +
  23 +**H (2026-08-06, Role Package abstraction)**: what used to be two hardcoded
  24 +Python dicts (`FRONTMATTER`/`_DESCRIPTIONS`/`_MIGRATED_FROM_PROFILE`) is now
  25 +`roles/<role_id>/role.json` -- a declarative manifest, not code. `ORG_NODES`
  26 +is no longer a fixed 3-tuple; it's discovered by scanning `roles/` for
  27 +directories containing a `role.json`, so a new role becomes recognized by
  28 +adding one JSON file, no schema.py/agent_profile.py edit. role.json is JSON,
  29 +not YAML, despite Skill files using YAML frontmatter -- pure-stdlib (05
  30 +§5.7) rules out PyYAML, and unlike Skill frontmatter (which only Claude Code
  31 +itself ever parses, never this codebase), role.json genuinely needs to be
  32 +read by this module's own Python code; `json.load` is the zero-risk choice,
  33 +no hand-rolled parser. role.json content is deliberately a REFERENCE
  34 +manifest, not a content copy: `identity.soul_md_path`/`agents_md_path` still
  35 +point at the same `agents/<org_node>/SOUL.md`/`AGENTS.md` files, unmoved --
  36 +see docs/todo/role-package-abstraction-design.md §2 for why keeping the
  37 +existing identity files as the single source of prose content makes the
  38 +migration's "deployed output must stay byte-identical" requirement close to
  39 +automatic (only the frontmatter's data SOURCE changed, not the body-assembly
  40 +logic). Every public function here takes an optional `roles_dir` override
  41 +(defaults to the real `roles/` directory) precisely so a test can prove "a
  42 +new role needs no code change" against a throwaway fixture directory instead
  43 +of mutating this repo's own `roles/`.
22 """ 44 """
23 45
  46 +import json
24 import os 47 import os
25 48
26 _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 49 _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27 _AGENTS_SOURCE_DIR = os.path.join(_REPO_ROOT, "agents") 50 _AGENTS_SOURCE_DIR = os.path.join(_REPO_ROOT, "agents")
  51 +_ROLES_SOURCE_DIR = os.path.join(_REPO_ROOT, "roles")
  52 +
  53 +
  54 +def _discover_role_ids(roles_dir=None):
  55 + """role_id == directory name, for every `<roles_dir>/<role_id>/role.json`
  56 + that exists. Sorted for deterministic iteration order (matches the old
  57 + hardcoded tuple's alphabetical `("coder", "planner", "validator")`...
  58 + actually the old tuple was declared as ("planner", "coder", "validator"),
  59 + not alphabetical -- callers that relied on that exact order didn't exist
  60 + (grep confirms every real use is either membership-testing or a `for`
  61 + loop that doesn't care about order), so sorted() here is a deliberate,
  62 + harmless simplification, not a compatibility requirement)."""
  63 + roles_dir = roles_dir or _ROLES_SOURCE_DIR
  64 + if not os.path.isdir(roles_dir):
  65 + return ()
  66 + return tuple(sorted(
  67 + name for name in os.listdir(roles_dir)
  68 + if os.path.isfile(os.path.join(roles_dir, name, "role.json"))
  69 + ))
  70 +
  71 +
  72 +ORG_NODES = _discover_role_ids()
28 73
29 -ORG_NODES = ("planner", "coder", "validator")  
30 -  
31 -_MIGRATED_FROM_PROFILE = {  
32 - "planner": "planning",  
33 - "coder": "code",  
34 - "validator": "qa",  
35 -}  
36 -  
37 -_DESCRIPTIONS = {  
38 - "planner": "graphlemon Planner: turns a goal into BRD/PRD/SDD/Benchmark artifacts. Never writes business code.",  
39 - "coder": "graphlemon Coder: implements one task's code + tests against the Planner's SDD.",  
40 - "validator": "graphlemon Validator: independently verifies a task, PASS/FAIL/INCONCLUSIVE, read-only on source.",  
41 -}  
42 -  
43 -# 05 §5.1: skills:/memory:/tools:/model: (and, for validator, disallowedTools: --  
44 -# the qa/AGENTS.md workspace-permission-contract folded in) are the frontmatter  
45 -# fields U-004's Done criterion checks for.  
46 -FRONTMATTER = {  
47 - "planner": {  
48 - "memory": "project",  
49 - "model": "claude-sonnet-5",  
50 - "skills": [  
51 - "planning-requirement-expansion",  
52 - "project-brd-planning",  
53 - "project-prd-planning",  
54 - "project-sdd-planning",  
55 - "project-unit-decomposition",  
56 - "review-blocker-verification",  
57 - ],  
58 - "tools": ["Read", "Write", "Grep", "Glob"],  
59 - },  
60 - "coder": {  
61 - "memory": "project",  
62 - "model": "claude-sonnet-5",  
63 - "skills": [  
64 - "code-task-development",  
65 - "code-agent-engineering",  
66 - "debugging-toolkit",  
67 - "software-planning-execution",  
68 - "design-dynamic-work-graph",  
69 - ],  
70 - "tools": ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],  
71 - },  
72 - "validator": {  
73 - "memory": "project",  
74 - "model": "claude-sonnet-5",  
75 - "skills": [  
76 - "qa-task-validation", "unattended-agent-operations", "design-dynamic-work-graph",  
77 - "unknown-scope-defect-sweep",  
78 - ],  
79 - "tools": ["Read", "Bash", "Grep", "Glob"],  
80 - # 2026-07-31 fix, found for real running U-014's full closed loop:  
81 - # scoped Bash patterns here ("Bash(git commit:*)") don't just block  
82 - # that one subcommand -- they silently disable Bash *entirely* for  
83 - # the agent (confirmed by isolating it: a plain ["Edit","Write"]  
84 - # disallowedTools list leaves Bash working fine; adding any  
85 - # "Bash(...)" entry breaks it, even though the CLI's own  
86 - # --disallowedTools flag documents this exact scoped syntax as  
87 - # supported -- it just doesn't work the same way in subagent  
88 - # frontmatter). A Validator that can't run tests at all is a much  
89 - # worse outcome than the residual risk of it running `git commit`,  
90 - # so the git-mutation restriction now relies on the prose instruction  
91 - # already in the migrated AGENTS.md ("不提交、推送、reset、清理或覆盖用户  
92 - # 工作树") instead of a tool-level block that doesn't actually work.  
93 - # ponytail: this is a real gap (no enforced ceiling on Bash git  
94 - # mutations, just an asked-nicely one) -- upgrade path is whatever  
95 - # Claude Code's correct scoped-disallowedTools frontmatter syntax  
96 - # turns out to be, if this gets fixed/documented upstream.  
97 - "disallowedTools": ["Edit", "Write"],  
98 - },  
99 -}  
100 -  
101 -  
102 -def _soul_path(org_node):  
103 - return os.path.join(_AGENTS_SOURCE_DIR, org_node, "SOUL.md")  
104 -  
105 -  
106 -def _agents_md_path(org_node):  
107 - return os.path.join(_AGENTS_SOURCE_DIR, org_node, "AGENTS.md") 74 +
  75 +def _role_json_path(role_id, roles_dir=None):
  76 + return os.path.join(roles_dir or _ROLES_SOURCE_DIR, role_id, "role.json")
  77 +
  78 +
  79 +def _load_role(role_id, roles_dir=None):
  80 + with open(_role_json_path(role_id, roles_dir), encoding="utf-8") as f:
  81 + return json.load(f)
108 82
109 83
110 def _read(path): 84 def _read(path):
@@ -112,46 +86,59 @@ def _read(path): @@ -112,46 +86,59 @@ def _read(path):
112 return f.read() 86 return f.read()
113 87
114 88
115 -def _frontmatter_yaml(org_node, fm):  
116 - lines = ["---", f"name: {org_node}", f"description: {_DESCRIPTIONS[org_node]}"]  
117 - lines.append(f"memory: {fm['memory']}")  
118 - lines.append(f"model: {fm['model']}") 89 +def _frontmatter_yaml(role):
  90 + # 05 §5.1: skills:/memory:/tools:/model: (and, for validator,
  91 + # disallowedTools: -- the qa/AGENTS.md workspace-permission-contract
  92 + # folded in) are the frontmatter fields U-004's Done criterion checks
  93 + # for. role.json's own field names are snake_case (permissions.tools/
  94 + # disallowed_tools); the deployed frontmatter keeps Claude Code's own
  95 + # camelCase `disallowedTools:` key -- this function is the one place
  96 + # that translates between the two, not role.json itself.
  97 + permissions = role["permissions"]
  98 + lines = ["---", "name: {}".format(role["role_id"]), "description: {}".format(role["description"])]
  99 + lines.append("memory: {}".format(role["context"]["memory"]))
  100 + lines.append("model: {}".format(role["model"]))
119 lines.append("skills:") 101 lines.append("skills:")
120 - lines += [f" - {s}" for s in fm["skills"]] 102 + lines += [" - {}".format(s) for s in role["skills"]]
121 lines.append("tools:") 103 lines.append("tools:")
122 - lines += [f" - {t}" for t in fm["tools"]]  
123 - if "disallowedTools" in fm: 104 + lines += [" - {}".format(t) for t in permissions["tools"]]
  105 + if permissions.get("disallowed_tools"):
124 lines.append("disallowedTools:") 106 lines.append("disallowedTools:")
125 - lines += [f" - {t}" for t in fm["disallowedTools"]] 107 + lines += [" - {}".format(t) for t in permissions["disallowed_tools"]]
126 lines.append("---") 108 lines.append("---")
127 return "\n".join(lines) 109 return "\n".join(lines)
128 110
129 111
130 -def render_agent_definition(org_node): 112 +def render_agent_definition(org_node, roles_dir=None):
131 """Assemble the .claude/agents/<org_node>.md content: frontmatter + migrated body.""" 113 """Assemble the .claude/agents/<org_node>.md content: frontmatter + migrated body."""
132 - if org_node not in ORG_NODES: 114 + if org_node not in _discover_role_ids(roles_dir):
133 raise ValueError(org_node) 115 raise ValueError(org_node)
134 - body = _read(_soul_path(org_node))  
135 - agents_md_path = _agents_md_path(org_node)  
136 - if os.path.exists(agents_md_path):  
137 - body += "\n\n" + _read(agents_md_path)  
138 - return _frontmatter_yaml(org_node, FRONTMATTER[org_node]) + "\n\n" + body  
139 -  
140 -  
141 -def deploy_agent_definitions(target_folder_path):  
142 - """Write .claude/agents/{planner,coder,validator}.md into a target project.""" 116 + role = _load_role(org_node, roles_dir)
  117 + identity = role["identity"]
  118 + body = _read(os.path.join(_REPO_ROOT, identity["soul_md_path"]))
  119 + if identity.get("agents_md_path"):
  120 + body += "\n\n" + _read(os.path.join(_REPO_ROOT, identity["agents_md_path"]))
  121 + return _frontmatter_yaml(role) + "\n\n" + body
  122 +
  123 +
  124 +def deploy_agent_definitions(target_folder_path, roles_dir=None):
  125 + """Write .claude/agents/<role_id>.md into a target project for every
  126 + discovered role (still exactly {planner, coder, validator} against this
  127 + repo's real roles/ directory, but data-driven now -- see module
  128 + docstring)."""
143 out_dir = os.path.join(target_folder_path, ".claude", "agents") 129 out_dir = os.path.join(target_folder_path, ".claude", "agents")
144 os.makedirs(out_dir, exist_ok=True) 130 os.makedirs(out_dir, exist_ok=True)
145 - for org_node in ORG_NODES: 131 + for org_node in _discover_role_ids(roles_dir):
146 with open(os.path.join(out_dir, org_node + ".md"), "w", encoding="utf-8") as f: 132 with open(os.path.join(out_dir, org_node + ".md"), "w", encoding="utf-8") as f:
147 - f.write(render_agent_definition(org_node)) 133 + f.write(render_agent_definition(org_node, roles_dir))
148 134
149 135
150 -def record_agent_profiles(conn, current_git_commit_sha): 136 +def record_agent_profiles(conn, current_git_commit_sha, roles_dir=None):
151 """Upsert the agent_profile (01-M 1.9) singleton rows for graphlemon's own repo.""" 137 """Upsert the agent_profile (01-M 1.9) singleton rows for graphlemon's own repo."""
152 - for org_node in ORG_NODES:  
153 - agents_md_path = _agents_md_path(org_node)  
154 - agents_file_path = os.path.relpath(agents_md_path, _REPO_ROOT) if os.path.exists(agents_md_path) else None 138 + for org_node in _discover_role_ids(roles_dir):
  139 + role = _load_role(org_node, roles_dir)
  140 + identity = role["identity"]
  141 + agents_md_path = identity.get("agents_md_path")
155 conn.execute( 142 conn.execute(
156 "INSERT INTO agent_profile " 143 "INSERT INTO agent_profile "
157 "(org_node, soul_file_path, agents_file_path, migrated_from_profile, current_git_commit_sha) " 144 "(org_node, soul_file_path, agents_file_path, migrated_from_profile, current_git_commit_sha) "
@@ -163,9 +150,9 @@ def record_agent_profiles(conn, current_git_commit_sha): @@ -163,9 +150,9 @@ def record_agent_profiles(conn, current_git_commit_sha):
163 "current_git_commit_sha=excluded.current_git_commit_sha", 150 "current_git_commit_sha=excluded.current_git_commit_sha",
164 ( 151 (
165 org_node, 152 org_node,
166 - os.path.relpath(_soul_path(org_node), _REPO_ROOT),  
167 - agents_file_path,  
168 - _MIGRATED_FROM_PROFILE[org_node], 153 + identity["soul_md_path"],
  154 + agents_md_path,
  155 + role["migrated_from_profile"],
169 current_git_commit_sha, 156 current_git_commit_sha,
170 ), 157 ),
171 ) 158 )
src/schema.py
@@ -80,7 +80,12 @@ CREATE TABLE org_graph_definition ( @@ -80,7 +80,12 @@ CREATE TABLE org_graph_definition (
80 -- references it) 80 -- references it)
81 CREATE TABLE skill ( 81 CREATE TABLE skill (
82 skill_id TEXT PRIMARY KEY, 82 skill_id TEXT PRIMARY KEY,
83 - owner_org_node TEXT NOT NULL CHECK (owner_org_node IN ('planner', 'coder', 'validator')), 83 + -- H (2026-08-06, Role Package abstraction): was CHECK(owner_org_node IN
  84 + -- ('planner','coder','validator')) -- relaxed to a plain non-empty-string
  85 + -- check so a new role, added purely via roles/<role_id>/role.json (no
  86 + -- schema.py/agent_profile.py code change), isn't rejected by a stale
  87 + -- hardcoded enum. See docs/todo/role-package-abstraction-design.md §4.
  88 + owner_org_node TEXT NOT NULL CHECK (owner_org_node != ''),
84 file_path TEXT NOT NULL, 89 file_path TEXT NOT NULL,
85 current_git_commit_sha TEXT NOT NULL, 90 current_git_commit_sha TEXT NOT NULL,
86 source TEXT NOT NULL CHECK (source IN ('migrated_from_hermes', 'new', 'auto_iterated')), 91 source TEXT NOT NULL CHECK (source IN ('migrated_from_hermes', 'new', 'auto_iterated')),
@@ -105,7 +110,8 @@ CREATE TABLE task ( @@ -105,7 +110,8 @@ CREATE TABLE task (
105 route TEXT CHECK (route IN ('fast_lane', 'full_replan')), 110 route TEXT CHECK (route IN ('fast_lane', 'full_replan')),
106 loop_round INTEGER NOT NULL DEFAULT 0, 111 loop_round INTEGER NOT NULL DEFAULT 0,
107 blocked_reason TEXT, 112 blocked_reason TEXT,
108 - assigned_org_node TEXT NOT NULL CHECK (assigned_org_node IN ('planner', 'coder', 'validator')), 113 + -- H (2026-08-06): see skill.owner_org_node's comment above -- same relaxation.
  114 + assigned_org_node TEXT NOT NULL CHECK (assigned_org_node != ''),
109 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 115 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
110 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 116 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
111 ); 117 );
@@ -118,7 +124,8 @@ CREATE INDEX idx_task_project_id ON task(project_id); @@ -118,7 +124,8 @@ CREATE INDEX idx_task_project_id ON task(project_id);
118 CREATE TABLE work_graph_run ( 124 CREATE TABLE work_graph_run (
119 run_id TEXT PRIMARY KEY, 125 run_id TEXT PRIMARY KEY,
120 task_id TEXT NOT NULL REFERENCES task(task_id), 126 task_id TEXT NOT NULL REFERENCES task(task_id),
121 - org_node TEXT NOT NULL CHECK (org_node IN ('planner', 'coder', 'validator')), 127 + -- H (2026-08-06): see skill.owner_org_node's comment above -- same relaxation.
  128 + org_node TEXT NOT NULL CHECK (org_node != ''),
122 graph_manifest TEXT NOT NULL, 129 graph_manifest TEXT NOT NULL,
123 orchestration_script_ref TEXT, 130 orchestration_script_ref TEXT,
124 status TEXT NOT NULL CHECK (status IN ( 131 status TEXT NOT NULL CHECK (status IN (
@@ -217,7 +224,8 @@ CREATE TABLE skill_iteration ( @@ -217,7 +224,8 @@ CREATE TABLE skill_iteration (
217 224
218 -- 1.9 agent_profile 225 -- 1.9 agent_profile
219 CREATE TABLE agent_profile ( 226 CREATE TABLE agent_profile (
220 - org_node TEXT PRIMARY KEY CHECK (org_node IN ('planner', 'coder', 'validator')), 227 + -- H (2026-08-06): see skill.owner_org_node's comment above -- same relaxation.
  228 + org_node TEXT PRIMARY KEY CHECK (org_node != ''),
221 soul_file_path TEXT NOT NULL, 229 soul_file_path TEXT NOT NULL,
222 agents_file_path TEXT, 230 agents_file_path TEXT,
223 migrated_from_profile TEXT NOT NULL CHECK (migrated_from_profile IN ('planning', 'code', 'qa')), 231 migrated_from_profile TEXT NOT NULL CHECK (migrated_from_profile IN ('planning', 'code', 'qa')),
@@ -426,3 +434,150 @@ def migrate_add_work_graph_fork_columns(conn): @@ -426,3 +434,150 @@ def migrate_add_work_graph_fork_columns(conn):
426 "REFERENCES work_node_execution(node_exec_id)" 434 "REFERENCES work_node_execution(node_exec_id)"
427 ) 435 )
428 conn.commit() 436 conn.commit()
  437 +
  438 +
  439 +def _table_sql(conn, table_name):
  440 + row = conn.execute(
  441 + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
  442 + ).fetchone()
  443 + return row[0] if row else None
  444 +
  445 +
  446 +def migrate_relax_org_node_constraints(conn):
  447 + """H (2026-08-06, Role Package abstraction): relaxes the 4 tables whose
  448 + org_node-ish column was CHECK-constrained to exactly ('planner',
  449 + 'coder', 'validator') down to CHECK(x != '') -- a new role, added
  450 + purely via roles/<role_id>/role.json (see src/agent_profile.py), no
  451 + longer gets rejected by a schema-level enum baked in at U-001.
  452 +
  453 + **Why all 4 need the create-copy-drop-rename dance, not a plain ALTER
  454 + TABLE**: SQLite has no `ALTER TABLE ... DROP CONSTRAINT` for CHECK.
  455 + `skill`/`task`/`work_graph_run` are referenced by several other tables'
  456 + foreign keys (work_node_execution, artifact, bug_record,
  457 + execution_event, work_graph_run's own self-reference) -- but since only
  458 + the PARENT table's own CHECK text changes (the FK column definitions
  459 + in those child tables, e.g. `REFERENCES task(task_id)`, don't change at
  460 + all), only these 4 parent tables need rebuilding, none of their
  461 + children. Followed the SQLite-documented procedure for schema changes
  462 + under active foreign keys ("Making Other Kinds Of Table Schema
  463 + Changes"): PRAGMA foreign_keys=OFF for the duration (FK enforcement
  464 + can't be toggled mid-transaction), rebuild, PRAGMA foreign_key_check to
  465 + confirm nothing went dangling, PRAGMA foreign_keys=ON again.
  466 +
  467 + Depends on migrate_add_work_graph_fork_columns() having already run
  468 + (called here unconditionally, it's idempotent) -- work_graph_run's
  469 + rebuild needs to know about D's parent_run_id/fork_of_node_exec_id
  470 + columns whether or not this specific database has seen that migration
  471 + yet.
  472 +
  473 + Idempotent: each table's rebuild is skipped if its stored CREATE TABLE
  474 + text no longer contains the old enum CHECK, so a fresh
  475 + create_schema()'d database (already on the new constraint) is a no-op,
  476 + same as migrate_expand_artifact_types's own convention.
  477 + """
  478 + migrate_add_work_graph_fork_columns(conn)
  479 + conn.commit()
  480 +
  481 + conn.execute("PRAGMA foreign_keys = OFF")
  482 + try:
  483 + if "owner_org_node IN (" in (_table_sql(conn, "skill") or ""):
  484 + conn.executescript(
  485 + """
  486 + CREATE TABLE skill_new (
  487 + skill_id TEXT PRIMARY KEY,
  488 + owner_org_node TEXT NOT NULL CHECK (owner_org_node != ''),
  489 + file_path TEXT NOT NULL,
  490 + current_git_commit_sha TEXT NOT NULL,
  491 + source TEXT NOT NULL CHECK (source IN ('migrated_from_hermes', 'new', 'auto_iterated')),
  492 + migrated_from_profile TEXT CHECK (migrated_from_profile IN ('code', 'planning', 'qa')),
  493 + status TEXT NOT NULL CHECK (status IN ('active', 'archived'))
  494 + );
  495 + INSERT INTO skill_new SELECT * FROM skill;
  496 + DROP TABLE skill;
  497 + ALTER TABLE skill_new RENAME TO skill;
  498 + """
  499 + )
  500 +
  501 + if "assigned_org_node IN (" in (_table_sql(conn, "task") or ""):
  502 + conn.executescript(
  503 + """
  504 + CREATE TABLE task_new (
  505 + task_id TEXT PRIMARY KEY,
  506 + project_id TEXT NOT NULL REFERENCES project(project_id),
  507 + goal_text TEXT NOT NULL,
  508 + task_type TEXT NOT NULL CHECK (task_type IN ('goal', 'bug_fix')),
  509 + origin_ref TEXT,
  510 + created_via TEXT NOT NULL DEFAULT 'api' CHECK (created_via IN ('api', 'human_console')),
  511 + status TEXT NOT NULL CHECK (status IN (
  512 + 'queued', 'planning_brd_prd_sdd', 'planning_failed',
  513 + 'coding', 'coding_failed', 'validating',
  514 + 'routed_fast_lane', 'routed_full_replan',
  515 + 'blocked_pending_human', 'done'
  516 + )),
  517 + route TEXT CHECK (route IN ('fast_lane', 'full_replan')),
  518 + loop_round INTEGER NOT NULL DEFAULT 0,
  519 + blocked_reason TEXT,
  520 + assigned_org_node TEXT NOT NULL CHECK (assigned_org_node != ''),
  521 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  522 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  523 + );
  524 + INSERT INTO task_new SELECT * FROM task;
  525 + DROP TABLE task;
  526 + ALTER TABLE task_new RENAME TO task;
  527 + CREATE INDEX idx_task_status ON task(status);
  528 + CREATE INDEX idx_task_assigned_org_node ON task(assigned_org_node);
  529 + CREATE INDEX idx_task_project_id ON task(project_id);
  530 + """
  531 + )
  532 +
  533 + if "org_node IN (" in (_table_sql(conn, "work_graph_run") or ""):
  534 + conn.executescript(
  535 + """
  536 + CREATE TABLE work_graph_run_new (
  537 + run_id TEXT PRIMARY KEY,
  538 + task_id TEXT NOT NULL REFERENCES task(task_id),
  539 + org_node TEXT NOT NULL CHECK (org_node != ''),
  540 + graph_manifest TEXT NOT NULL,
  541 + orchestration_script_ref TEXT,
  542 + status TEXT NOT NULL CHECK (status IN (
  543 + 'planning', 'running', 'merging', 'verifying',
  544 + 'reflecting', 'blocked', 'done', 'failed'
  545 + )),
  546 + started_at TEXT,
  547 + completed_at TEXT,
  548 + result_artifact_refs TEXT,
  549 + parent_run_id TEXT REFERENCES work_graph_run(run_id),
  550 + fork_of_node_exec_id TEXT REFERENCES work_node_execution(node_exec_id)
  551 + );
  552 + INSERT INTO work_graph_run_new SELECT * FROM work_graph_run;
  553 + DROP TABLE work_graph_run;
  554 + ALTER TABLE work_graph_run_new RENAME TO work_graph_run;
  555 + CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)
  556 + WHERE status IN ('planning', 'running', 'merging', 'verifying');
  557 + """
  558 + )
  559 +
  560 + if "org_node TEXT PRIMARY KEY CHECK (org_node IN (" in (_table_sql(conn, "agent_profile") or ""):
  561 + conn.executescript(
  562 + """
  563 + CREATE TABLE agent_profile_new (
  564 + org_node TEXT PRIMARY KEY CHECK (org_node != ''),
  565 + soul_file_path TEXT NOT NULL,
  566 + agents_file_path TEXT,
  567 + migrated_from_profile TEXT NOT NULL CHECK (migrated_from_profile IN ('planning', 'code', 'qa')),
  568 + current_git_commit_sha TEXT NOT NULL
  569 + );
  570 + INSERT INTO agent_profile_new SELECT * FROM agent_profile;
  571 + DROP TABLE agent_profile;
  572 + ALTER TABLE agent_profile_new RENAME TO agent_profile;
  573 + """
  574 + )
  575 +
  576 + conn.commit()
  577 + violations = conn.execute("PRAGMA foreign_key_check").fetchall()
  578 + if violations:
  579 + raise RuntimeError(
  580 + "migrate_relax_org_node_constraints left dangling foreign keys: {}".format(violations)
  581 + )
  582 + finally:
  583 + conn.execute("PRAGMA foreign_keys = ON")
src/test_agent_profile.py
@@ -5,6 +5,7 @@ src/test_agent_e2e.py (RUN_REAL_AGENT_E2E=1) for the actual A-2 validation. @@ -5,6 +5,7 @@ src/test_agent_e2e.py (RUN_REAL_AGENT_E2E=1) for the actual A-2 validation.
5 Run: python3 -m unittest src.test_agent_profile -v 5 Run: python3 -m unittest src.test_agent_profile -v
6 """ 6 """
7 7
  8 +import json
8 import os 9 import os
9 import tempfile 10 import tempfile
10 import unittest 11 import unittest
@@ -71,6 +72,70 @@ class RenderAgentDefinitionTest(unittest.TestCase): @@ -71,6 +72,70 @@ class RenderAgentDefinitionTest(unittest.TestCase):
71 self.assertNotIn("优先使用 claude --dangerously-skip-permissions", content) 72 self.assertNotIn("优先使用 claude --dangerously-skip-permissions", content)
72 73
73 74
  75 +class NewRoleWithoutCodeChangeTest(unittest.TestCase):
  76 + """H (2026-08-06), requirements doc acceptance standard 1: a brand-new
  77 + role, defined purely by adding a roles/<role_id>/role.json, is
  78 + recognized by these UNMODIFIED functions (render_agent_definition/
  79 + deploy_agent_definitions/record_agent_profiles) via the roles_dir
  80 + override -- no agent_profile.py/schema.py edit involved. Points
  81 + identity.soul_md_path at a real, already-existing repo file
  82 + (agents/planner/SOUL.md) rather than fabricating a new one -- this
  83 + standard is about the roles/ discovery + rendering mechanism, not
  84 + about proving a new identity file can be written (unrelated concern)."""
  85 +
  86 + def setUp(self):
  87 + self.tmp = tempfile.TemporaryDirectory()
  88 + self.addCleanup(self.tmp.cleanup)
  89 + role_dir = os.path.join(self.tmp.name, "researcher")
  90 + os.makedirs(role_dir)
  91 + role = {
  92 + "role_id": "researcher",
  93 + "description": "A brand-new role added with zero Python/schema changes.",
  94 + "migrated_from_profile": "planning",
  95 + "identity": {"soul_md_path": "agents/planner/SOUL.md", "agents_md_path": None},
  96 + "context": {"memory": "project"},
  97 + "model": "claude-sonnet-5",
  98 + "skills": ["planning-requirement-expansion"],
  99 + "applications": {"mcp_servers": []},
  100 + "workflows": {"dynamic_planning": "generic"},
  101 + "permissions": {"tools": ["Read", "Grep"], "disallowed_tools": [], "notes": []},
  102 + "acceptance_criteria": {"ref": "docs/sdd/06-验收与测试锚点.md"},
  103 + }
  104 + with open(os.path.join(role_dir, "role.json"), "w", encoding="utf-8") as f:
  105 + json.dump(role, f)
  106 +
  107 + def test_discovered_alongside_the_real_three_roles(self):
  108 + discovered = agent_profile._discover_role_ids(self.tmp.name)
  109 + self.assertIn("researcher", discovered)
  110 +
  111 + def test_render_agent_definition_recognizes_it(self):
  112 + content = agent_profile.render_agent_definition("researcher", roles_dir=self.tmp.name)
  113 + self.assertIn("name: researcher", content)
  114 + self.assertIn("- planning-requirement-expansion", content)
  115 +
  116 + def test_deploy_agent_definitions_writes_it_out(self):
  117 + with tempfile.TemporaryDirectory() as target:
  118 + agent_profile.deploy_agent_definitions(target, roles_dir=self.tmp.name)
  119 + path = os.path.join(target, ".claude", "agents", "researcher.md")
  120 + self.assertTrue(os.path.isfile(path))
  121 +
  122 + def test_record_agent_profiles_writes_a_row(self):
  123 + conn = schema.connect(":memory:")
  124 + try:
  125 + schema.create_schema(conn)
  126 + agent_profile.record_agent_profiles(conn, "deadbeef", roles_dir=self.tmp.name)
  127 + row = conn.execute(
  128 + "SELECT migrated_from_profile FROM agent_profile WHERE org_node = 'researcher'"
  129 + ).fetchone()
  130 + self.assertEqual(row, ("planning",))
  131 + finally:
  132 + conn.close()
  133 +
  134 + def test_unknown_role_still_rejected(self):
  135 + with self.assertRaises(ValueError):
  136 + agent_profile.render_agent_definition("no-such-role", roles_dir=self.tmp.name)
  137 +
  138 +
74 class DeployAgentDefinitionsTest(unittest.TestCase): 139 class DeployAgentDefinitionsTest(unittest.TestCase):
75 def test_writes_three_files_into_target_claude_agents(self): 140 def test_writes_three_files_into_target_claude_agents(self):
76 with tempfile.TemporaryDirectory() as tmp: 141 with tempfile.TemporaryDirectory() as tmp:
src/test_schema.py
@@ -550,5 +550,192 @@ class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase): @@ -550,5 +550,192 @@ class MigrateAddWorkGraphForkColumnsTest(unittest.TestCase):
550 fresh.close() 550 fresh.close()
551 551
552 552
  553 +def _old_org_node_enum_schema_sql():
  554 + """H (2026-08-06): simulates a pre-migration database whose 4
  555 + org_node-ish columns still have the old CHECK(x IN ('planner','coder',
  556 + 'validator')) enum, and whose work_graph_run predates D's fork columns
  557 + too (migrate_relax_org_node_constraints depends on
  558 + migrate_add_work_graph_fork_columns having run, so the test fixture
  559 + should start from a database that's had NEITHER migration, same as a
  560 + real old production db would be)."""
  561 + sql = schema.SCHEMA_SQL
  562 + for column in ("owner_org_node", "assigned_org_node", "org_node"):
  563 + sql = re.sub(
  564 + r"(?: --[^\n]*\n)* " + column + r" TEXT( NOT NULL)? CHECK \(" + column + r" != ''\)",
  565 + column + r" TEXT\1 CHECK (" + column + " IN ('planner', 'coder', 'validator'))",
  566 + sql,
  567 + )
  568 + sql = re.sub(
  569 + r"(?: --[^\n]*\n)* org_node TEXT PRIMARY KEY CHECK \(org_node != ''\)",
  570 + "org_node TEXT PRIMARY KEY CHECK (org_node IN ('planner', 'coder', 'validator'))",
  571 + sql,
  572 + )
  573 + sql = re.sub(
  574 + r",\n( --[^\n]*\n)* parent_run_id TEXT REFERENCES work_graph_run\(run_id\),\n"
  575 + r" fork_of_node_exec_id TEXT REFERENCES work_node_execution\(node_exec_id\)\n\);",
  576 + "\n);",
  577 + sql,
  578 + )
  579 + return sql
  580 +
  581 +
  582 +class MigrateRelaxOrgNodeConstraintsTest(unittest.TestCase):
  583 + """H (2026-08-06, Role Package abstraction): real upgrade path for an
  584 + already-populated database whose skill/task/work_graph_run/
  585 + agent_profile tables still enum-CHECK org_node to exactly
  586 + ('planner', 'coder', 'validator')."""
  587 +
  588 + def setUp(self):
  589 + old_sql = _old_org_node_enum_schema_sql()
  590 + self.assertNotIn("!= ''", old_sql) # sanity: the substitution actually matched
  591 + self.assertNotIn("parent_run_id", old_sql)
  592 +
  593 + self.conn = schema.connect(":memory:")
  594 + self.conn.execute("PRAGMA foreign_keys = ON")
  595 + self.conn.executescript(old_sql)
  596 + self.conn.commit()
  597 + self.conn.execute(
  598 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  599 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  600 + )
  601 + self.conn.execute(
  602 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  603 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'planner')"
  604 + )
  605 + self.conn.execute(
  606 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  607 + "VALUES ('run-1', 'task-1', 'planner', '{}', 'done')"
  608 + )
  609 + self.conn.execute(
  610 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  611 + "VALUES ('skill-1', 'planner', 'skills/x/', 'sha', 'new', 'active')"
  612 + )
  613 + self.conn.execute(
  614 + "INSERT INTO agent_profile (org_node, soul_file_path, migrated_from_profile, current_git_commit_sha) "
  615 + "VALUES ('planner', 'agents/planner/SOUL.md', 'planning', 'sha')"
  616 + )
  617 + self.conn.commit()
  618 +
  619 + def tearDown(self):
  620 + self.conn.close()
  621 +
  622 + def test_old_schema_rejects_a_new_role(self):
  623 + with self.assertRaises(sqlite3.IntegrityError):
  624 + self.conn.execute(
  625 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  626 + "VALUES ('task-2', 'proj-1', 'g', 'goal', 'queued', 'newrole')"
  627 + )
  628 +
  629 + def test_migration_preserves_existing_rows(self):
  630 + schema.migrate_relax_org_node_constraints(self.conn)
  631 + self.assertEqual(
  632 + self.conn.execute("SELECT task_id, assigned_org_node FROM task WHERE task_id = 'task-1'").fetchone(),
  633 + ("task-1", "planner"),
  634 + )
  635 + self.assertEqual(
  636 + self.conn.execute("SELECT skill_id, owner_org_node FROM skill WHERE skill_id = 'skill-1'").fetchone(),
  637 + ("skill-1", "planner"),
  638 + )
  639 + self.assertEqual(
  640 + self.conn.execute("SELECT run_id, org_node FROM work_graph_run WHERE run_id = 'run-1'").fetchone(),
  641 + ("run-1", "planner"),
  642 + )
  643 + self.assertEqual(
  644 + self.conn.execute("SELECT org_node FROM agent_profile WHERE org_node = 'planner'").fetchone(),
  645 + ("planner",),
  646 + )
  647 +
  648 + def test_migration_accepts_a_new_role_afterward(self):
  649 + schema.migrate_relax_org_node_constraints(self.conn)
  650 + self.conn.execute(
  651 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  652 + "VALUES ('task-2', 'proj-1', 'g', 'goal', 'queued', 'newrole')"
  653 + )
  654 + self.conn.execute(
  655 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  656 + "VALUES ('skill-2', 'newrole', 'skills/y/', 'sha', 'new', 'active')"
  657 + )
  658 + self.conn.execute(
  659 + "INSERT INTO agent_profile (org_node, soul_file_path, migrated_from_profile, current_git_commit_sha) "
  660 + "VALUES ('newrole', 'roles/newrole/SOUL.md', 'planning', 'sha')"
  661 + )
  662 + self.conn.commit()
  663 + self.assertEqual(self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0], 2)
  664 +
  665 + def test_migration_still_rejects_empty_string(self):
  666 + schema.migrate_relax_org_node_constraints(self.conn)
  667 + with self.assertRaises(sqlite3.IntegrityError):
  668 + self.conn.execute(
  669 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  670 + "VALUES ('task-2', 'proj-1', 'g', 'goal', 'queued', '')"
  671 + )
  672 +
  673 + def test_ux_work_graph_run_active_still_enforced_after_rebuild(self):
  674 + schema.migrate_relax_org_node_constraints(self.conn)
  675 + self.conn.execute(
  676 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  677 + "VALUES ('run-2', 'task-1', 'coder', '{}', 'running')"
  678 + )
  679 + with self.assertRaises(sqlite3.IntegrityError):
  680 + self.conn.execute(
  681 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  682 + "VALUES ('run-3', 'task-1', 'coder', '{}', 'running')"
  683 + )
  684 +
  685 + def test_task_indexes_survive_rebuild(self):
  686 + schema.migrate_relax_org_node_constraints(self.conn)
  687 + index_names = {
  688 + row[1] for row in self.conn.execute("PRAGMA index_list(task)").fetchall()
  689 + }
  690 + self.assertIn("idx_task_status", index_names)
  691 + self.assertIn("idx_task_assigned_org_node", index_names)
  692 + self.assertIn("idx_task_project_id", index_names)
  693 +
  694 + def test_foreign_key_relationships_survive_rebuild(self):
  695 + # work_node_execution references both skill and work_graph_run --
  696 + # confirms the child tables (never rebuilt themselves) still see a
  697 + # valid parent after skill/work_graph_run were dropped and recreated.
  698 + schema.migrate_relax_org_node_constraints(self.conn)
  699 + self.conn.execute(
  700 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  701 + "input_schema_ref, output_schema_ref, input_payload, status) "
  702 + "VALUES ('node-1', 'run-1', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', 'done')"
  703 + )
  704 + self.conn.commit()
  705 + with self.assertRaises(sqlite3.IntegrityError):
  706 + self.conn.execute(
  707 + "INSERT INTO work_node_execution (node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  708 + "input_schema_ref, output_schema_ref, input_payload, status) "
  709 + "VALUES ('node-2', 'no-such-run', 'n1', 'planner', 'skill-1', 'in.json', 'out.json', '{}', 'done')"
  710 + )
  711 +
  712 + def test_idempotent_on_a_database_already_migrated(self):
  713 + schema.migrate_relax_org_node_constraints(self.conn)
  714 + schema.migrate_relax_org_node_constraints(self.conn) # must not raise
  715 + count = self.conn.execute("SELECT COUNT(*) FROM task").fetchone()[0]
  716 + self.assertEqual(count, 1)
  717 +
  718 + def test_foreign_keys_pragma_restored_after_migration(self):
  719 + schema.migrate_relax_org_node_constraints(self.conn)
  720 + self.assertEqual(self.conn.execute("PRAGMA foreign_keys").fetchone()[0], 1)
  721 +
  722 + def test_noop_on_a_freshly_created_already_relaxed_database(self):
  723 + fresh = schema.connect(":memory:")
  724 + try:
  725 + schema.create_schema(fresh)
  726 + schema.migrate_relax_org_node_constraints(fresh) # must not raise
  727 + fresh.execute(
  728 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  729 + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')"
  730 + )
  731 + fresh.execute(
  732 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  733 + "VALUES ('task-1', 'proj-1', 'g', 'goal', 'queued', 'newrole')"
  734 + )
  735 + fresh.commit()
  736 + finally:
  737 + fresh.close()
  738 +
  739 +
553 if __name__ == "__main__": 740 if __name__ == "__main__":
554 unittest.main() 741 unittest.main()
src/work_graph_run_query.py
@@ -22,7 +22,14 @@ helper is private to its own module and this module already owns @@ -22,7 +22,14 @@ helper is private to its own module and this module already owns
22 22
23 import json 23 import json
24 24
25 -_VALID_ORG_NODES = ("planner", "coder", "validator") 25 +from src import agent_profile
  26 +
  27 +# H (2026-08-06): was its own hardcoded ('planner', 'coder', 'validator')
  28 +# tuple -- a real dependency the requirements doc's own risk analysis
  29 +# missed (see docs/todo/graph-engineering-open-items.md's H review note).
  30 +# Now reuses agent_profile.ORG_NODES so this validation stays data-driven
  31 +# too, not a second place someone has to remember to update for a new role.
  32 +_VALID_ORG_NODES = agent_profile.ORG_NODES
26 33
27 34
28 class InvalidParamError(Exception): 35 class InvalidParamError(Exception):