首页
/ gstack /autoplan Implementation Tasks Aggregator 原理与实战:多阶段评审任务的跨阶段汇聚管道

gstack /autoplan Implementation Tasks Aggregator 原理与实战:多阶段评审任务的跨阶段汇聚管道

2026-09-06 19:02:16作者:舒璇辛Bertina

导读

本文详解 gstack 的 /autoplan 自动评审流水线中,Phase 4「Final Approval Gate」所使用的 Implementation Tasks Aggregator(任务汇聚器):它把 CEO、Design、Eng、DX 四个评审阶段各自写入磁盘的 JSONL 任务清单,聚合、去重、排序后渲染成一个统一的 ### Implementation Tasks 勾选清单。读完本文你将掌握:任务 JSONL 的落盘格式与文件命名规则、聚合脚本每一段 jq 管道的语义(分支/提交窗口过滤、run_id 去重、精确去重与优先级排序)、运行时变量替换与构建期模板占位符的区别,以及那个让聚合器"永远输出零任务"的 #2018 jq 上下文重绑定陷阱及其回归测试。

本文对应的权威文档是 tasks-aggregator.md,它在整个流水线中的触发时机由 autoplan/SKILL.md 的 Section index 与 sections/manifest.json 共同注册:在呈现 Final Approval Gate(Phase 4)时读取,聚合器算出的 $AGGREGATED_TASKS 会被闸门消息替换进去。全文所有代码与路径均可在当前仓库中逐一验证。


一、聚合器在 /autoplan 流水线中的位置

/autoplan 的目标是"一条命令,粗糙计划进、全量评审计划出":它从磁盘读取四个评审 skill(CEO、Design、Eng、DX)并按严格顺序串行执行——CEO → Design → Eng → DX,每一阶段必须完整结束后才进入下一阶段(见 autoplan/SKILL.md 的 "Sequential Execution — MANDATORY")。中间的所有 AskUserQuestion 都基于 6 条决策原则自动裁决,品味类决策(close approaches、borderline scope、codex 分歧)与 User Challenge 被保留到最后的 Final Approval Gate 让用户做最终判断。

每个评审阶段在结束时不仅要产出评审结论,还要为后续实施沉淀一份"可构建的扁平任务列表"。聚合器就是这些阶段产物与最终闸门之间的汇流点:

  • 上游:四个评审 skill 各自写入 tasks-<phase>-*.jsonl 任务工件;
  • 聚合器:把四份列表合并为一个全局有序、无重复的清单;
  • 下游:Final Approval Gate 输出模板中的 ### Implementation Tasks (aggregated across phases) 小节直接渲染这个清单,供用户在 A) 批准 / B) 覆盖 / C) 盘问 / D) 修订 / E) 拒绝之间做选择。

值得强调的是,聚合器不是用户手动运行的一个命令,而是一段由 Agent 在运行时执行的 bash/jq 指令序列。gstack 以"决策树骨架"方式组织 skill:主 SKILL 只负责编排(读哪个 section、何时读),而 tasks-aggregator.md 是 Phase 4 这一具体步骤的"真源"(source of truth)。SKILL 中的指引明确写着:

Read ~/.claude/skills/gstack/autoplan/sections/tasks-aggregator.md and execute it in full. Do not work from memory — that section is the source of truth for this step.

也就是说,任何人(含 Agent 自身)想理解 Phase 4 之前如何准备任务清单,只需读这一个文件即可。


二、数据源:每个评审阶段写下的 JSONL 任务工件

2.1 工件格式与写入端

sections/manifest.json 中,聚合器被声明为 id: "tasks-aggregator",对应 Phase 4。而它的上游——每个评审阶段"逐任务写 JSONL"的行为——由解析器 {{TASKS_SECTION_EMIT:<phase>}} 负责渲染,实现在 scripts/resolvers/tasks-section.ts。该文件头部注释直接说明了两种解析器的分工:

  • {{TASKS_SECTION_EMIT:<phase>}}:单 skill 的任务产出 + JSONL 写入(CEO/Design/Eng/DX 四个评审各自使用);
  • {{TASKS_SECTION_AGGREGATE}}:autoplan 跨阶段聚合,即本主题。

VALID_PHASES 集合只接受四个值:ceo-reviewdesign-revieweng-reviewdevex-review,传入其他值会让解析器直接抛错(tasks-section.ts)。

四个评审 skill 的实际写入代码位于各自的 review-sections.md 中,例如 plan-ceo-review/sections/review-sections.mdplan-eng-review/sections/review-sections.md。每条任务以一行 JSON 追加到文件末尾,字段 schema 在 tasks-section.ts 中有权威定义:

字段 含义 取值示例
phase 来源评审阶段 ceo-review / design-review / eng-review / devex-review
run_id 本次运行的唯一标识 20260814T000000Z-12345(UTC 时间戳 + 进程 PID)
branch 评审运行时的当前分支 feature/x
commit 评审针对的 HEAD 提交(完整 SHA) abc123def...
id 任务编号 T1T2
priority 优先级 P1(阻塞发布)/ P2(应落在同一分支)/ P3(后续 TODO)
component 所属组件 browse/src/server.ts 所属模块名
files 涉及文件(JSON 数组字面量) ["browse/src/sanitize.ts","browse/src/server.ts"]
effort_human 人类工作量估计 ~2h
effort_cc CC(Claude Code + gstack)时间估计 ~15min
title 任务的祈使句标题 Add null check in cookie import
source_finding 溯源:哪个 section 的哪条 finding <section name> — <finding 摘要>

写入端的固定约束包括:

  • 文件命名:$TASKS_DIR/tasks-<phase>-$(date +%Y%m%d-%H%M%S).jsonl,例如 tasks-ceo-review-20260814-093015.jsonl
  • run_id 使用 $(date -u +%Y%m%dT%H%M%SZ)-$$$$ 是写入进程的 PID,保证同秒内多次运行也不撞号;
  • 必须用 jq -nc 构造每一行,禁止手写 echo/printf 拼 JSON——因为 title 与 source_finding 可能含引号、换行、反斜杠,只有交给 jq 序列化才安全(tasks-section.ts);
  • 零发现时仍要触碰该文件: > "$TASKS_FILE"),因为"空文件 = 跑过但无 finding"与"没有文件 = 根本没跑"在聚合端是两种语义(tasks-section.ts);
  • jq 缺失时跳过 JSONL 写入并向用户告警,但绝不手工伪造 JSONL

这些字段名之所以重要,是因为聚合脚本要按它们逐个做过滤、去重与排序。

2.2 读取端目录约定

TASKS_DIR 派生自 gstack-slug 计算出的项目别名(SLUG):

eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
TASKS_DIR="${HOME}/.gstack/projects/${SLUG:-unknown}"

SLUG 无法取得时回退为字面量 unknown,避免目录拼接失败。四个评审阶段写入、聚合器读取的正是 ~/.gstack/projects/<slug>/ 下同一组文件。


三、聚合主脚本:逐步拆解

下面这段脚本出自 tasks-aggregator.md 的完整原文(也与 tasks-section.tsgenerateTasksSectionAggregate 运行时产出的内容一致),先整体给出,再分五步解读:

eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)"
TASKS_DIR="${HOME}/.gstack/projects/${SLUG:-unknown}"
BRANCH=$(git branch --show-current 2>/dev/null || echo unknown)
# Commit window: last 5 commits on this branch. Drops stale standalone reviews.
COMMITS_RECENT=$(git log --format=%H -n 5 2>/dev/null | tr '\n' '|' | sed 's/|$//')

AGGREGATED_TASKS=""
if command -v jq >/dev/null 2>&1; then
  # Collect entries from all 4 phases, scoped to current branch + commit window.
  # For each phase, keep only the latest run_id. Within the surviving set,
  # dedupe by (component, sorted(files), title) — exact match only.
  # Sort by priority (P1 > P2 > P3) then by phase order.
  ALL_JSONL=$(mktemp -t autoplan-tasks.XXXXXXXX)
  for phase in ceo-review design-review eng-review devex-review; do
    # Use find instead of glob expansion — zsh nomatch errors otherwise when
    # a phase produced no JSONL files. Sorting by name keeps the order stable.
    while IFS= read -r f; do
      [ -f "$f" ] || continue
      # Filter to current branch + recent commits, then keep records for the
      # latest run_id only. (Single phase may have multiple files if the user
      # re-ran the review; aggregator takes the newest.)
      # .commit must be bound BEFORE piping to the split commit array: a
      # pipe rebinds jq's context, so a bare .commit after it indexes the
      # ARRAY with a string, every line errors into 2>/dev/null, and the
      # aggregate is empty forever — the #2018 zero-tasks bug.
      jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \
        '.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))' \
        "$f" 2>/dev/null >> "$ALL_JSONL" || true
    done < <(find "$TASKS_DIR" -maxdepth 1 -name "tasks-$phase-*.jsonl" 2>/dev/null | sort)
    # Reduce to latest run_id per phase
    if [ -s "$ALL_JSONL" ]; then
      jq -sc --arg phase "$phase" \
        '[.[] | select(.phase == $phase)] | (max_by(.run_id) // null) as $latest_run | if $latest_run then map(select(.run_id == $latest_run.run_id)) else [] end | .[]' \
        "$ALL_JSONL" > "$ALL_JSONL.phase" 2>/dev/null || true
      # Replace with reduced version for this phase, accumulating others
      jq -c --arg phase "$phase" 'select(.phase != $phase)' "$ALL_JSONL" > "$ALL_JSONL.other" 2>/dev/null || true
      cat "$ALL_JSONL.other" "$ALL_JSONL.phase" > "$ALL_JSONL"
      rm -f "$ALL_JSONL.phase" "$ALL_JSONL.other"
    fi
  done

  # Exact-match dedup by (component, sorted(files), title). Non-matches kept
  # separately with a possible-duplicate marker injected by the renderer.
  AGGREGATED_TASKS=$(jq -s \
    'group_by([.component, (.files | sort), .title])
     | map(
         # Take the highest-priority entry per group; tie-break by phase order
         sort_by({P1:0,P2:1,P3:2}[.priority] // 99, {"ceo-review":0,"design-review":1,"eng-review":2,"devex-review":3}[.phase] // 99) | .[0]
       )
     | sort_by({P1:0,P2:1,P3:2}[.priority] // 99, {"ceo-review":0,"design-review":1,"eng-review":2,"devex-review":3}[.phase] // 99)
     | if length == 0 then "_No actionable tasks emitted from any phase._" else
         map("- [ ] **\(.id) (\(.priority), human: \(.effort_human) / CC: \(.effort_cc)) — \(.component)** — \(.title)\n  - Surfaced by: \(.phase) — \(.source_finding)\n  - Files: \(.files | join(", "))") | join("\n")
       end' "$ALL_JSONL" 2>/dev/null | sed 's/^"//;s/"$//;s/\\n/\n/g')
  rm -f "$ALL_JSONL"
else
  AGGREGATED_TASKS="_jq not installed — install jq to aggregate per-phase task lists. Skipping._"
fi

步骤 1:建立运行上下文与提交窗口

脚本开头确定四个关键变量:

  1. SLUG:由 gstack-slug 输出,定位项目级工件目录 ~/.gstack/projects/<slug>
  2. TASKS_DIR:上述目录,所有 JSONL 的读写点;
  3. BRANCH:当前分支名,git branch --show-current 失败时回退为 unknown
  4. COMMITS_RECENT当前分支最近 5 个提交的完整 SHA,用 | 连接成单串。它的作用是丢弃"过期的独立评审"——如果你在别的分支或很久之前跑过评审,其任务不会混入本次闸门。

步骤 2:按阶段收集 + 分支/提交窗口过滤

外层 for phase in ceo-review design-review eng-review devex-review 固定按 CEO → Design → Eng → DX 的顺序遍历。内层用 find 而非 shell 通配符展开来枚举 tasks-$phase-*.jsonl——注释明确说明这是为了兼容 zsh:阶段没有产出任何 JSONL 时,通配符展开会触发 nomatch 报错中断脚本;find + sort 则既无此问题又保证枚举顺序稳定。

对每个文件的过滤是整段脚本的心智核心:

jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \
  '.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))' \
  "$f" 2>/dev/null >> "$ALL_JSONL" || true

语义是:仅保留 branch 等于当前分支、且 commit 命中最近 5 提交窗口的记录。2>/dev/null|| true 是刻意的容错设计——单个文件损坏不应杀死整个聚合,坏行被丢弃即可。

步骤 3:每个阶段只保留最新一次运行(run_id 收敛)

同一阶段可能因用户重跑评审而存在多个文件(每次运行一个时间戳文件名、一个 run_id)。聚合原则是"取最新一次运行",否则会重复计数:

'[.[] | select(.phase == $phase)] | (max_by(.run_id) // null) as $latest_run | if $latest_run then map(select(.run_id == $latest_run.run_id)) else [] end | .[]'

max_by(.run_id) 基于 YYYYMMDDTHHMMSSZ-PID 字符串做字典序比较,天然等价于时间排序;// null 防御空输入;随后用 select 保留该次运行的全部记录。临时文件 .phase / .othercat 重组技巧,实现了"只缩当前阶段、保留其余阶段累积结果"的原地归并。

步骤 4:跨阶段精确去重 + 双键排序

所有阶段汇聚进 ALL_JSONL 后,最终渲染前做一次全局去重与排序:

  • 去重键(component, sorted(files), title) 三元组的完全相等匹配。filessort 再比较,使文件书写顺序不同但集合相同的任务仍被视为重复(tasks-section.ts 注释还提到:未命中的"疑似重复"会由渲染器另行标记 possible-duplicate,而非静默丢弃);
  • 组内择优sort_by({P1:0,P2:1,P3:2}[.priority] // 99, {...}[.phase] // 99) 取每组第一条,即同组冲突时高优先级(P1)获胜,再平局则按阶段顺序 ceo → design → eng → devex 取先者;缺失的优先级/阶段字段映射到 99 表示"最低优先";
  • 最终排序:同上双键——先优先级(P1 > P2 > P3),再阶段顺序。

步骤 5:渲染 Markdown 勾选清单

排序后的记录被映射为每条一行、符合"gstack 风格"的待办项:

- [ ] **T1 (P1, human: ~1h / CC: ~15min) — some-component** — Add null check
  - Surfaced by: ceo-review — demo finding
  - Files: browse/src/sanitize.ts, browse/src/server.ts

渲染模板为 map("- [ ] **\(.id) (\(.priority), human: \(.effort_human) / CC: \(.effort_cc)) — \(.component)** — \(.title)\n - Surfaced by: \(.phase) — \(.source_finding)\n - Files: \(.files | join(", "))")。外层 sed 's/^"//;s/"$//;s/\\n/\n/g' 负责去掉 jq 输出字符串的外层引号并把 \n 还原为真实换行。没有记录时输出占位文案 _No actionable tasks emitted from any phase._


四、运行时替换,而非构建期模板

阅读 tasks-aggregator.md 时要特别区分两个层面:

  • 构建期(gen-skill-docs)tasks-aggregator.mdtasks-aggregator.md.tmpl 渲染而来,而后者只有一行占位符 {{TASKS_SECTION_AGGREGATE}}(见 tasks-aggregator.md.tmpl),由 generateTasksSectionAggregate 解析器填充成你看到的完整脚本文本。文档头部注释 AUTO-GENERATED from tasks-aggregator.md.tmpl — do not edit directly 也印证了这一点:手工改 .md 会被下一次 bun run gen:skill-docs 覆盖;
  • 运行时(Agent 执行)$AGGREGATED_TASKS 不是模板占位符。文档明确写道:

This is NOT a template placeholder — the agent does the substitution at runtime, not gen-skill-docs at build time.

即:Agent 按脚本实际执行并把 bash 变量 $AGGREGATED_TASKS 的内容在打印闸门消息前手工替换进 Final Approval Gate 模板中的 ### Implementation Tasks (aggregated across phases) 小节(该小节模板见 autoplan/SKILL.md)。如果把它当成构建期占位符处理,四个阶段的任务就永远不会出现在用户的最终报告里。


五、空结果与降级路径

5.1 两种"空"必须区分

  • 无文件(本轮没有任何评审 skill 运行):渲染 _No per-phase task lists found in $TASKS_DIR for branch $BRANCH. Each review skill writes its own; if you ran one of them but no list appears here, check that jq is installed and the tasks-<phase>-*.jsonl files exist._
  • 有文件但零任务:所有阶段都跑了、确认无 finding,聚合器输出 _No actionable tasks emitted from any phase._

区分二者的关键在于 2.1 节提到的写入约定:空文件意味着"跑过、没发现",无文件意味着"压根没跑"。这也解释了为什么脚本在 if [ -s "$ALL_JSONL" ] 后才做 run_id 收敛——空文件不该触发任何统计。

5.2 jq 缺失时的降级

脚本首先检查 command -v jq。jq 不可用时 AGGREGATED_TASKS 被置为字符串 _jq not installed — install jq to aggregate per-phase task lists. Skipping._,闸门消息仍可完整呈现,只是任务清单段落变成安装提示,流水线不因此中断。


六、#2018 零任务 Bug:一个 jq 上下文陷阱的真实教训

聚合脚本注释中专门内嵌了一段 bug 史,值得单独展开,因为它是理解 jq 语义与整个过滤链正确性的一把钥匙(tasks-aggregator.md):

.commit must be bound BEFORE piping to the split commit array: a pipe rebinds jq's context, so a bare .commit after it indexes the ARRAY with a string, every line errors into 2>/dev/null, and the aggregate is empty forever — the #2018 zero-tasks bug.

错误形态曾经是:

select(.branch == $branch and ($commits | split("|") | index(.commit) != null))

根因:在 jq 中,| 管道会把求值上下文重绑定到左侧表达式的结果上。这里 $commits | split("|") 之后的上下文是字符串数组,此时再写裸 .commit,实际是对数组做字符串索引 ["abc123","def456"]["commit"],每一行都抛 Cannot index array with string "commit";错误被 2>/dev/null 吞掉、|| true 吞掉退出码,最终结果是聚合永远为空,且与"确实没有任务"在现象上无法区分——一个"死亡但外表正常"的功能。

正确形态是先用 as 把值绑定到变量,再做管道:

.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))

.commit as $c 在管道之前执行,$c 通过变量作用域穿透后续管道,不再受上下文重绑定影响。

回归保障落在 test/tasks-section-jq.test.ts:该测试从解析器源码中正则提取真实下发的 jq 程序(而非复制一份近似实现),再对着 fixture JSONL 用真实 jq 二进制执行,验证:同分支且在窗口内能匹配、异分支/窗口外提交被滤除、空输入不伪造输出;最后一条测试直接断言源码中不允许再次出现 split("|") | ... | index(.commit) 这种会重绑定上下文的写法。任何重引入该缺陷形态的改动都会让这些测试变红,这正是文档注释 the #2018 zero-tasks bug 被逐字保留在产物脚本中的原因——它同时是给未来维护者的现场注释与给回归测试的显式契约。


七、实战排查清单

结合上游写入端与聚合脚本,当"评审跑了但闸门里没有任务清单"时,按下面顺序自查:

  1. jq 是否安装:聚合脚本输出是否为 _jq not installed ... 文案;
  2. 文件是否存在ls ~/.gstack/projects/<slug>/tasks-<phase>-*.jsonl。完全不存在 = 对应评审阶段本轮没跑(也可能没有 UI/DX 范围而合法跳过 Phase 2 / Phase 3.5);
  3. 分支与提交窗口:任务记录的 branch/commit 是否属于当前分支最近 5 个提交。文件存在但记录全部被窗口滤除时,聚合结果同样为空——这正是该过滤想表达的语义:只信本次评审
  4. 文件是否为空wc -l 为 0 表示该阶段跑过但零 finding,属正常结果而非故障;
  5. 是否重跑过评审:同一阶段多个文件时,聚合只认 run_id 最大(最新)的那次;
  6. 内容是否含特殊字符:检查 JSONL 行是否由 jq -nc 生成而非手写拼串,title/source_finding 中的引号或换行是否被正确转义。

八、小结:从"评审输出"到"可勾选实施清单"的最后一步

Implementation Tasks Aggregator 是 /autoplan 自动化闭环中承上启下的枢纽:上游吞掉四个评审阶段(ceo-phasedesign-phaseeng-phasedx-phase)产出的 JSONL 工件,下游吐给 Final Approval Gate 一份已按当前分支与最近 5 提交过滤、按 run_id 收敛到最新、按 (component, files, title) 精确去重、按 P1→P3 与 CEO→DX 排序的 markdown 勾选清单。它把多模型、多阶段的评审噪音压缩成"批准、覆盖、盘问、修订、拒绝"五个选项前的那一屏事实,而其正确性由文档内嵌注释、运行时替换约定与 #2018 回归测试三重保障。若想深入其实现细节,最直接的阅读路径是:先读 tasks-aggregator.md 了解行为契约,再到 tasks-section.ts 看两种解析器如何产出脚本,最后在 tasks-section-jq.test.ts 里看真实 jq 程序的回归演练。

登录后查看全文
热门项目推荐
相关项目推荐