For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Deliver FormCreate dept control so designers can drag「部门」, initiators can single/multi-select tenant org tree with full-path name snapshots, and narrow viewports use a full-screen/drawer picker.
Architecture: Mirror the relatedApproval four-layer FcDesigner extension: OaDeptSelect.vue + DragRule + setupOaDeptSelect on both main app and oa-fc-host. Persist { id, name } (or array). Backend validates shape on start_instance. Print/summary format name with顿号.
Tech Stack: FastAPI · pytest · Vue 3 · FormCreate / @form-create/designer · Element Plus (el-tree-select, el-drawer) · fetchDeptTree · buildPathLabelMapFromTreeRoots
Spec: docs/superpowers/specs/2026-07-27-oa-dept-select-design.md
Demo: docs/mockups/oa-dept-select-demo.html
| Path | Responsibility |
|---|---|
Create: backend/app/services/oa_dept_select.py |
Walk schema; validate dept form_data shapes |
Create: backend/tests/test_oa_dept_select.py |
Unit tests for validation |
Modify: backend/app/services/oa_engine.py |
Call validate before persist on start |
Modify: backend/app/services/oa_related_approval.py |
Format dept in related-summary flatten |
Create: web-admin/src/utils/oa-dept-select-rule.ts |
DragRule + field unique helper |
Create: web-admin/src/utils/oa-form-create-dept.ts |
setupOaDeptSelect registration |
Create: web-admin/src/components/oa-approval/OaDeptSelect.vue |
Runtime PC tree-select + narrow drawer |
Create: web-admin/src/utils/oa-dept-value.ts |
Normalize/format {id,name} helpers (shared print + component) |
Modify: web-admin/src/main.ts |
Call setupOaDeptSelect |
Modify: web-admin/src/oa-fc-host/main.ts |
Same setup |
Modify: web-admin/src/utils/oa-approval-print.ts |
Print dept as path text, not JSON |
Files:
backend/app/services/oa_dept_select.pyCreate: backend/tests/test_oa_dept_select.py
[ ] Step 1: Write failing tests
# backend/tests/test_oa_dept_select.py
import unittest
from app.services.oa_dept_select import validate_dept_form_values
class TestValidateDeptFormValues(unittest.TestCase):
def test_multiple_ok(self):
schema = {
"rule": [
{
"type": "dept",
"field": "depts",
"title": "部门",
"$required": True,
"props": {"multiple": True, "max": 2},
}
]
}
data = {
"depts": [
{"id": "d1", "name": "中科兴皖 / 研发"},
{"id": "d2", "name": "中科兴皖 / 质量"},
]
}
self.assertEqual(validate_dept_form_values(schema, data), [])
def test_required_empty_array(self):
schema = {
"rule": [
{
"type": "dept",
"field": "depts",
"$required": True,
"props": {"multiple": True},
}
]
}
errs = validate_dept_form_values(schema, {"depts": []})
self.assertTrue(any("必填" in e or "部门" in e for e in errs))
def test_single_must_be_object(self):
schema = {
"rule": [
{
"type": "dept",
"field": "dept",
"props": {"multiple": False},
}
]
}
errs = validate_dept_form_values(schema, {"dept": [{"id": "d1", "name": "A"}]})
self.assertTrue(len(errs) >= 1)
def test_max_exceeded(self):
schema = {
"rule": [
{
"type": "dept",
"field": "depts",
"props": {"multiple": True, "max": 1},
}
]
}
data = {
"depts": [
{"id": "d1", "name": "A"},
{"id": "d2", "name": "B"},
]
}
errs = validate_dept_form_values(schema, data)
self.assertTrue(any("最大" in e or "上限" in e for e in errs))
def test_nested_fc_row(self):
schema = {
"rule": [
{
"type": "fcRow",
"children": [
{
"type": "dept",
"field": "d",
"required": True,
"props": {"multiple": False},
}
],
}
]
}
errs = validate_dept_form_values(schema, {})
self.assertTrue(len(errs) >= 1)
def test_no_dept_fields_ok(self):
schema = {"rule": [{"type": "input", "field": "t"}]}
self.assertEqual(validate_dept_form_values(schema, {"t": "x"}), [])
if __name__ == "__main__":
unittest.main()
cd backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py -v
Expected: ModuleNotFoundError or import error for oa_dept_select.
# backend/app/services/oa_dept_select.py
"""OA 部门控件:form_data 形态校验"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def _walk_rules(rules: Any, out: List[dict]) -> None:
if not isinstance(rules, list):
return
for r in rules:
if not isinstance(r, dict):
continue
out.append(r)
children = r.get("children") or (r.get("props") or {}).get("rule") or []
if isinstance(children, list):
_walk_rules(children, out)
def _schema_rules(schema: Any) -> List[dict]:
if isinstance(schema, list):
out: List[dict] = []
_walk_rules(schema, out)
return out
if not isinstance(schema, dict):
return []
out: List[dict] = []
_walk_rules(schema.get("rule") or schema.get("rules") or [], out)
return out
def _is_required(rule: dict) -> bool:
if rule.get("$required") is True or rule.get("required") is True:
return True
props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
return props.get("required") is True
def _is_multiple(rule: dict) -> bool:
props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
# 默认多选(与设计一致)
if "multiple" not in props:
return True
return props.get("multiple") is True
def _max_count(rule: dict) -> Optional[int]:
props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
raw = props.get("max")
if raw is None or raw == "":
return None
try:
n = int(raw)
except (TypeError, ValueError):
return None
return n if n > 0 else None
def _valid_item(obj: Any) -> bool:
if not isinstance(obj, dict):
return False
id_ = str(obj.get("id") or "").strip()
name = str(obj.get("name") or "").strip()
return bool(id_) and bool(name)
def validate_dept_form_values(
schema: Any, form_data: Optional[Dict[str, Any]]
) -> List[str]:
"""返回人类可读错误列表;空列表表示通过。不校验 id 是否仍存在于组织树。"""
data = form_data or {}
errors: List[str] = []
for rule in _schema_rules(schema):
if str(rule.get("type") or "") != "dept":
continue
field = str(rule.get("field") or "").strip()
if not field:
continue
title = str(rule.get("title") or rule.get("label") or field)
required = _is_required(rule)
multiple = _is_multiple(rule)
max_n = _max_count(rule)
raw = data.get(field)
if multiple:
if raw is None or raw == "":
if required:
errors.append(f"「{title}」为必填部门字段")
continue
if not isinstance(raw, list):
errors.append(f"「{title}」多选时须为数组")
continue
if required and len(raw) == 0:
errors.append(f"「{title}」为必填部门字段")
continue
if max_n is not None and len(raw) > max_n:
errors.append(f"「{title}」最多可选 {max_n} 个部门")
continue
for i, item in enumerate(raw):
if not _valid_item(item):
errors.append(f"「{title}」第 {i + 1} 项须含非空 id 与 name")
break
else:
if raw is None or raw == "":
if required:
errors.append(f"「{title}」为必填部门字段")
continue
if isinstance(raw, list):
errors.append(f"「{title}」单选时须为对象而非数组")
continue
if not _valid_item(raw):
errors.append(f"「{title}」须为含非空 id 与 name 的对象")
return errors
cd backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py -v
Expected: all tests PASS.
git add backend/app/services/oa_dept_select.py backend/tests/test_oa_dept_select.py
git commit -m "feat(oa): validate dept control form_data shapes"
Files:
Modify: backend/app/services/oa_engine.py (inside start_instance, after apply_formulas_and_linkages)
[ ] Step 1: Add import and call
After:
try:
data = apply_formulas_and_linkages(schema, data)
except FormulaError as e:
raise OaEngineError(f"公式计算失败: {e}") from e
Insert:
from app.services.oa_dept_select import validate_dept_form_values
dept_errs = validate_dept_form_values(schema, data)
if dept_errs:
raise OaEngineError(dept_errs[0])
cd backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py tests/test_oa_related_approval.py tests/test_oa_publish.py -v --tb=short
Expected: PASS (no new failures).
git add backend/app/services/oa_engine.py
git commit -m "feat(oa): reject invalid dept form_data on start"
Files:
web-admin/src/utils/oa-dept-value.tsweb-admin/src/utils/oa-dept-select-rule.tsweb-admin/src/utils/oa-form-create-dept.tsweb-admin/src/main.tsModify: web-admin/src/oa-fc-host/main.ts
[ ] Step 1: Create value helpers
// web-admin/src/utils/oa-dept-value.ts
export type OaDeptValueItem = { id: string; name: string }
export function isDeptValueItem(v: unknown): v is OaDeptValueItem {
if (!v || typeof v !== 'object') return false
const o = v as Record<string, unknown>
return String(o.id || '').trim() !== '' && String(o.name || '').trim() !== ''
}
/** 单选 → item|null;多选 → items(容错把单对象包成数组) */
export function normalizeDeptModelValue(
raw: unknown,
multiple: boolean
): OaDeptValueItem | OaDeptValueItem[] | null {
if (multiple) {
if (Array.isArray(raw)) {
return raw.filter(isDeptValueItem).map((x) => ({
id: String(x.id).trim(),
name: String(x.name).trim()
}))
}
if (isDeptValueItem(raw)) {
return [{ id: String(raw.id).trim(), name: String(raw.name).trim() }]
}
return []
}
if (isDeptValueItem(raw)) {
return { id: String(raw.id).trim(), name: String(raw.name).trim() }
}
if (Array.isArray(raw) && raw.length === 1 && isDeptValueItem(raw[0])) {
return { id: String(raw[0].id).trim(), name: String(raw[0].name).trim() }
}
return null
}
export function formatDeptDisplay(raw: unknown): string {
if (Array.isArray(raw)) {
return raw
.filter(isDeptValueItem)
.map((x) => x.name)
.filter(Boolean)
.join('、')
}
if (isDeptValueItem(raw)) return raw.name
return ''
}
// web-admin/src/utils/oa-dept-select-rule.ts
let _uidSeq = 0
function uniqueId() {
const num = 370 + ++_uidSeq
return (
'F' +
Math.random().toString(36).slice(3, 6) +
Number(`${Date.now()}`).toString(36) +
num.toString(36) +
'c'
)
}
export const deptSelectDragRule = {
menu: 'main' as const,
icon: 'icon-tree',
label: '部门',
name: 'dept',
input: true,
mask: true,
validate: ['string', 'array', 'object'],
event: ['change'],
rule() {
return {
type: 'dept',
field: uniqueId(),
title: '部门',
info: '',
$required: false,
props: {
multiple: true,
max: undefined
}
}
},
props() {
return [
{
type: 'switch',
field: 'multiple',
title: '允许多选',
value: true,
props: { activeText: '是', inactiveText: '否' }
},
{
type: 'inputNumber',
field: 'max',
title: '最大可选数',
value: undefined,
props: { min: 1, precision: 0, placeholder: '不填则不限制' },
// 单选时可隐藏:若设计器不支持联动,保留字段但运行时仅 multiple 时读取
},
{
type: 'input',
field: 'formCreateInfo',
title: '提示信息'
}
]
}
}
// web-admin/src/utils/oa-form-create-dept.ts
import FcDesigner from '@form-create/designer'
import OaDeptSelect from '@/components/oa-approval/OaDeptSelect.vue'
import { deptSelectDragRule } from '@/utils/oa-dept-select-rule'
const FLAG = '__oaDeptSelectConfigured'
export function setupOaDeptSelect(formCreate?: { component?: (name: string, c: unknown) => void }) {
if ((FcDesigner as any)[FLAG]) return
FcDesigner.component('dept', OaDeptSelect)
if (typeof formCreate?.component === 'function') {
formCreate.component('dept', OaDeptSelect)
}
if (typeof FcDesigner.addDragRule === 'function') {
FcDesigner.addDragRule(deptSelectDragRule)
}
;(FcDesigner as any)[FLAG] = true
}
In web-admin/src/main.ts, after setupOaRelatedApproval:
import { setupOaDeptSelect } from '@/utils/oa-form-create-dept'
// ...
setupOaRelatedApproval(FcDesigner.formCreate)
setupOaDeptSelect(FcDesigner.formCreate)
In web-admin/src/oa-fc-host/main.ts, same import + call after related approval setup.
If committing this task alone first, create a minimal stub:
<!-- web-admin/src/components/oa-approval/OaDeptSelect.vue -->
<template>
<div class="oa-dept-select">部门选择(stub)</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'dept' })
defineProps<{ modelValue?: unknown; disabled?: boolean; multiple?: boolean; max?: number }>()
defineEmits<{ (e: 'update:modelValue', v: unknown): void; (e: 'change', v: unknown): void }>()
</script>
Then commit:
git add web-admin/src/utils/oa-dept-value.ts \
web-admin/src/utils/oa-dept-select-rule.ts \
web-admin/src/utils/oa-form-create-dept.ts \
web-admin/src/components/oa-approval/OaDeptSelect.vue \
web-admin/src/main.ts \
web-admin/src/oa-fc-host/main.ts
git commit -m "feat(oa): register dept FormCreate control scaffold"
OaDeptSelect.vue (PC + narrow)Files:
Modify: web-admin/src/components/oa-approval/OaDeptSelect.vue (replace stub)
[ ] Step 1: Implement full component
Key behaviors (must match spec):
fetchDeptTree(), build pathMap via buildPathLabelMapFromTreeRoots(tree, { nameKey: 'deptName' }).data: map nodes to { value: id, label: deptName, children }.{ id, name: pathMap.get(id) || label }.multiple prop default true; when false emit single object or null.max: when multiple and length >= max, prevent adding (ElMessage warning).disabled / readonly: show text via formatDeptDisplay, no picker.matchMedia('(max-width: 767px)')): hide tree-select; show trigger + el-drawer with el-tree (show-checkbox if multiple), search filter, 取消/确定; only confirm writes model.modal-class without blur; footer buttons with Close/Check icons.<!-- web-admin/src/components/oa-approval/OaDeptSelect.vue -->
<template>
<div class="oa-dept-select">
<template v-if="disabled">
<span class="oa-dept-select__readonly">{{ displayText || '—' }}</span>
</template>
<template v-else-if="isNarrow">
<button type="button" class="oa-dept-select__trigger" @click="openDrawer">
<span v-if="displayText" class="oa-dept-select__trigger-text">{{ displayText }}</span>
<span v-else class="oa-dept-select__placeholder">请选择部门</span>
<span class="oa-dept-select__chev">▾</span>
</button>
<el-drawer
v-model="drawerVisible"
title="选择部门"
direction="btt"
size="75%"
append-to-body
:close-on-click-modal="false"
class="oa-dept-drawer"
modal-class="protocol-dialog-mask"
>
<el-input
v-model="keyword"
clearable
placeholder="搜索部门名称"
class="oa-dept-select__search"
/>
<el-tree
ref="treeRef"
class="oa-dept-select__tree"
:data="treeSelectData"
node-key="value"
:props="{ label: 'label', children: 'children' }"
:filter-node-method="filterNode"
:show-checkbox="multiple"
:check-strictly="true"
:highlight-current="!multiple"
default-expand-all
@node-click="onNarrowNodeClick"
@check="onNarrowCheck"
/>
<template #footer>
<el-button @click="drawerVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button type="primary" @click="confirmDrawer">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-drawer>
</template>
<el-tree-select
v-else
:model-value="treeSelectModel"
class="oa-dept-select__tree-select"
:data="treeSelectData"
:props="{ value: 'value', label: 'label', children: 'children' }"
:multiple="multiple"
:render-after-expand="false"
filterable
clearable
check-strictly
default-expand-all
placeholder="请选择部门"
style="width: 100%"
@update:model-value="onTreeSelectChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Check, Close } from '@element-plus/icons-vue'
import { fetchDeptTree } from '@/api/admin'
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
import {
formatDeptDisplay,
normalizeDeptModelValue,
type OaDeptValueItem
} from '@/utils/oa-dept-value'
defineOptions({ name: 'dept' })
const props = withDefaults(
defineProps<{
modelValue?: unknown
disabled?: boolean
multiple?: boolean
max?: number | null
formCreateInject?: Record<string, unknown>
}>(),
{ multiple: true, max: null }
)
const emit = defineEmits<{
(e: 'update:modelValue', v: OaDeptValueItem | OaDeptValueItem[] | null): void
(e: 'change', v: OaDeptValueItem | OaDeptValueItem[] | null): void
}>()
type TreeNode = { value: string; label: string; children?: TreeNode[] }
const pathMap = ref(new Map<string, string>())
const treeSelectData = ref<TreeNode[]>([])
const isNarrow = ref(false)
const drawerVisible = ref(false)
const keyword = ref('')
const pendingIds = ref<string[]>([])
const treeRef = ref<{
filter: (q: string) => void
setCheckedKeys: (keys: string[]) => void
setCurrentKey: (key: string | null) => void
getCheckedKeys: () => string[]
getCurrentKey: () => string | null
} | null>(null)
let mql: MediaQueryList | null = null
const normalized = computed(() => normalizeDeptModelValue(props.modelValue, props.multiple))
const displayText = computed(() => formatDeptDisplay(normalized.value))
const treeSelectModel = computed(() => {
const n = normalized.value
if (props.multiple) {
return Array.isArray(n) ? n.map((x) => x.id) : []
}
return n && !Array.isArray(n) ? n.id : undefined
})
function mapTree(nodes: Api.Dept.DeptItem[]): TreeNode[] {
return (nodes || []).map((n) => ({
value: n.id,
label: n.deptName || n.id,
children: n.children?.length ? mapTree(n.children) : undefined
}))
}
function emitFromIds(ids: string[]) {
const items: OaDeptValueItem[] = []
const seen = new Set<string>()
for (const id of ids) {
const tid = String(id || '').trim()
if (!tid || seen.has(tid)) continue
seen.add(tid)
const name = pathMap.value.get(tid) || tid
items.push({ id: tid, name })
}
if (props.multiple) {
const max = props.max != null && Number(props.max) > 0 ? Number(props.max) : null
if (max != null && items.length > max) {
ElMessage.warning(`最多可选 ${max} 个部门`)
return
}
emit('update:modelValue', items)
emit('change', items)
} else {
const one = items[0] || null
emit('update:modelValue', one)
emit('change', one)
}
}
function onTreeSelectChange(val: string | string[] | null | undefined) {
if (props.multiple) {
emitFromIds(Array.isArray(val) ? val.map(String) : [])
} else {
emitFromIds(val ? [String(val)] : [])
}
}
function openDrawer() {
const n = normalized.value
if (props.multiple) {
pendingIds.value = Array.isArray(n) ? n.map((x) => x.id) : []
} else {
pendingIds.value = n && !Array.isArray(n) ? [n.id] : []
}
drawerVisible.value = true
nextTick(() => {
if (props.multiple) {
treeRef.value?.setCheckedKeys(pendingIds.value)
} else {
treeRef.value?.setCurrentKey(pendingIds.value[0] || null)
}
})
}
function onNarrowNodeClick(data: TreeNode) {
if (props.multiple) return
pendingIds.value = [data.value]
treeRef.value?.setCurrentKey(data.value)
}
function onNarrowCheck() {
if (!props.multiple) return
pendingIds.value = (treeRef.value?.getCheckedKeys() || []).map(String)
}
function confirmDrawer() {
let ids = pendingIds.value
if (!props.multiple) {
const cur = treeRef.value?.getCurrentKey()
ids = cur ? [String(cur)] : []
} else {
ids = (treeRef.value?.getCheckedKeys() || []).map(String)
}
emitFromIds(ids)
drawerVisible.value = false
}
function filterNode(q: string, data: TreeNode) {
if (!q) return true
return (data.label || '').includes(q)
}
watch(keyword, (q) => {
treeRef.value?.filter(q || '')
})
async function loadTree() {
try {
const tree = (await fetchDeptTree()) || []
pathMap.value = buildPathLabelMapFromTreeRoots(tree, { nameKey: 'deptName' })
treeSelectData.value = mapTree(tree)
} catch {
pathMap.value = new Map()
treeSelectData.value = []
}
}
function syncNarrow(e?: MediaQueryList | MediaQueryListEvent) {
isNarrow.value = !!(e ? ('matches' in e ? e.matches : (e as MediaQueryList).matches) : mql?.matches)
}
onMounted(() => {
loadTree()
mql = window.matchMedia('(max-width: 767px)')
syncNarrow(mql)
mql.addEventListener('change', syncNarrow)
})
onBeforeUnmount(() => {
mql?.removeEventListener('change', syncNarrow)
})
</script>
<style scoped lang="scss">
.oa-dept-select {
width: 100%;
}
.oa-dept-select__readonly {
color: var(--mint-text, #0f172a);
font-size: 14px;
line-height: 1.5;
word-break: break-all;
}
.oa-dept-select__trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 36px;
padding: 4px 12px;
border: 1px solid var(--mint-border, #cbd5e1);
border-radius: 8px;
background: #fff;
text-align: left;
cursor: pointer;
}
.oa-dept-select__placeholder {
color: var(--mint-text-muted, #94a3b8);
}
.oa-dept-select__trigger-text {
color: var(--mint-text, #0f172a);
word-break: break-all;
}
.oa-dept-select__chev {
margin-left: 8px;
color: var(--mint-text-muted, #94a3b8);
font-size: 11px;
}
.oa-dept-select__search {
margin-bottom: 10px;
}
.oa-dept-select__tree {
max-height: 50vh;
overflow: auto;
}
</style>
Notes for implementer:
ElMessage is auto-imported in main app; in oa-fc-host ensure Element Plus message works (already global).check-strictly so parent/child independent (any level selectable).Designer’s phone preview iframe may be narrow — drawer path will activate there.
[ ] Step 2: Smoke in browser
/#/system-manage/oa-approval/designer/:id →「2 表单设计」→ left「部门」exists.git add web-admin/src/components/oa-approval/OaDeptSelect.vue \
web-admin/src/utils/oa-dept-value.ts \
web-admin/src/utils/oa-dept-select-rule.ts \
web-admin/src/utils/oa-form-create-dept.ts \
web-admin/src/main.ts \
web-admin/src/oa-fc-host/main.ts
git commit -m "feat(oa): add dept select runtime with PC and narrow pickers"
Files:
web-admin/src/utils/oa-approval-print.tsbackend/app/services/oa_related_approval.pyModify: backend/tests/test_oa_related_approval.py (optional assert for dept format)
[ ] Step 1: Print special-case
In walkRules, before generic formatFieldValue:
import { formatDeptDisplay } from '@/utils/oa-dept-value'
// inside walkRules loop, after relatedApproval block:
if (type === 'dept') {
const text = formatDeptDisplay(value)
out.push({ label, text: text || '-' })
continue
}
In oa_related_approval.py _format_value, before generic dict handling:
if typ == "dept":
if isinstance(raw, list):
names = []
for x in raw:
if isinstance(x, dict):
n = str(x.get("name") or "").strip()
if n:
names.append(n)
return "、".join(names) if names else "", False
if isinstance(raw, dict):
return str(raw.get("name") or "").strip(), False
return "", False
git add web-admin/src/utils/oa-approval-print.ts \
backend/app/services/oa_related_approval.py \
backend/tests/test_oa_related_approval.py
git commit -m "feat(oa): format dept values in print and related summaries"
Files: none required for code (ops)
./scripts/update-graph.sh all
Commit graph artifacts if the script dirties tracked files:
git add backend/graphify-out web-admin/graphify-out
git commit -m "chore(graphify): sync after oa dept select"
[ ] Step 2: Manual E2E (human)
[ ] Designer left menu shows「部门」; drag works
[ ] Props: multiple on/off; max limits multi select
[ ] PC tree-select any level; chips/path use /
[ ] Narrow / phone preview: drawer can finish selection
[ ] Submit stores {id,name} / array; rename dept → old instance still shows snapshot name
[ ] Print shows顿号-joined paths, not JSON
[ ] App without dept unchanged
| Spec section | Task |
|---|---|
Custom type=dept four-layer |
3, 4 |
| Default multiple + prop toggle | 3 (DragRule), 4 |
{id,name} / array + full path snapshot |
1, 4 |
| Any tree level | 4 (check-strictly) |
| Narrow full-screen/drawer | 4 |
| Backend validate on start | 1, 2 |
| No condition-branch switch | (no code; do not add to CONDITION_ELIGIBLE) |
| Print / summary | 5 |
Publish whitelist already has dept |
(no change) |
Plan complete and saved to docs/superpowers/plans/2026-07-27-oa-dept-select.md. Two execution options:
1. Subagent-Driven (recommended) — dispatch a fresh subagent per task, review between tasks
2. Inline Execution — run tasks in this session with executing-plans and checkpoints
Which approach?