@tea-agent/loop-agent 0.35.1-beta.4 → 0.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/AGENTS.md +0 -2
  2. package/CHANGELOG.md +43 -26
  3. package/bin/loop-agent.js +1 -37
  4. package/dist/application/dag/generate-task-dag.js +4 -1
  5. package/dist/application/task-lifecycle/advance.js +14 -0
  6. package/dist/cli/program.js +2 -2
  7. package/dist/commands/task-advance.js +1 -0
  8. package/dist/executors/dag-pi-executor.js +0 -44
  9. package/dist/shared/package-metadata.js +0 -42
  10. package/dist/task/config-types.js +2 -0
  11. package/dist/task/contract/project.js +3 -0
  12. package/dist/task/contract/schema.js +1 -0
  13. package/dist/task/source-prepare/build-draft.js +7 -0
  14. package/dist/task/source-prepare/semantic-intake.js +37 -10
  15. package/dist/task/task-demand-routing.js +10 -0
  16. package/dist/worker/console/chat/assistant-content.js +110 -0
  17. package/dist/worker/console/chat/pi-runtime.js +13 -32
  18. package/dist/worker/console/chat/routes.js +9 -4
  19. package/dist/worker/console/chat/session-store.js +3 -0
  20. package/dist/worker/console/chat/shortcuts.js +9 -7
  21. package/dist/worker/console/chat/turn-process.js +62 -15
  22. package/dist/worker/console/operator-actions.js +72 -6
  23. package/dist/worker/console/prd-intake-bridge.js +10 -3
  24. package/dist/worker/console/prd-reference-discovery.js +124 -0
  25. package/dist/worker/console/static/assets/index-DRqZiQ7J.css +1 -0
  26. package/dist/worker/console/static/assets/index-gVHrlqI9.js +56 -0
  27. package/dist/worker/console/static/index.html +2 -2
  28. package/dist/worker/console/static-src/app/useOperatorActions.js +19 -1
  29. package/dist/worker/console/static-src/app/useRecoveryConsole.js +0 -5
  30. package/dist/worker/console/static-src/app/useTaskWizard.js +12 -0
  31. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +10 -1
  32. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +11 -6
  33. package/dist/worker/console/static-src/operator-chat/useChatThread.js +11 -6
  34. package/dist/worker/console/static-src/operator-chat/useComposer.js +15 -1
  35. package/dist/worker/loop-agent/loop-agent-client.js +3 -17
  36. package/dist/worker/observability/read-model.js +0 -20
  37. package/dist/worker/observe/spec-evidence.js +8 -3
  38. package/dist/worker/observe/static/views/dag-inspector.js +71 -6
  39. package/dist/worker/preflight.js +1 -2
  40. package/dist/workflows/dag/backend-test-scenario-param.js +23 -33
  41. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -9
  42. package/dist/workflows/dag/frontend-implementation-contract.js +39 -233
  43. package/dist/workflows/dag/frontend-prewrite-gate.js +61 -364
  44. package/dist/workflows/dag/frontend-repair.js +18 -219
  45. package/dist/workflows/dag/frontend-verification-trace.js +32 -47
  46. package/dist/workflows/dag/init-hybrid.js +26 -49
  47. package/dist/workflows/dag/node-execution.js +0 -89
  48. package/dist/workflows/dag/recovery-recommendation.js +0 -58
  49. package/dist/workflows/dag/runner.js +11 -245
  50. package/dist/workflows/dag/scheduler.js +3 -257
  51. package/dist/workflows/dag/types.js +2 -130
  52. package/harness.json +1 -1
  53. package/package.json +2 -2
  54. package/dist/build-stamp.json +0 -6
  55. package/dist/worker/console/static/assets/index-CvsQgALl.js +0 -56
  56. package/dist/worker/console/static/assets/index-hJqCPs_g.css +0 -1
  57. package/dist/workflows/dag/contract-output-registry.js +0 -14
  58. package/dist/workflows/dag/contract-validator-registrations.js +0 -8
  59. package/dist/workflows/dag/frontend-recovery-plan.js +0 -73
  60. package/dist/workflows/dag/frontend-recovery-root-manifest.js +0 -123
  61. package/dist/workflows/dag/frontend-recovery-run.js +0 -539
  62. package/dist/workflows/dag/frontend-writer-recovery.js +0 -106
  63. package/dist/workflows/dag/frontend-writer-rollback.js +0 -821
package/AGENTS.md CHANGED
@@ -94,8 +94,6 @@ npm run build
94
94
  node bin/loop-agent.js --help
95
95
  ```
96
96
 
97
- - 改 `src/**` 后必须先 `npm run build` 再用 `bin/loop-agent.js`:bin 内置 stale-dist 守卫(src 比 dist 新即硬失败,`--ignore-stale-dist` 逃生),`--version` 会显示构建 git SHA,发布/自测前用它确认跑的是目标改动。
98
-
99
97
  文档站变更:`npm run docs:build`。init / architecture / skill entry / pack 定向验证见 verification-matrix。
100
98
 
101
99
  ## 交接
package/CHANGELOG.md CHANGED
@@ -1,38 +1,55 @@
1
1
  # 更新日志
2
2
 
3
- ## [0.35.1-beta.4] - 2026-08-15
3
+ ## [Unreleased]
4
4
 
5
- > 观察面板规范证据不再单列 openspec:读取与检索统一为「规范已读取 / 规范检索」,空态文案同步中性化(beta,发布到 `beta` dist-tag)。
5
+ ## [0.35.2] - 2026-08-15
6
6
 
7
- ## [0.35.1-beta.3] - 2026-08-14
7
+ ### 重点更新
8
8
 
9
- > frontend-implementation DAG 优化 v9 全六阶段 + Console 展示层(beta,发布到 `beta` dist-tag)。
9
+ - Operator Chat 处理详情对齐 pi-web:回合尾部的思考内容与工具调用摘要切分为可折叠「处理详情」,流式中思考可见,回合结束后自动折叠,最终回答单独成气泡
10
10
 
11
- ## [Unreleased]
11
+ ### 新增
12
+
13
+ - 对话时间线新增内联可折叠「处理详情」区块,集中展示模型思考与工具调用摘要,回合结束后自动折叠
14
+ - turn-process 保留 SDK thinking blocks,流式传输与持久化均携带思考内容
12
15
 
13
16
  ### 改进
14
17
 
15
- - DAG runner 捕获 SIGTERM/SIGINT:外层硬超时或监督层杀进程时,先把 run 写成 `failed` 终态(含 `terminalReason` 与 `finishedAt`)再退出,不再留 orphaned RUNNING;agent-worker 硬超时改为 SIGTERM→宽限 10s→SIGKILL 两级,给终态落盘留窗口
16
- - prewrite gate 失败结果新增机器可读 `failureCode`(如 `mock-strategy-outside-allowed`、`verdict-not-pass`、`contract-invalid` 等),失败场景强制非空,下游路由不再解析散文
17
- - plan 节点的契约自校验改为 `schemaId → validator` 注册表(`contract-output-registry`),新增契约无需改 executor;契约 schema 层新增深度 null 归一(`z.preprocess` 递归删 null),模型对可选字段吐 null 不再炸 strict schema,必填字段 null 仍以清晰 Required 报错
18
- - VERDICT 归一更宽容:`VERDICT:pass`(缺空格)、尾部句号/感叹号等漂移在节点级即归一,减少无谓的 protocol-invalid 重试
19
- - 构建写入 `dist/build-stamp.json`(git SHA + 时间),`loop-agent --version` 显示构建来源;`bin/loop-agent.js` 在源码树内运行且 dist 落后于 src 时硬失败(`--ignore-stale-dist` 逃生),杜绝「改了没生效」的陈旧产物事故
20
-
21
- - `frontend-implementation` DAG 删除冗余的 `frontend-contract-json-pi` 与 `frontend-contract-json-validate-shell` 两个节点,`frontend-prewrite-gate-shell` 直接消费 plan/revision 的 candidate JSON,成为 candidate → canonical 的唯一转换点(standard 17→15、small 15→13、green path 13→11 节点 / 8→7 Pi)
22
- - prewrite gate 新增 `frontend-prewrite-result-v1` 分类 artifact(accepted / accepted-normalized / retryable-invalid / blocked),blocked/retryable-invalid 先落 artifact 再以结构化结果返回
23
- - 新增两层 writer 前置门禁:scheduler admission + executeDagNode 运行前 guard,仅在 classification 为 accepted/accepted-normalized 时放行 writer,否则 SKIPPED + `frontend-prewrite-not-authorized`,writer provider 调用次数为 0
24
- - 终态聚合对 candidate-contract-invalid / prewrite-blocked 强制 `failed`,防止「prewrite FINISHED + writer SKIPPED」误判 partial_failed
25
- - 删除 contract-json 节点后,frozen command label 提醒与 requirement coverage 指令迁移进 `frontend-plan-pi` / `frontend-plan-revision-pi` 提示,模型侧引导不丢失
26
- - `frontend-repair.ts` 的失败分类从 nodeId/stdout/stderr 字符串启发式改为结构化证据(verificationTargetId / commandLabelcommandCategory / exitCode / traceRef / changedPaths),字符串匹配降级为兜底
27
- - `review` 失败不再默认可自动修复:只有 finding 同时具备明确文件、行或符号、requirement/AC 且修复范围 ⊆ writeSet、不需重新规划 contract 时才进入自动 repair,否则转人工
28
- - `frontendRepairAssessment` 扩展结构化证据字段(verificationTargetId / commandLabel / exitCode / commandCategory / requirementIds / acceptanceCriteriaIds / fixScope / changedPaths / traceRef),缺必要证据时不再判定 repairable
29
- - `frontend-implement-pi` 与 `frontend-repair-pi` 共享同一 prompt builder / contract loader / writeSet guard,仅 phase 与 baseline 不同
30
- - 新增 `frontend-recovery-state-v1` / `frontend-recovery-result-v1` 类型与 schema;prewrite 判定 retryable-invalid 时不再直接失败,改为 parent 先终态收敛、写 recovery intent(phase=child-staging)
31
- - 新增 candidate continuation:带 commit point 的 staged child 创建(staging 目录 + rename + activation marker + requestId 幂等)、facts import 与 reset closure,parent/child 以 recoveryRootRunId 归一到同一全链统计
32
- - scheduler recovery child 施加 activation marker 门禁(无有效 marker 不可调度/执行),fail-closed;continuation 配额按 root 统一限制(至多一次)
33
- - 新增 writer transient rollback 基元(`frontend-writer-rollback.ts`):writeSet 内容快照(existing files + allowed-new)、逐文件 CAS 恢复(current hash 与 attempt hash 一致才恢复,否则 auto-recovery-blocked,禁止 `git reset --hard`)、可恢复事务式 rollback journal(pending→restoring→completed|blocked,含崩溃恢复 hash/CAS 重判)
34
- - 接入 writer transient partial-write 自动恢复(`frontend-writer-recovery.ts`):writer provider 调用前捕获 baseline 快照、调用后记录 attempt 变更;终态聚合时对 transient partial write 先 CAS 回滚、再写 recovery intent 并顺序创建 child(重跑 `frontend-implement-pi`,导入上游 facts);回滚无法证明完整 → `auto-recovery-blocked`,不建 child
35
- - 新增 frontend recovery root manifest(`frontend-recovery-root-manifest-v1`):全链按 `recoveryRootRunId` 去重统计(parent/child 不重复计),`recovered` 收敛为逻辑 `finished`;6 个 outcome(none/recovered/candidate-contract-invalid/prewrite-blocked/repair-exhausted/auto-recovery-blocked)映射到稳定 recovery CTA
18
+ - 命令面板 shortcut 优化:Operator 组单行展示,/task、/contract、/dag 分别跳转到任务与恢复页面
19
+
20
+ ### 修复
21
+
22
+ - 修复「处理详情」思考折叠块背景引用未定义样式变量 --panel 的问题,改为确定性透明背景,消除 Console 样式变量完整性门禁对发布的拦截(视觉无变化)
23
+ - 移除自指的 /help 命令项:输入 / 已弹出命令面板,不再单独提供重复的帮助入口
24
+
25
+ ## [0.35.1] - 2026-08-15
26
+
27
+ ### 重点更新
28
+
29
+ - taskKind 显式契约全链路生效:页面显式选择的任务类型(含「后端研发(默认 DAG)」= standard)不再被 intake 语义识别或 DAG 生成阶段的语义分类改写;taskKindExplicit 从 CLI 标志 语义草稿 task.json DAG 路由全链路透传,显式 standard 跳过语义分类且不回写 taskKind
30
+ - Console 任务向导提交反馈:导入/访谈/契约等长动作点击后立即显示进度文案与按钮 spinner,消除数十秒无反馈的「页面卡死」观感;导入结果新增自动发现引用文档与未解析引用的透明提示
31
+ - Console intake 链路修复:任务导航上传 PRD 三件套后契约落地不再因工程边界缺失卡死;semantic intake 在缺失型边界缺口时接管并基于仓库布局推荐 allowedPaths/verify;文字录入「请参照这三个prd路径…」自动发现并导入引用文档;新的 PRD 导入重置 semantic intake 一次性 marker,失败后可重试
32
+
33
+ ### 新增
34
+
35
+ - PRD 引用自动发现:无上传文档时从文字录入内容中提取 Unicode 路径 token,经 URL 剥离、存在性/大小/排除过滤与角色推断后按角色自动导入引用文件,并透出自动导入与未解析引用结果
36
+ - 任务向导长动作(bootstrap/import/interview/contract)点击后立即在 flash 区域显示动作进度文案(含全链路阶段说明与预期时长),结束时被结果消息覆盖
37
+ - PRD 导入主按钮增加 busy spinner、「导入中…」文案与 aria-busy,新增旋转动画样式
38
+ - 导入结果渲染自动导入的引用文档(路径:角色)与未解析引用警告
39
+
40
+ ### 改进
41
+
42
+ - semantic intake 资格判定拆分缺失型(工程边界/taskKind 缺失时允许模型按仓库布局推荐)与非法型(路径策略/安全/重叠等仍维持拒绝),回归其 repo-layout-aware fallback 的设计意图
43
+ - semantic intake 提示词增补正式 AC 标题逐字复制指引
44
+ - task advance 对模型未推荐边界场景增加 SEMANTIC_INTAKE_BOUNDARY_UNRESOLVED 可操作提示,明确告知用户当前状态
45
+
46
+ ### 修复
47
+
48
+ - 修复 Console 显式选择的 taskKind 被丢弃的问题:此前「未选择」与「显式选 standard」被混为一谈,仅在非 standard 时传递任务类型标志,导致 AI 语义识别(如推荐 backend-test)覆盖用户选择、DAG 被改道
49
+ - 修复 DAG 生成阶段对显式 standard 仍执行语义分类并回写 taskKind 的问题:显式信息此前在契约落盘时丢失,现显式指定时跳过语义分类且不回写
50
+ - 修复 PRD 三件套上传时工程边界缺失以 EMPTY_ALLOWED_PATHS blocking 卡死、semantic intake 拒绝接管、契约永不 apply 的问题
51
+ - 修复文字录入「请参照这三个prd路径XXX」只物化一句话、引用文件从未导入的问题
52
+ - 修复 semantic intake attempt marker 一次性失败后永不重试的问题:新的 PRD 导入会重置 marker,失败可随新证据重试
36
53
 
37
54
  ## [0.35.0] - 2026-08-13
38
55
 
package/bin/loop-agent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
 
@@ -13,42 +13,6 @@ if (!existsSync(cliEntry)) {
13
13
  process.exit(1);
14
14
  }
15
15
 
16
- /**
17
- * Stale-dist guard: when running from a source checkout (src/ present), refuse
18
- * to silently execute a dist/ that predates the latest source edit. The
19
- * classic failure mode is editing src/** and running the bin without
20
- * `npm run build`, which executes the previous build. Suppress with
21
- * --ignore-stale-dist when intentionally running a stale build.
22
- */
23
- const srcDir = join(packageRoot, "src");
24
- const ignoreStale = process.argv.includes("--ignore-stale-dist");
25
- if (ignoreStale) {
26
- process.argv = process.argv.filter((arg) => arg !== "--ignore-stale-dist");
27
- }
28
- if (!ignoreStale && existsSync(srcDir)) {
29
- const distCliMtime = statSync(cliEntry).mtimeMs;
30
- let newestSrcMtime = 0;
31
- const walk = (dir) => {
32
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
33
- const full = join(dir, entry.name);
34
- if (entry.isDirectory()) {
35
- walk(full);
36
- } else {
37
- const mtime = statSync(full).mtimeMs;
38
- if (mtime > newestSrcMtime) newestSrcMtime = mtime;
39
- }
40
- }
41
- };
42
- walk(srcDir);
43
- if (newestSrcMtime > distCliMtime) {
44
- console.error(
45
- `loop-agent: dist/ is stale relative to src/ (a source file is newer than ${cliEntry}).\n` +
46
- ` Run \`npm run build\` before using the bin, or pass --ignore-stale-dist to run the stale build intentionally.`,
47
- );
48
- process.exit(1);
49
- }
50
- }
51
-
52
16
  try {
53
17
  await import(pathToFileURL(cliEntry).href);
54
18
  } catch (error) {
@@ -308,7 +308,10 @@ export async function generateTaskDagUseCase(input) {
308
308
  if (hasAutomaticTaskSourceRoute &&
309
309
  candidateResult.template === "frontend-implementation") {
310
310
  const taskConfig = await loadTaskConfig(repoRoot, parsed.taskId);
311
- if (!taskConfig.taskKind || taskConfig.taskKind === "standard") {
311
+ // Human-explicit taskKind (incl. explicit standard) is a hard contract:
312
+ // never rewrite task.json taskKind via semantic classification.
313
+ if (!taskConfig.taskKindExplicit &&
314
+ (!taskConfig.taskKind || taskConfig.taskKind === "standard")) {
312
315
  await writeTaskConfig(repoRoot, parsed.taskId, {
313
316
  ...taskConfig,
314
317
  taskKind: "frontend-implementation",
@@ -627,6 +627,7 @@ export async function advanceTaskLifecycle(input) {
627
627
  flags: {
628
628
  title: input.title,
629
629
  taskKind: input.taskKind,
630
+ taskKindExplicit: input.taskKindExplicit,
630
631
  featureId: input.featureId,
631
632
  allowedPaths: effectiveInput.allowedPaths,
632
633
  forbiddenPaths: input.forbiddenPaths,
@@ -681,6 +682,7 @@ export async function advanceTaskLifecycle(input) {
681
682
  flags: {
682
683
  title: input.title,
683
684
  taskKind: input.taskKind,
685
+ taskKindExplicit: input.taskKindExplicit,
684
686
  featureId: input.featureId,
685
687
  allowedPaths: effectiveInput.allowedPaths,
686
688
  forbiddenPaths: input.forbiddenPaths,
@@ -700,6 +702,7 @@ export async function advanceTaskLifecycle(input) {
700
702
  flags: {
701
703
  title: input.title,
702
704
  taskKind: input.taskKind,
705
+ taskKindExplicit: input.taskKindExplicit,
703
706
  featureId: input.featureId,
704
707
  allowedPaths: effectiveInput.allowedPaths,
705
708
  forbiddenPaths: input.forbiddenPaths,
@@ -762,6 +765,17 @@ export async function advanceTaskLifecycle(input) {
762
765
  message: prepareResult.error?.message ??
763
766
  "PRD imported but intake incomplete; DAG not generated",
764
767
  });
768
+ // B2: semantic intake ran but the model declined to recommend an
769
+ // engineering boundary — surface an actionable hint instead of a
770
+ // bare EMPTY_ALLOWED_PATHS so the Console operator knows the fix.
771
+ const gapCodesNow = new Set((prepareResult.gaps ?? []).map((gap) => gap.code));
772
+ if (semanticIntakeAttemptedThisCall &&
773
+ gapCodesNow.has("EMPTY_ALLOWED_PATHS")) {
774
+ warnings.push({
775
+ code: "SEMANTIC_INTAKE_BOUNDARY_UNRESOLVED",
776
+ message: "semantic intake structured the requirement but no engineering boundary was recommended; fill allowedPaths in the Console wizard (工程边界) or pass --allowed-path",
777
+ });
778
+ }
765
779
  for (const gap of prepareResult.gaps ?? []) {
766
780
  if (gap.level === "blocking") {
767
781
  warnings.push({ code: gap.code, message: gap.message });
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { Command } from "commander";
4
- import { readCliVersionLabel } from "../shared/package-metadata.js";
4
+ import { readPackageVersion } from "../shared/package-metadata.js";
5
5
  import { resolveAdapter } from "../adapters/index.js";
6
6
  import { runCoverageAudit } from "../commands/coverage-audit.js";
7
7
  import { runCoverageReport } from "../commands/coverage-report.js";
@@ -547,7 +547,7 @@ export function buildLoopAgentProgram(options) {
547
547
  program
548
548
  .name("loop-agent")
549
549
  .description("Loop Agent task and DAG automation CLI")
550
- .version(readCliVersionLabel(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))))) ?? "0.0.0", "-V, --version", "display version")
550
+ .version(readPackageVersion(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))))) ?? "0.0.0", "-V, --version", "display version")
551
551
  .showHelpAfterError()
552
552
  .option("--repo-root <path>", "repository root")
553
553
  .option("--adapter <name>", "harness adapter name")
@@ -252,6 +252,7 @@ function toUseCaseInput(repoRoot, taskId, options) {
252
252
  fromText: options.fromText,
253
253
  fromDraftPath: options.fromDraft,
254
254
  taskKind: options.taskKind,
255
+ taskKindExplicit: Boolean(options.taskKind?.trim()),
255
256
  featureId: options.featureId,
256
257
  allowedPaths: options.allowedPaths,
257
258
  forbiddenPaths: options.forbiddenPaths,
@@ -520,33 +520,6 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
520
520
  }
521
521
  let result;
522
522
  try {
523
- // Phase 5 (P1-9): capture the writeSet baseline BEFORE the writer provider
524
- // call so a transient partial write can be rolled back and recovered via a
525
- // staged child. Only frontend-implementation writers; a failed capture is a
526
- // hard fail (no baseline → no rollback → no recovery). Dynamic import avoids
527
- // an executor ↔ scheduler static cycle.
528
- if (isWriteTask &&
529
- (input.task.id === "frontend-implement-pi" ||
530
- input.task.id === "frontend-repair-pi") &&
531
- (input.task.writeSet?.length ?? 0) > 0) {
532
- try {
533
- const { captureFrontendWriterAttemptIntent } = await import("../workflows/dag/frontend-writer-recovery.js");
534
- await captureFrontendWriterAttemptIntent({
535
- cwd: input.cwd,
536
- runDir: meta.runDir,
537
- writeSet: input.task.writeSet,
538
- });
539
- }
540
- catch (error) {
541
- return {
542
- ok: false,
543
- stdout: "",
544
- stderr: `frontend writer attempt baseline capture failed: ${error instanceof Error ? error.message : String(error)}`,
545
- failureCategory: "write-guard",
546
- durationMs: Date.now() - started,
547
- };
548
- }
549
- }
550
523
  result = await piStepFn({
551
524
  attachedFiles: [],
552
525
  modelConfig: resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined),
@@ -718,23 +691,6 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
718
691
  const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
719
692
  changeManifestAfterStatus = afterStatus;
720
693
  changeManifestChangedFiles = changedFiles;
721
- // Phase 5: record the attempt changed paths/hashes into the rollback
722
- // intent so a transient partial write can be CAS-restored.
723
- if ((input.task.id === "frontend-implement-pi" ||
724
- input.task.id === "frontend-repair-pi") &&
725
- (input.task.writeSet?.length ?? 0) > 0) {
726
- try {
727
- const { recordFrontendWriterAttempt } = await import("../workflows/dag/frontend-writer-recovery.js");
728
- await recordFrontendWriterAttempt({
729
- cwd: input.cwd,
730
- runDir: meta.runDir,
731
- changedPaths: changedFiles,
732
- });
733
- }
734
- catch {
735
- // best-effort: a missing intent (no baseline captured) is a no-op
736
- }
737
- }
738
694
  const guard = validateShellWriteGuardFromDiff({
739
695
  changedFiles,
740
696
  task: input.task,
@@ -45,48 +45,6 @@ export function readPackageName(packageDir) {
45
45
  }
46
46
  return undefined;
47
47
  }
48
- /**
49
- * Read the build stamp (git SHA + build time) written by the build pipeline
50
- * into `<packageDir>/dist/build-stamp.json`. `undefined` when absent (e.g.
51
- * running from source via tsx, or a hand-assembled package), which callers
52
- * treat as "no stamp available".
53
- */
54
- export function readBuildStamp(packageDir) {
55
- try {
56
- const raw = readFileSync(path.join(packageDir, "dist", "build-stamp.json"), "utf-8");
57
- const parsed = JSON.parse(raw);
58
- if (parsed.schemaVersion !== 1 ||
59
- typeof parsed.version !== "string" ||
60
- typeof parsed.builtAt !== "string")
61
- return undefined;
62
- return parsed;
63
- }
64
- catch {
65
- return undefined;
66
- }
67
- }
68
- /**
69
- * Human-readable CLI version label: plain version, enriched with the build
70
- * stamp (short git SHA + build date) when available so a stale dist/ that was
71
- * built from an old commit is immediately recognizable in `--version`.
72
- */
73
- export function readCliVersionLabel(packageDir) {
74
- const version = readPackageVersion(packageDir) ?? "0.0.0";
75
- const stamp = readBuildStamp(packageDir);
76
- if (!stamp?.gitSha)
77
- return version;
78
- return `${version} (build ${stamp.gitSha.slice(0, 8)} ${stamp.builtAt.slice(0, 10)})`;
79
- }
80
- /**
81
- * Strip the build-stamp suffix from a CLI `--version` label, returning the bare
82
- * SemVer. Controller-identity/preflight comparisons must use this rather than
83
- * the raw label so a build-stamped `0.35.0 (build 7f8c5b5b …)` still matches
84
- * `packageVersion === "0.35.0"`.
85
- */
86
- export function normalizeCliVersionLabel(label) {
87
- const match = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(label.trim());
88
- return match ? match[0] : label.trim();
89
- }
90
48
  /**
91
49
  * Compute the SHA-256 hash of a file on disk.
92
50
  *
@@ -118,6 +118,8 @@ const taskConfigObjectSchema = z.object({
118
118
  sourceFiles: z.array(z.string()),
119
119
  /** standard: 本仓库需求实现;feature-study: 参考外部代码特性并在目标仓库落地;frontend-implementation: 前端实现 DAG;backend-test: 后端测试 DAG;knowledge-sync: 最终验证后回写测试知识库 DAG;knowledge-graph-bootstrap: AI 辅助业务知识图谱初始化 */
120
120
  taskKind: taskKindSchema.optional().default("standard"),
121
+ /** true = taskKind 由人类显式选择(含显式 standard);DAG 路由必须尊重,不做语义改写。 */
122
+ taskKindExplicit: z.boolean().optional(),
121
123
  /** Feature 目录 id(如 F-2026-004)。knowledge-sync 必填(也可从 hardConstraints/需求正文/taskId 解析);用于收窄 writeSet */
122
124
  featureId: z.string().min(1).optional(),
123
125
  referenceRepos: z.array(referenceRepoConfigSchema).optional().default([]),
@@ -152,6 +152,9 @@ export function mergeManagedTaskConfigFields(existing, draft) {
152
152
  ...existing,
153
153
  title: draft.title,
154
154
  taskKind: draft.taskKind,
155
+ ...(draft.taskKindExplicit !== undefined
156
+ ? { taskKindExplicit: draft.taskKindExplicit }
157
+ : {}),
155
158
  allowedPaths: canonicalizePathArray(draft.constraints.allowedPaths),
156
159
  ...(draft.constraints.allowedRoots !== undefined
157
160
  ? { allowedRoots: canonicalizePathArray(draft.constraints.allowedRoots) }
@@ -57,6 +57,7 @@ export const taskContractDraftSchema = z
57
57
  taskId: z.string().min(1),
58
58
  title: z.string().min(1),
59
59
  taskKind: taskKindSchema,
60
+ taskKindExplicit: z.boolean().optional(),
60
61
  featureId: z.string().min(1).optional(),
61
62
  requirement: taskContractDraftRequirementSchema,
62
63
  constraints: taskContractDraftConstraintsSchema,
@@ -17,6 +17,11 @@ function resolveTaskKind(flags, baseDraft, existing) {
17
17
  const parsed = taskKindSchema.safeParse(candidate);
18
18
  return parsed.success ? parsed.data : String(candidate);
19
19
  }
20
+ function resolveTaskKindExplicit(flags, baseDraft) {
21
+ if (flags.taskKindExplicit !== undefined)
22
+ return flags.taskKindExplicit;
23
+ return baseDraft?.taskKindExplicit ?? false;
24
+ }
20
25
  function resolveTitle(taskId, flags, facts, baseDraft, existing) {
21
26
  return (trimNonEmpty(flags.title) ??
22
27
  trimNonEmpty(baseDraft?.title) ??
@@ -105,6 +110,7 @@ export function buildPrepareDraft(input) {
105
110
  const { taskId, existingTaskConfig, baseDraft, facts, flags, references } = input;
106
111
  const title = resolveTitle(taskId, flags, facts, baseDraft, existingTaskConfig);
107
112
  const taskKind = resolveTaskKind(flags, baseDraft, existingTaskConfig);
113
+ const taskKindExplicit = resolveTaskKindExplicit(flags, baseDraft);
108
114
  const featureId = trimNonEmpty(flags.featureId) ??
109
115
  trimNonEmpty(baseDraft?.featureId) ??
110
116
  trimNonEmpty(existingTaskConfig?.featureId) ??
@@ -170,6 +176,7 @@ export function buildPrepareDraft(input) {
170
176
  taskId,
171
177
  title,
172
178
  taskKind: taskKind,
179
+ taskKindExplicit,
173
180
  requirement: {
174
181
  objective,
175
182
  scope,
@@ -7,7 +7,7 @@
7
7
  * optional model recommendation (repo-layout-aware fallback for Console
8
8
  * web-wizard flows that never collect human paths).
9
9
  */
10
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
10
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
11
11
  import path from "node:path";
12
12
  import { z } from "zod";
13
13
  import { executePiStep } from "../../executors/pi-executor.js";
@@ -92,31 +92,39 @@ export function evaluateSemanticIntakeEligibility(input) {
92
92
  };
93
93
  }
94
94
  }
95
- // Engineering-only gaps are not fixed by semantic intake.
96
- const onlyEngineering = blocking.length > 0 &&
97
- blocking.every((g) => g.code === "EMPTY_ALLOWED_PATHS" ||
98
- g.code === "EMPTY_TASK_KIND" ||
99
- g.code === "MISSING_FEATURE_ID" ||
95
+ // Invalid-value engineering gaps are NOT fixed by semantic intake (the
96
+ // model cannot repair a rejected path policy or an illegal overlap).
97
+ // Missing-value engineering gaps (EMPTY_ALLOWED_PATHS / EMPTY_TASK_KIND)
98
+ // ARE the documented Console web-wizard case: the wizard never collects
99
+ // human paths, so the repo-layout-aware model recommendation is the
100
+ // designed fallback (see module header). Keep them eligible.
101
+ const invalidEngineering = blocking.length > 0 &&
102
+ blocking.every((g) => g.code === "MISSING_FEATURE_ID" ||
100
103
  g.code === "PATH_POLICY" ||
101
104
  g.code.startsWith("UNSAFE_") ||
102
105
  g.code === "ALLOWED_FORBIDDEN_OVERLAP");
103
- if (onlyEngineering) {
106
+ if (invalidEngineering) {
104
107
  return {
105
108
  eligible: false,
106
- reason: "only engineering boundary gaps; provide --allowed-path / flags",
109
+ reason: "engineering boundary gaps carry invalid values; fix the flags/paths",
107
110
  };
108
111
  }
109
112
  const structural = codes.has("SEMANTIC_INTAKE_RECOMMENDED") ||
110
113
  blockingCodes.has("EMPTY_ACCEPTANCE") ||
111
114
  blockingCodes.has("EMPTY_OBJECTIVE") ||
112
- blockingCodes.has("NO_PARSEABLE_REQUIREMENT");
115
+ blockingCodes.has("NO_PARSEABLE_REQUIREMENT") ||
116
+ blockingCodes.has("EMPTY_ALLOWED_PATHS") ||
117
+ blockingCodes.has("EMPTY_TASK_KIND");
113
118
  if (!structural && !input.force) {
114
119
  return {
115
120
  eligible: false,
116
121
  reason: "no structural requirement gaps for semantic intake",
117
122
  };
118
123
  }
119
- return { eligible: true, reason: "structural intake gaps with imported sources" };
124
+ return {
125
+ eligible: true,
126
+ reason: "structural or engineering-boundary gaps with imported sources",
127
+ };
120
128
  }
121
129
  export function extractJsonObject(text) {
122
130
  const fenced = /```(?:json)?\s*([\s\S]*?)```/iu.exec(text);
@@ -206,6 +214,9 @@ export function buildDraftFromSemanticIntake(input) {
206
214
  taskId: input.taskId,
207
215
  title: input.semantic.title?.trim() || input.title,
208
216
  taskKind,
217
+ ...(explicitTaskKind || input.flags.taskKindExplicit
218
+ ? { taskKindExplicit: true }
219
+ : {}),
209
220
  requirement: {
210
221
  objective: input.semantic.objective.trim(),
211
222
  scope: input.semantic.scope.map((s) => s.trim()).filter(Boolean),
@@ -374,6 +385,7 @@ async function buildPrompt(input) {
374
385
  "Prefer smallest correct write boundary; do not invent product paths that are not in the layout hint.",
375
386
  "Do NOT rewrite or suggest editing the original PRD files.",
376
387
  "Prefer explicit text; use provenance derived for rephrasing; use inferred only when clearly implied — if you must invent ACs, leave acceptanceCriteria empty so the system can stop for clarification.",
388
+ "When documents embed formal AC headings (e.g. #### AC-FE-001 …), copy each AC verbatim under the SAME id with provenance explicit; do not rephrase or renumber them.",
377
389
  "Return exactly one JSON object, no markdown prose outside optional fence:",
378
390
  JSON.stringify({
379
391
  title: "optional string",
@@ -416,6 +428,21 @@ export async function readSemanticIntakeAttempted(repoRoot, taskId) {
416
428
  return false;
417
429
  }
418
430
  }
431
+ /**
432
+ * C: a new PRD import is new evidence — clear the one-shot attempt marker
433
+ * so semantic intake may run again for the refreshed sources.
434
+ */
435
+ export async function clearSemanticIntakeAttempt(repoRoot, taskId) {
436
+ const paths = getTaskPaths(repoRoot, taskId);
437
+ const marker = path.join(paths.taskDir, SEMANTIC_INTAKE_ATTEMPT_MARKER);
438
+ try {
439
+ await unlink(marker);
440
+ return true;
441
+ }
442
+ catch {
443
+ return false;
444
+ }
445
+ }
419
446
  async function writeAttemptMarker(repoRoot, taskId, payload) {
420
447
  const paths = getTaskPaths(repoRoot, taskId);
421
448
  const dir = path.join(paths.taskDir, "artifacts", "intake");
@@ -402,6 +402,16 @@ export function resolveTaskDagTemplateSelection(input) {
402
402
  reasons: [],
403
403
  };
404
404
  }
405
+ // Human-explicit standard (e.g. Console 后端研发默认 DAG) is a hard
406
+ // contract: semantic task-source classification must not rewrite it to a
407
+ // specialized template (which would also rewrite task.json taskKind).
408
+ if (input.taskKindExplicit) {
409
+ return {
410
+ template: requestedTemplate,
411
+ source: "taskKind",
412
+ reasons: ["taskKind standard was explicitly selected by human; semantic classification skipped"],
413
+ };
414
+ }
405
415
  const classification = classifyTaskDemand(input);
406
416
  if (classification.kind === "frontend") {
407
417
  const frontendSignals = classification.signals
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Assistant content-block helpers shared by Pi event mapping and Operator Chat UI.
3
+ * Mirrors pi-web `lib/message-display.ts` (process vs answer split) without deferred fetch.
4
+ */
5
+ function asRecord(value) {
6
+ if (!value || typeof value !== "object")
7
+ return undefined;
8
+ return value;
9
+ }
10
+ function readString(value) {
11
+ return typeof value === "string" ? value : undefined;
12
+ }
13
+ /** Parse a persisted/SSE content array; unknown shapes are dropped. */
14
+ export function parseAssistantContent(value) {
15
+ if (!Array.isArray(value))
16
+ return undefined;
17
+ const blocks = [];
18
+ for (const item of value) {
19
+ const rec = asRecord(item);
20
+ if (!rec)
21
+ continue;
22
+ const type = readString(rec.type) ?? "";
23
+ if (type === "thinking" || type === "reasoning") {
24
+ const thinking = readString(rec.thinking) ?? readString(rec.text) ?? "";
25
+ blocks.push({ type: "thinking", thinking });
26
+ continue;
27
+ }
28
+ if (type === "toolCall" ||
29
+ type === "tool_call" ||
30
+ type === "toolUse" ||
31
+ type === "tool_use") {
32
+ const toolCallId = readString(rec.toolCallId) ?? readString(rec.id) ?? "";
33
+ if (!toolCallId)
34
+ continue;
35
+ const toolName = readString(rec.toolName) ?? readString(rec.name);
36
+ blocks.push({
37
+ type: "toolCall",
38
+ toolCallId,
39
+ ...(toolName ? { toolName } : {}),
40
+ });
41
+ continue;
42
+ }
43
+ if (type === "text" || type === "output_text" || type === "") {
44
+ const text = readString(rec.text);
45
+ if (text !== undefined)
46
+ blocks.push({ type: "text", text });
47
+ }
48
+ }
49
+ return blocks;
50
+ }
51
+ /**
52
+ * Extract ordered assistant content blocks from a Pi SDK message object.
53
+ * Thinking/reasoning blocks keep their own type so they never leak into answer text.
54
+ */
55
+ export function extractAssistantContentBlocks(message) {
56
+ const rec = asRecord(message);
57
+ if (!rec)
58
+ return [];
59
+ const contents = Array.isArray(rec.content) ? rec.content : [];
60
+ const parts = Array.isArray(rec.parts) ? rec.parts : [];
61
+ const fromArrays = parseAssistantContent([...contents, ...parts]) ?? [];
62
+ if (fromArrays.length > 0)
63
+ return fromArrays;
64
+ const top = readString(rec.text);
65
+ return top ? [{ type: "text", text: top }] : [];
66
+ }
67
+ /**
68
+ * Latest assistant answer text (text blocks only). Thinking is excluded.
69
+ * Preserves the historical `{ text }` / `{ parts: [{ text }] }` fallbacks.
70
+ */
71
+ export function extractAssistantText(message) {
72
+ return extractAssistantContentBlocks(message)
73
+ .filter((block) => block.type === "text")
74
+ .map((block) => block.text)
75
+ .join("");
76
+ }
77
+ export function isEmptyThinkingBlock(block, options = {}) {
78
+ return (block.type === "thinking" &&
79
+ !options.isStreaming &&
80
+ block.thinking.trim() === "");
81
+ }
82
+ export function getDisplayableAssistantBlocks(blocks, options = {}) {
83
+ return blocks.filter((block) => !isEmptyThinkingBlock(block, options));
84
+ }
85
+ function isFinalAnswerBlock(block) {
86
+ return block.type === "text";
87
+ }
88
+ export function splitFinalAssistantBlocks(blocks, options = {}) {
89
+ const displayable = getDisplayableAssistantBlocks(blocks, options);
90
+ let lastProcessIndex = -1;
91
+ for (let i = displayable.length - 1; i >= 0; i--) {
92
+ if (!isFinalAnswerBlock(displayable[i])) {
93
+ lastProcessIndex = i;
94
+ break;
95
+ }
96
+ }
97
+ if (lastProcessIndex === -1) {
98
+ return { answerBlocks: displayable, processBlocks: [] };
99
+ }
100
+ return {
101
+ answerBlocks: displayable.slice(lastProcessIndex + 1),
102
+ processBlocks: displayable.slice(0, lastProcessIndex + 1),
103
+ };
104
+ }
105
+ export function joinAssistantText(blocks) {
106
+ return blocks
107
+ .filter((block) => block.type === "text")
108
+ .map((block) => block.text)
109
+ .join("");
110
+ }