paseo-req-kit 1.14.0-beta.4 → 1.14.0

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 (116) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/req.mjs +6 -0
  3. package/contracts/notifier-event.md +1 -1
  4. package/contracts/spec.md +1 -0
  5. package/contracts/state.md +3 -1
  6. package/contracts/usage-record.md +16 -2
  7. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +3 -1
  8. package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +17 -1
  9. package/engine/prompts/00-shared-context.md +3 -2
  10. package/engine/src/activity.ts +21 -0
  11. package/engine/src/api.ts +30 -5
  12. package/engine/src/code-review.ts +3 -0
  13. package/engine/src/config-view.ts +10 -0
  14. package/engine/src/config.ts +27 -0
  15. package/engine/src/danger-guard.ts +180 -0
  16. package/engine/src/features.ts +1 -1
  17. package/engine/src/messages.ts +29 -0
  18. package/engine/src/outcomes.ts +145 -0
  19. package/engine/src/paseo-runtime.ts +9 -1
  20. package/engine/src/pipeline.ts +135 -6
  21. package/engine/src/records.ts +71 -0
  22. package/engine/src/reply.ts +30 -11
  23. package/engine/src/review.ts +15 -1
  24. package/engine/src/runtime.ts +2 -0
  25. package/engine/src/secret-scan.ts +177 -0
  26. package/engine/src/state.ts +30 -0
  27. package/engine/src/stats.ts +143 -9
  28. package/engine/src/watchdog.ts +38 -4
  29. package/package.json +1 -1
  30. package/paseo-plugin/client/stats.tsx +15 -0
  31. package/paseo-plugin/package.json +1 -1
  32. package/paseo-plugin/shared/contracts.ts +7 -0
  33. package/site-dist/404.html +2 -2
  34. package/site-dist/assets/{app.Cp7WwPWE.js → app.VQ-QxRPH.js} +1 -1
  35. package/site-dist/assets/changelog.md.CIKYJXpF.js +1 -0
  36. package/site-dist/assets/{changelog.md.Zi-_3Rjh.lean.js → changelog.md.CIKYJXpF.lean.js} +1 -1
  37. package/site-dist/assets/chunks/@localSearchIndexroot.h7oQE_np.js +1 -0
  38. package/site-dist/assets/chunks/{VPLocalSearchBox.CMTXQK-Q.js → VPLocalSearchBox.CT4Vlj76.js} +1 -1
  39. package/site-dist/assets/chunks/{theme.DvVUfKr3.js → theme.B_UmHHou.js} +2 -2
  40. package/site-dist/assets/{guide_getting-started.md.D8XlIQOg.js → guide_getting-started.md.CZM53rO_.js} +1 -1
  41. package/site-dist/assets/{guide_project.md.Csi5wiyt.js → guide_project.md.DIjDRoAq.js} +1 -1
  42. package/site-dist/assets/{guide_project.md.Csi5wiyt.lean.js → guide_project.md.DIjDRoAq.lean.js} +1 -1
  43. package/site-dist/assets/{guide_user.md.B4N9ywmr.js → guide_user.md.UV0WxdJa.js} +1 -1
  44. package/site-dist/assets/{guide_user.md.B4N9ywmr.lean.js → guide_user.md.UV0WxdJa.lean.js} +1 -1
  45. package/site-dist/assets/{reference_contracts_notifier-event.md.CCfrkm_A.js → reference_contracts_notifier-event.md.B9WqeZdO.js} +1 -1
  46. package/site-dist/assets/{reference_contracts_notifier-event.md.CCfrkm_A.lean.js → reference_contracts_notifier-event.md.B9WqeZdO.lean.js} +1 -1
  47. package/site-dist/assets/{reference_contracts_spec.md.BwwS0L_j.js → reference_contracts_spec.md.Dh5XczZL.js} +2 -2
  48. package/site-dist/assets/{reference_contracts_spec.md.BwwS0L_j.lean.js → reference_contracts_spec.md.Dh5XczZL.lean.js} +2 -2
  49. package/site-dist/assets/reference_contracts_state.md.STlqkPPS.js +1 -0
  50. package/site-dist/assets/{reference_contracts_usage-record.md.D5krNLW7.lean.js → reference_contracts_usage-record.md.MjVps4CA.js} +2 -2
  51. package/site-dist/assets/{reference_contracts_usage-record.md.D5krNLW7.js → reference_contracts_usage-record.md.MjVps4CA.lean.js} +2 -2
  52. package/site-dist/changelog.html +5 -5
  53. package/site-dist/guide/getting-started.html +5 -5
  54. package/site-dist/guide/how-it-works.html +4 -4
  55. package/site-dist/guide/index.html +4 -4
  56. package/site-dist/guide/install.html +4 -4
  57. package/site-dist/guide/maintainer.html +4 -4
  58. package/site-dist/guide/phases.html +4 -4
  59. package/site-dist/guide/project.html +6 -6
  60. package/site-dist/guide/user.html +5 -5
  61. package/site-dist/hashmap.json +1 -1
  62. package/site-dist/index.html +4 -4
  63. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  64. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  65. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  66. package/site-dist/reference/adr/0004-result-files.html +4 -4
  67. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  68. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  69. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  70. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  71. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  72. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  73. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  74. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  75. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  76. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  77. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  78. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  79. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  80. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  81. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  82. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  83. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  84. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  85. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  86. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  87. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  88. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  89. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  90. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  91. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  92. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  93. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  94. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  95. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
  96. package/site-dist/reference/adr/index.html +4 -4
  97. package/site-dist/reference/commands.html +4 -4
  98. package/site-dist/reference/config.html +4 -4
  99. package/site-dist/reference/contracts/cli-json.html +4 -4
  100. package/site-dist/reference/contracts/feature-files.html +4 -4
  101. package/site-dist/reference/contracts/index.html +4 -4
  102. package/site-dist/reference/contracts/notifier-event.html +6 -6
  103. package/site-dist/reference/contracts/result-file.html +4 -4
  104. package/site-dist/reference/contracts/spec.html +6 -6
  105. package/site-dist/reference/contracts/state.html +5 -5
  106. package/site-dist/reference/contracts/usage-record.html +6 -6
  107. package/site-dist/reference/contracts/verify.html +4 -4
  108. package/site-dist/reference/scope.html +4 -4
  109. package/skills/req/SKILL.md +1 -0
  110. package/skills/req/playbooks/incident.md +1 -1
  111. package/skills/spec-writing/SKILL.md +2 -1
  112. package/site-dist/assets/changelog.md.Zi-_3Rjh.js +0 -1
  113. package/site-dist/assets/chunks/@localSearchIndexroot.COxp5Rrc.js +0 -1
  114. package/site-dist/assets/reference_contracts_state.md.EEBd_l2P.js +0 -1
  115. /package/site-dist/assets/{guide_getting-started.md.D8XlIQOg.lean.js → guide_getting-started.md.CZM53rO_.lean.js} +0 -0
  116. /package/site-dist/assets/{reference_contracts_state.md.EEBd_l2P.lean.js → reference_contracts_state.md.STlqkPPS.lean.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  每个版本分为"使用者会看到的变化"和"升级须知"。升级步骤见 [docs/guide/安装与升级.md](docs/guide/安装与升级.md)。
4
4
 
5
+ ## 1.14.0
6
+
7
+ 稳定版:需求从对齐开始就在插件里,新建工作区能接着做,插件里能做代码审查和验收,危险命令和密钥有程序把关,评审、验收、合并之后的结果有记录可查。内容包括 1.14.0-beta.1 至 beta.5(详见下面各条)。
8
+
9
+ ### 使用者会看到的变化
10
+
11
+ - **修复:自动新建工作区后只有需求说明、没有代码。** 工作区一建好就记下来,再次启动在同一个工作区里接着做;最多等 15 分钟。术语表和设计决定随需求说明一起带过去;需求说明不会再被提交到主分支。
12
+ - **需求从开始谈就出现在插件里**("对齐中");确认了却没开始开发的进"等你处理",点"开始开发"即可,谈需求的对话里也会提醒一次。已经开发完的需求不会误显示为"未开始";同一个需求在两处只列一次。
13
+ - **代码审查**(插件需求页面上的"代码审查",`req code-review`):改动按文件列出,AI 评审意见放在对应的行旁边;评审人在任意一行写意见、标"要改 / 不用改";审核人默认取自 `CODEOWNERS`,提交前可改。退回修改后流水线自动按意见修改,评审通过后有新提交会提示重新评审。
14
+ - **验收清单和一键试用**("验收清单(可一键试用)",`req checklist`、`req try`):按验收标准逐条勾选,提交后写成 `acceptance.md` 并提交;不通过的转给需求的对话处理。
15
+ - **拦截危险命令**(`dangerGuard`):步骤 agent 推送、发布制品、改动集群、连接其他机器上的数据库或主机、删除仓库以外的目录、下载脚本直接执行,或命令里出现配置的生产环境地址时,打断并通知。
16
+ - **密钥扫描**(`secretScan`):每个改代码的步骤之后扫描本需求新增的内容,交回 agent 去掉;有疑似密钥的分支不推送。
17
+ - **记录可查**:代码审查和验收的全文随需求分支提交;结论另记入使用记录。`req stats` 和插件统计新增"人工把关和合并后":评审退回次数、验收通过率、合并进主干的需求、合并后发现的问题(`req outcome`)。
18
+
19
+ ### 升级须知
20
+
21
+ - 新配置都可选:`dangerGuard`(默认开,建议团队配置里把生产环境的主机名、地址写进 `production`)、`secretScan`(默认开)。打开 `dangerGuard` 时流水线每 15 秒读一次步骤 agent 的命令记录,不占模型 token。
22
+ - 合并请求还没有接入,等 shenbing 提供类似 `gh pr create / gh pr merge` 的命令。
23
+ - 状态文件、使用记录、`req api` 和外部通知的新增字段见各 beta 的升级须知;新命令 `req code-review`、`req review-fix`、`req checklist`、`req try`、`req outcome`。
24
+ - 更新后请在 Paseo 中重新加载插件(`req upgrade` 会自动做)。
25
+
26
+ ## 1.14.0-beta.5
27
+
28
+ ### 使用者会看到的变化
29
+
30
+ - **拦截危险命令**(`dangerGuard`,默认开):步骤 agent 推送代码、发布制品、改动集群或基础设施、连接其他机器上的数据库或主机、删除仓库以外的目录、下载脚本直接执行,或者命令里出现配置的生产环境地址时,流水线打断它(还在运行时)、告诉它把需要人做的写进产物,并通知需求的对话;每次都记下来,写进完成报告。按命令文字检查,不用模型;能拦住常见的情况,不是沙箱。
31
+ - **密钥扫描**(`secretScan`,默认开):每个改代码的步骤之后,检查本需求新增的内容(每次提交加的行、未提交的改动、新文件)有没有密钥、令牌、口令;发现了交回这一步的 agent 去掉(包括从还没推送的提交里去掉),仍去不掉的通知需求的对话、写进代码评审和完成报告;配置了推送的需求不会推送有疑似密钥的分支。
32
+ - **记录合并之后的结果**:`req stats` 和插件的使用统计新增"人工把关和合并后":人工代码评审几次、退回几次,验收几次、不通过几次,第一次就通过的需求数,合并进主干的需求和"完成到合并"的时间,合并后发现的问题。合并是从本机仓库已有的分支判断的(需求的流水线文件进了主干);缺陷需求写了 `- 引入问题的需求:<目录名>` 的,算作那个需求合并后的问题;也可以 `req outcome <需求> --issue "<问题>"` 手动记一笔。
33
+ - **代码审查和验收的结论进使用记录**:每次在代码审查页、验收页提交,结论、评审人和意见条数记入本机使用记录(全文仍在随分支提交的 `code-review.md`、`acceptance.md` 里);`req stats --export` 一起导出。
34
+ - **确认了却没开始开发的需求,在谈需求的对话里提醒一次**(插件的后台检查,确认 30 分钟后;只提醒 3 天内确认的)。
35
+ - **修复:同一个需求在主目录和它自己的工作区里各有一份时,插件列出两次。** 现在只列一次,优先列在运行的、有运行状态的那一份。
36
+
37
+ ### 升级须知
38
+
39
+ - 新配置都可选:`dangerGuard`(默认 `{ "enabled": true, "production": [], "allow": [] }`,建议团队配置里把生产环境的主机名、地址写进 `production`)、`secretScan`(默认 `{ "enabled": true, "allow": [] }`)。打开 `dangerGuard` 时流水线每 15 秒读一次步骤 agent 的命令记录,不占模型 token。
40
+ - 使用记录 `usage/runs.jsonl` 新增 `review`、`acceptance`、`merge`、`issue` 几种记录(见 `contracts/usage-record.md`),旧版本读取时会跳过;`req stats --json` / `req api stats` 新增 `oversight`,每个需求新增 `afterRun`,CSV 新增 7 列。状态文件新增 `dangers`、`secretScans`;外部通知 `alert` 事件新增 `details.kind: "danger"`。
41
+ - 新命令 `req outcome`;`req reply` 新增 `--notice`(后台检查用)。
42
+ - 提示词里"不改写历史"加了一个例外:流水线要求从本需求还没推送的提交里去掉密钥时。
43
+
5
44
  ## 1.14.0-beta.4
6
45
 
7
46
  ### 使用者会看到的变化
package/bin/req.mjs CHANGED
@@ -52,6 +52,9 @@ const HELP = `req-kit ${version}(${kitRoot})
52
52
  req stats --report [--out 目录]
53
53
  生成汇报用的汇总(.md)和每个需求一行的表格(.csv)
54
54
  req stats --export [文件] 导出本机的运行记录;汇总多台机器:req stats --merge <文件…> --report
55
+ req outcome 查找已合并进主干的需求和合并后发现的问题,记入使用记录(req stats 也会自动查)
56
+ req outcome <需求> --merged | --issue "<问题>"
57
+ 手动记一笔:已合并,或合并后发现的问题
55
58
  req index [--rebuild [目录…]] [--prune]
56
59
  本机记录的需求(Paseo 插件据此找到所有需求);1.9 之前运行过的用 --rebuild 补登
57
60
  req api <命令> … 给程序用的 JSON 输出(见 contracts/cli-json.md)
@@ -237,6 +240,9 @@ switch (command) {
237
240
  case "checklist":
238
241
  code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "checklist-cmd.ts"), ...rest]) : 1;
239
242
  break;
243
+ case "outcome":
244
+ code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "outcomes.ts"), ...rest]) : 1;
245
+ break;
240
246
  case "ui-check":
241
247
  code = needTsx() ? node([tsxCli, path.join(kitRoot, "engine", "src", "ui-check.ts"), ...rest]) : 1;
242
248
  break;
@@ -79,7 +79,7 @@ export default async function notify(event, options) { /* 发送 */ }
79
79
  | `completed` | 流水线结束,报告已生成 | `outcome`(`completed`、`completed_with_issues`、`not_completed`)、`summary`、`reportPath`。"只做研发"时另有 `mode`(`develop-only`)和 `handoffPath`,报告是研发报告,需要人工测试 |
80
80
  | `approval` | 1.8.0 起。方案确认点(`designApproval`):方案已通过审核,等待需求方确认"做完后会看到什么",还没有开始写代码 | `previewPath`(要确认的内容)、`approveCommand`(确认后继续的命令)、`resumeCommand`(修改需求后继续的命令),都在发起的对话中执行 |
81
81
  | `phase_done` | 1.7.0 起。分阶段执行时研发阶段已完成(代码已通过评审、提测说明已写好),流水线停在闸口等待测试验证。测试退回后再次修完也会发 | `handoffPath`(提测说明)、`mode`(`gated`)、`verifyCommand`(开始测试验证的命令,在发起的对话中执行)、`verifyAt`(预约的开始时间,ISO 格式,没有为 null)、`handbacks`(已退回的次数) |
82
- | `alert` | 1.12 起。接单器(`req factory`)自检没有通过(Paseo 连不上、agent 没有登录、额度用完、需求管理系统的命令不可用),暂停接新卡片;或者恢复正常。同样的问题只发一次。`feature.title` 为"接单器",`feature.dir`、`repo` 为空 | `source`(`factory`)、`machine`、`problems`(字符串数组,恢复时为空)、`recovered` |
82
+ | `alert` | 1.12 起。接单器(`req factory`)自检没有通过(Paseo 连不上、agent 没有登录、额度用完、需求管理系统的命令不可用),暂停接新卡片;或者恢复正常。同样的问题只发一次。`feature.title` 为"接单器",`feature.dir`、`repo` 为空 | `source`(`factory`)、`machine`、`problems`(字符串数组,恢复时为空)、`recovered`。1.14 起,步骤 agent 运行或开始运行危险命令(`dangerGuard`)时也发:`details.kind` 为 `danger`,另有 `stepTitle`、`command`、`label`、`ran`;这时 `feature`、`repo` 是那个需求的 |
83
83
  | `digest` | `req digest --notify`(或由 `req digest --schedule` 创建的 Paseo 定时任务)发送项目的需求总览,1.6.0 起。`feature.title` 为"<项目> 需求总览",`text` 是完整的多行总览 | `features`:每个需求的 `name`、`title`、`stage`、`branch`、`updatedAt`、`usage`;`recurringLessons`(1.8.0 起):在两个以上需求的 `lessons.tsv` 中出现的问题类别,每项 `category`、`features`、`rules` |
84
84
 
85
85
  ## 兼容性
package/contracts/spec.md CHANGED
@@ -22,6 +22,7 @@
22
22
  | `- 执行方式:` | 引擎 | "连续"、"分阶段"或"只做研发"(也接受 `continuous` / `gated` / `develop-only`)。可以没有;没有时用配置中的 `phases.mode`,启动参数 `--phases` 优先(1.7 起) |
23
23
  | `- 类型:` | 引擎(1.8 起)、`/req` | 新需求 / 小改动 / 缺陷 / 重构 / 评估(也接受 `defect`、`bug`、`refactor`、`assessment`;"技术改进"同重构,"调研"同评估)。缺陷、重构、评估按各自的做法运行(`engine/prompts/types/`),配置 `requirementTypes` 可以关掉某个类型;其他写法按新需求处理 |
24
24
  | `- 联合开发:` | 引擎(1.8 起) | 这个需求还要改的其他仓库,写项目配置 `relatedRepos` 中的名称,最多两个,用顿号分开。可以没有。见 ADR 0025 |
25
+ | `- 引入问题的需求:` | `req stats`、`req outcome`(1.14 起) | 缺陷需求才写:引入这个问题的需求的目录名(或功能目录、标题)。记为那个需求"合并后发现的问题"。可以没有 |
25
26
  | `- 版本:` | `/req` | 从 1 开始,每次修改需求加 1 |
26
27
  | `- 格式:` | `/req`、引擎(将来) | `req-kit/spec@<n>`。没有这一行的旧 spec 按 `req-kit/spec@1` 处理 |
27
28
 
@@ -22,6 +22,8 @@
22
22
  | `budget` | object | 可选,1.13 起,配置了 `budget.tokens` 时。`{ allowance, warnedAt }`:停下前允许用到的 tokens(预算,每次用完后继续再加一份);已经提醒过的那一档 |
23
23
  | `waitingPermission` | object \| null | 可选,1.13 起。步骤 agent 在等权限批准时:`{ agentId, stepTitle, requests, since }`;没有在等时为 null |
24
24
  | `recoveries` | array | 可选,1.12 起。流水线自己处理掉的问题(最多保留 200 条):`at`、`key`(步骤键)、`kind`(`retry` 重试 / `reattach` 接着等上一次的 agent / `reuse_result` 沿用上一次 agent 的结果 / `extend_timeout` 超时后多等一轮 / `redo` 新开 agent 重做 / `fallback` 换备用模型 / `auto_resume` 预约自动继续 / `quota_wait` 预约在额度恢复后继续 / `self_answer` 方案审核的问题由 builder 自答 / `stop_scan` 打断了搜索大目录的命令,1.13 起)、`detail` |
25
+ | `dangers` | array | 可选,1.14 起。步骤 agent 运行(或开始运行)的危险命令(`dangerGuard`,最多保留 50 条):`at`、`key`(步骤键)、`stepTitle`、`command`、`label`(如"推送到远程仓库")、`ran`(发现时已经运行完为 true,正在运行、已打断为 false)、`agentId`、`callId`(同一个调用只处理一次) |
26
+ | `secretScans` | array | 可选,1.14 起。改代码的步骤之后的密钥扫描(`secretScan`),每个步骤、每次尝试保留最新一次:`key`、`attempt`、`at`、`findings`(`{ file, line, commit, kind, preview }`,`preview` 里的值已遮住)、`fixRounds`、`passed` |
25
27
  | `profile` | `quick` \| `standard` | 档位 |
26
28
  | `branch` | string \| null | 工作分支,启动时确定 |
27
29
  | `milestones` | string[] | 已发送的进展通知,续跑时不重复发送 |
@@ -39,7 +41,7 @@
39
41
  | `joint` | array \| null | 可选,1.8.0 起。联合需求(spec 的 `- 联合开发:`)中其他仓库的工作区:`name`(`relatedRepos` 中的名称)、`slug`(步骤键的后缀,如 `implement@backend`)、`sourceRepo`(切出工作区的那份检出)、`repo`(工作区目录)、`workspaceId`、`branch`、`specHash`(最近一次同步过去的 spec),以及这个仓库自己的 `codeBase` 和 `gate`(格式同顶层)。见 ADR 0025 |
40
42
  | `approval` | object \| null | 可选,1.8.0 起。方案确认点:`attempt`(哪一次尝试的方案)、`status`(`waiting` / `approved`)、`at`。需求变更后的新尝试需要重新确认 |
41
43
  | `external` | object \| null | 可选,1.9.0 起。外部系统中的卡片,按集成名称:`id`(卡片编号)、`url`、`status`(最近设置的状态)、`syncedAt`、`pending`(没有成功、等待补发的调用:`action` 为 `create` / `status`、`event`、`status`、`at`、`attempts`、`error`)。见 ADR 0028 |
42
- | `pushed` | array \| null | 可选,1.10 起。从卡片开始、配置了 `push` 的需求推送过的分支,每个仓库一条(主仓库在前):`repo`、`remote`、`branch`、`at`、`error`(成功时为 null)。见 ADR 0029 |
44
+ | `pushed` | array \| null | 可选,1.10 起。从卡片开始、配置了 `push` 的需求推送过的分支,每个仓库一条(主仓库在前):`repo`、`remote`、`branch`、`at`、`error`(成功时为 null;1.14 起,发现疑似密钥时不推送,`error` 写明原因)。见 ADR 0029 |
43
45
  | `gate` | object \| null | 可选,1.8.0 起。质量门:`baseline`(`attempt`、`commit`、`at`、`results`:每项检查改动前 `pass` / `fail`),`runs`(每次运行:`key` 步骤键、`attempt`、`at`、`checks`(`name`、`result`:`pass` / `fail` / `skipped`、`failedBefore`)、`newFailures`、`fixRounds`、`passed`)。各次运行的完整输出在 `.pipeline/checks/<步骤键>/` |
44
46
 
45
47
  `schedules[].kind` 在 1.7.0 起还可能是 `verify-start`(`--verify-at` 预约的测试验证开始),1.8.0 起还可能是 `approval-continue`(方案确认超时后按方案继续,`designApproval.autoContinueHours`)。`history` 中每条步骤记录在 1.7.0 起有可选的 `phase`(`develop` / `verify`),用于按阶段统计用时与用量;没有时按研发阶段统计。
@@ -42,6 +42,19 @@
42
42
 
43
43
  不记录需求内容、代码和对话。
44
44
 
45
+ ### 运行之后的事(1.14 起)
46
+
47
+ 同一个文件里还有几种记录,都有 `schema`(1)、`kind`、`machine`、`kitVersion`、`at` 和 `requirement`(`{ key, project, name, title }`,`key` 与运行记录相同)。读运行记录的旧版本会跳过它们。
48
+
49
+ | `kind` | 何时写 | 字段 |
50
+ | --- | --- | --- |
51
+ | `review` | 插件代码审查页提交"退回修改"或"评审通过" | `review: { verdict: "approved" \| "changes_requested", reviewer, items, head }`:评审人、意见条数、评审的提交 |
52
+ | `acceptance` | 验收清单页提交 | `acceptance: { verdict: "通过" \| "不通过", reviewer, total, passed, failed, unchecked }` |
53
+ | `merge` | `req stats`、`req api stats`、`req outcome` 发现需求的流水线文件(`implementation.md`、`report.md` 等)已经在主干(`origin/HEAD`,没有时 `origin/main`、`main` 等)里;或者 `req outcome <需求> --merged` | `merge: { into, commit, mergedAt, how: "files" \| "manual" }`:`commit`、`mergedAt` 是把它带进主干的那次提交(合并提交或压缩合并的提交);只看本机已有的分支,不 fetch |
54
+ | `issue` | 缺陷需求的 spec 写了 `- 引入问题的需求:<目录名>`(扫描时发现),或者 `req outcome <需求> --issue "<问题>"` | `issue: { text, by, id }`:`by` 是那个缺陷需求的目录名,手动记的为 null;同一个 `id` 只算一次 |
55
+
56
+ 每个需求的 `merge` 只记一次。评审和验收的全文在需求目录的 `code-review.md`、`acceptance.md` 里,随需求分支提交;这里只记结论和数量,用于统计。
57
+
45
58
  ## 统计口径(`req stats`)
46
59
 
47
60
  - 一段时间的用量、步骤用时:结束时间在这段时间内的运行。
@@ -52,6 +65,7 @@
52
65
  - 停下等人的原因(1.12 起):这段时间结束的运行按 `stopKind` 计数;更早的记录按结束时的状态推断。确认方案、研发完成等待测试是必要的决定。
53
66
  - 无人工到提测的比例(1.12 起):这段时间有进展、已经研发完成或完成的需求中,所有运行都只停在必要决定处的比例。
54
67
  - 自动处理的比例(1.12 起):自动处理的次数 ÷(自动处理的次数 + 以中断结束的运行数);需求方决定、缺陷复现不了不算中断。
55
- - 合并多台机器的导出时,同一机器、同一需求、同一开始时间的运行只算一次。
68
+ - 人工把关和合并后(1.14 起,`oversight`):这段时间里的人工代码评审次数、退回修改次数、意见条数,验收次数和不通过次数;"第一次就通过"看每个需求最早的那次评审(验收),它在这段时间里并且是通过;合并进主干的需求数和"完成到合并"的小时数中位数;合并后发现的问题数和涉及的需求数。每个需求一项里的 `afterRun` 汇总它全部的这类记录(`humanReviews`、`sentBack`、`lastReview`、`acceptance`、`acceptanceFailed`、`mergedAt`、`issues`)。
69
+ - 合并多台机器的导出时,同一机器、同一需求、同一开始时间的运行只算一次;同一个需求、同一种、同一时间的事件(问题按 `id`)只算一次。`req stats --export` 同时导出这些需求的事件。
56
70
 
57
- `req stats --json` 和 `req api stats` 输出同样的对象(`schema: 1`):`period`、`machines`、`active`、`completed`、`byType`、`cycleHours`、`steps`、`rework`、`waits`、`usage`、`byBuilder`、`requirements`(每个需求一项,字段与 `req stats --csv` 的列对应)。
71
+ `req stats --json` 和 `req api stats` 输出同样的对象(`schema: 1`):`period`、`machines`、`active`、`completed`、`byType`、`cycleHours`、`steps`、`rework`、`waits`、`usage`、`tokens`、`oversight`、`byBuilder`、`requirements`(每个需求一项,字段与 `req stats --csv` 的列对应)。
@@ -117,9 +117,11 @@ req-kit 帮你把一个需求从"我想要……"做到"可以验收":你在 P
117
117
 
118
118
  测试同事可以把验收标准直接转成测试用例:每条都写成了"前置条件 / 操作 / 预期结果"。
119
119
 
120
+ **记录会留下来(1.14 起)。** 代码审查和验收的结论、评审人、每条意见都写在需求目录的 `code-review.md`、`acceptance.md`(退回修改的原因在 `changes.md`)里,随需求分支提交,合并后在主干上也能查。结论和数量另外记在本机的使用记录里,`req stats` 能看到人工评审退回了几次、验收通过率、合并进主干的需求和合并后发现的问题。
121
+
120
122
  ## 上线后发现问题
121
123
 
122
- 说"线上发现……不对"。AI 会先问这个问题是否正在影响真实用户:是的话,请马上联系开发同事或值班人员处理线上情况(req-kit 不碰生产环境)。然后它会问清现象、复现步骤、影响范围,关联到原来的需求,生成一份缺陷说明,按快速流程修复,修复后照常验收。
124
+ 说"线上发现……不对"。AI 会先问这个问题是否正在影响真实用户:是的话,请马上联系开发同事或值班人员处理线上情况(req-kit 不碰生产环境)。然后它会问清现象、复现步骤、影响范围,关联到原来的需求,生成一份缺陷说明,按快速流程修复,修复后照常验收。缺陷说明里写明是哪个需求引入的问题,`req stats` 会把它算作那个需求"合并后发现的问题";也可以在终端里手动记一笔:`req outcome <需求> --issue "<问题>"`。
123
125
 
124
126
  ## 注意
125
127
 
@@ -533,7 +533,7 @@ Paseo 自带的 `/paseo-advisor`、`/paseo-committee` 等编排 skill 也可以
533
533
 
534
534
  **结果文件**:每步必须写一个 JSON 结果文件,流水线只看它,不看 agent 的回复文字。文件缺失或格式不对时,会在同一个会话里提醒 agent 补写,最多 2 次,仍不对则判定该步骤失败。
535
535
 
536
- **等待与超时**:每步有总时限(`stepTimeoutMinutes`)。到时 agent 还在做时,先再给它同样长的时间(`selfHeal`,见下);仍未完成才判定失败,但不停止 agent。继续(`--resume`、自动重试)时先看这一步上一次的 agent:已经做完、结果文件有效就直接用它的结果;还在做就接着等它一轮(这一轮不再额外延长);第二次仍超时才停止它、另起一个 agent。需求改过、开始了新一次尝试时,上一次留下的 agent 直接停掉,不接手(1.12.1)。同一步不会有两个 agent 同时在做(它们会改同样的文件、在同一个工作区提交、写同一个结果文件)。连续读不到 agent 状态时同样处理。等待被拆成每段 10 分钟(打开 `scanGuard` 时每段 1 分钟,以便查看正在运行的命令),连接断开时自动重试。agent 等待权限批准时,同一个请求只通知一次,然后继续等待。
536
+ **等待与超时**:每步有总时限(`stepTimeoutMinutes`)。到时 agent 还在做时,先再给它同样长的时间(`selfHeal`,见下);仍未完成才判定失败,但不停止 agent。继续(`--resume`、自动重试)时先看这一步上一次的 agent:已经做完、结果文件有效就直接用它的结果;还在做就接着等它一轮(这一轮不再额外延长);第二次仍超时才停止它、另起一个 agent。需求改过、开始了新一次尝试时,上一次留下的 agent 直接停掉,不接手(1.12.1)。同一步不会有两个 agent 同时在做(它们会改同样的文件、在同一个工作区提交、写同一个结果文件)。连续读不到 agent 状态时同样处理。等待被拆成每段 10 分钟(打开 `scanGuard` 时每段 1 分钟、打开 `dangerGuard` 时每段 15 秒,以便查看正在运行的命令;只是读取状态,不占模型 token),连接断开时自动重试。agent 等待权限批准时,同一个请求只通知一次,然后继续等待。
537
537
 
538
538
  **临时错误自动重试**:步骤因限流(429)、服务暂时不可用(502/503/504/529、overloaded)、网络中断等临时错误失败时,等待后用新的 agent 重做这一步,并告诉它工作区里可能留有上一次没做完的改动。默认最多重试 2 次,分别等 1 分钟和 5 分钟:
539
539
 
@@ -569,6 +569,22 @@ Paseo 自带的 `/paseo-advisor`、`/paseo-committee` 等编排 skill 也可以
569
569
 
570
570
  `paths`:另外不允许整个搜索的目录(例如共享盘);`enabled: false` 关闭。
571
571
 
572
+ **拦截危险命令**(1.14,`dangerGuard`,默认开):步骤 agent 通常以免确认模式运行命令,"只做本地提交、不碰生产"以前只写在提示词里。现在步骤运行时,流水线每 15 秒看一次 agent 运行过和正在运行的命令(读 Paseo 的记录,不用模型)。下面这些会被处理:推送(`git push`,强推、删除远程分支单独标出)、改远程地址;发布制品(`npm publish`、`mvn deploy`、`gradle publish`、`docker push` 等);改动集群和基础设施(`kubectl apply/delete/exec…`、`helm install/upgrade…`、`terraform apply/destroy`、`ansible-playbook`);连接其他机器上的数据库(`mysql -h <带点的主机名或地址>` 等,`localhost` 和 docker compose 里的服务名如 `db` 不算);登录或复制到其他机器(`ssh`、`scp`、`rsync`,`git@` 不算);删除仓库以外的目录(`rm -rf ~`、`/`、`..`、仓库外的绝对路径,临时目录除外);下载脚本直接执行(`curl … | sh`);关机、格式化等系统级操作;以及命令里出现 `production` 中任何一项(生产环境的主机名、地址、库名)。还在运行的命令立即打断,告诉 agent 不要再做、把需要人做的写进产物;已经运行完的也会告诉它。每次都通知需求的对话(和外部通知的 `alert` 事件,`details.kind` 为 `danger`),记在状态文件的 `dangers` 里,并写进完成报告。这是按命令文字的检查,能拦住常见的情况,不是沙箱。
573
+
574
+ ```json
575
+ "dangerGuard": { "enabled": true, "production": ["prod.example.com", "10.8.0."], "allow": ["^ssh test-box"] }
576
+ ```
577
+
578
+ `production`:生产环境的标记(出现在命令或访问的地址里就拦),建议团队配置里写上;`allow`:放行的命令(正则表达式),例如测试机的登录;`enabled: false` 关闭。
579
+
580
+ **密钥扫描**(1.14,`secretScan`,默认开):每个改代码的步骤之后(和质量门一起),流水线检查本需求新增的内容:从这次尝试开始时的提交以来,每次提交加进来的行、还没提交的改动和新文件。常见格式的密钥(私钥、AWS、GitHub、GitLab、Slack、模型服务的 API 密钥、JWT、地址里带的口令)在任何文件里都算;`password = "…"`、`token: …` 这样的赋值只在代码和配置里算,测试和文档不算,值是占位符(`${…}`、`<your-token>`、`changeme`)或从环境变量读取的也不算。发现了就交回这一步的 agent:改成从配置或环境变量读取;已经写进本需求提交的,用 `git commit --fixup` 和非交互的 `git rebase --autosquash` 从这些还没推送的提交里去掉;确实不是密钥的,在那一行加注释 `req-kit:allow-secret`。最多要求 `qualityGate.fixAttempts` 次,仍有的通知需求的对话、写进代码评审和完成报告;配置了 `push` 的需求**不会推送**有疑似密钥的分支。
581
+
582
+ ```json
583
+ "secretScan": { "enabled": true, "allow": ["test/fixtures/**"] }
584
+ ```
585
+
586
+ `allow`:不扫描的文件(路径或通配符);`enabled: false` 关闭。
587
+
572
588
  **同一个目录一次只运行一条流水线**,因为各步骤都在这个目录中修改和提交。需要并行时,为每个需求使用单独的 worktree 工作区。
573
589
 
574
590
  ## 注意事项
@@ -78,12 +78,13 @@ time step ref decision alternatives reason kind needs_human
78
78
  - 所有改动都提交到这个分支。不要切换分支,也不要新建分支;发现当前分支不是工作分支时,不要提交,直接结束本步骤并在最后的回复中说明。
79
79
  - 工作分支写着"不是 git 仓库"时,跳过所有 git 操作。
80
80
  - 你的工作结束时提交一次(实现类步骤可以按任务多次提交)。只暂存你这一步改动或新建的文件,不要用 `git add -A` 把别人的未提交改动带进来。`.pipeline/` 目录不提交。
81
- - 只做本地提交。不 push,不合并,不改写历史,不动其他分支。合并上线由人来决定。
81
+ - 只做本地提交。不 push,不合并,不改写历史(流水线要求你从本需求还没推送的提交里去掉密钥时除外),不动其他分支。合并上线由人来决定。
82
82
 
83
83
  **这是一台真实的机器,没有人在旁边。**
84
84
  - 不操作仓库以外的文件,不做全局安装,不修改系统配置。
85
85
  - 不连接生产环境,不使用生产凭据,不触发真实的对外动作(发邮件、发短信、扣款、调用会产生费用或影响真实用户的外部接口)。需要时使用项目自带的测试替身或本地环境。
86
- - 不把密钥、口令、个人信息写进任何文件或提交。
86
+ - 不把密钥、口令、个人信息写进任何文件或提交。流水线会扫描本需求新增的内容,发现了会要求你去掉。
87
+ - 不发布制品(npm publish、mvn deploy、docker push),不改动集群和基础设施(kubectl、helm、terraform),不登录其他机器,不删除仓库以外的目录,不下载脚本直接执行。流水线会打断这类命令并通知用户;需要做的写进产物的"需要人处理"。
87
88
  - 启动的进程在本步骤结束前关掉。
88
89
 
89
90
  **如实报告。** 最终读者没有能力核对你的结论,他们会直接相信你写的内容。
@@ -108,3 +108,24 @@ export function runningToolOf(entries: TimelineEntry[]): import("./scan-guard.js
108
108
  const text = Array.isArray(raw) ? raw.join(" ") : String(raw ?? "");
109
109
  return { kind, text, startedAt: call.first, callId };
110
110
  }
111
+
112
+ /**
113
+ * 1.14: the tool calls at the end of the timeline (for the danger guard), oldest first: each
114
+ * call's latest status, its command (shell), pattern (search) or address (fetch), and when it started.
115
+ */
116
+ export function toolCallsOf(entries: TimelineEntry[]): import("./danger-guard.js").ToolCall[] {
117
+ const calls = new Map<string, { first: string; last: Record<string, unknown> }>();
118
+ for (const entry of entries) {
119
+ const it = entry.item as Record<string, unknown> | null;
120
+ if (!it || typeof it !== "object" || it.type !== "tool_call" || typeof it.callId !== "string") continue;
121
+ const seen = calls.get(it.callId);
122
+ if (seen) seen.last = it;
123
+ else calls.set(it.callId, { first: entry.timestamp, last: it });
124
+ }
125
+ return [...calls.entries()].map(([callId, call]) => {
126
+ const detail = (call.last.detail ?? {}) as Record<string, unknown>;
127
+ const kind = String(detail.type ?? "unknown");
128
+ const raw = kind === "shell" ? detail.command : kind === "search" ? detail.query : kind === "fetch" ? detail.url : (detail.command ?? "");
129
+ return { callId, kind, text: Array.isArray(raw) ? raw.join(" ") : String(raw ?? ""), status: String(call.last.status ?? ""), startedAt: call.first };
130
+ });
131
+ }
package/engine/src/api.ts CHANGED
@@ -29,8 +29,10 @@ import { pruneIndex, readIndex, registerRequirement, requirementKey } from "./re
29
29
  import { listWorktreeRecords } from "./worktree.js";
30
30
  import { readHumanReview } from "./code-review.js";
31
31
  import { isAlive, type PipelineState, readState, statePath, type StepRecord } from "./state.js";
32
+ import { notStartedNotice } from "./messages.js";
33
+ import { scanOutcomes } from "./outcomes.js";
32
34
  import { parsePeriod, summarizeRuns } from "./stats.js";
33
- import { readRuns, runsFile } from "./records.js";
35
+ import { readEvents, readRuns, runsFile } from "./records.js";
34
36
  import { totals } from "./usage.js";
35
37
 
36
38
  export const API_SCHEMA = 1;
@@ -242,13 +244,14 @@ function waitingOf(repo: string, featureDir: string, state: PipelineState | null
242
244
  export function apiList(input: { repo: string; all: boolean }): ListItem[] {
243
245
  if (!input.all) {
244
246
  const repo = fs.realpathSync(input.repo);
245
- return listFeatures(repo)
247
+ const items = listFeatures(repo)
246
248
  .map((f) => {
247
249
  const state = readStateSafe(f.worktree, f.featureDir);
248
250
  const source = state?.worktree?.sourceRepo ?? null;
249
251
  return item(f.worktree, f.branch, f.featureDir, { sourceRepo: source, workspaceId: state?.worktree?.workspaceId ?? null, project: path.basename(source ?? repo) });
250
252
  })
251
253
  .filter((x): x is ListItem => x !== null);
254
+ return onePerRequirement(items);
252
255
  }
253
256
  const out: ListItem[] = [];
254
257
  const index = readIndex();
@@ -301,7 +304,21 @@ export function apiList(input: { repo: string; all: boolean }): ListItem[] {
301
304
  }
302
305
  }
303
306
  }
304
- return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
307
+ return onePerRequirement(out).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
308
+ }
309
+
310
+ /**
311
+ * 1.14: a requirement found in two places (the main checkout still has its spec, its own worktree
312
+ * has the run) is listed once: the copy that runs, else the one with a run state, else the latest.
313
+ */
314
+ export function onePerRequirement(items: ListItem[]): ListItem[] {
315
+ const rank = (x: ListItem) => (x.running ? 3 : 0) + (x.status ? 2 : 0) + (x.present === false ? -4 : 0);
316
+ const best = new Map<string, ListItem>();
317
+ for (const x of items) {
318
+ const b = best.get(x.key);
319
+ if (!b || rank(x) > rank(b) || (rank(x) === rank(b) && x.updatedAt > b.updatedAt)) best.set(x.key, x);
320
+ }
321
+ return items.filter((x) => best.get(x.key) === x);
305
322
  }
306
323
 
307
324
  function samePathSafe(a: string, b: string): boolean {
@@ -547,7 +564,9 @@ export function apiMain(argvIn: string[], cwd = process.cwd()): unknown {
547
564
  case "stats": {
548
565
  const since = option(argv, "--since");
549
566
  const month = option(argv, "--month");
550
- return summarizeRuns(readRuns(runsFile()), parsePeriod({ since, month }));
567
+ // 1.14: merges and post-merge problems this machine can see, then runs and events.
568
+ scanOutcomes();
569
+ return summarizeRuns(readRuns(runsFile()), parsePeriod({ since, month }), readEvents(runsFile()));
551
570
  }
552
571
  case "index": {
553
572
  const prune = flag(argv, "--prune");
@@ -582,7 +601,13 @@ export function apiMain(argvIn: string[], cwd = process.cwd()): unknown {
582
601
  const by = (option(argv, "--by") ?? "manual") as "plugin" | "factory" | "manual";
583
602
  const dryRun = flag(argv, "--dry-run");
584
603
  return import("./watchdog.js").then(({ watchdogPass }) =>
585
- watchdogPass({ by: ["plugin", "factory"].includes(by) ? by : "manual", dryRun, resume: (r, f) => apiRun("resume", r, f, []) }),
604
+ watchdogPass({
605
+ by: ["plugin", "factory"].includes(by) ? by : "manual",
606
+ dryRun,
607
+ resume: (r, f) => apiRun("resume", r, f, []),
608
+ // 1.14: a confirmed requirement nobody started: one reminder in its chat.
609
+ remind: (r, f) => apiRun("reply", r, f, ["--notice", notStartedNotice(f)]),
610
+ }),
586
611
  );
587
612
  }
588
613
  case "review-url": {
@@ -9,6 +9,7 @@ import path from "node:path";
9
9
  import { section } from "./checklist.js";
10
10
  import { ownersFor, readCodeowners } from "./codeowners.js";
11
11
  import { KIT_ROOT } from "./locations.js";
12
+ import { appendEvent, requirementRef } from "./records.js";
12
13
  import { isAlive, readState, statePath } from "./state.js";
13
14
  import { readWorktreeRecord } from "./worktree.js";
14
15
 
@@ -398,6 +399,8 @@ export function recordHumanReview(repo: string, featureDir: string, sub: Submiss
398
399
  const committed = commitDocs(repo, touched, `docs(${path.basename(featureDir)}): 人工代码评审${sub.verdict === "approve" ? "通过" : "意见"}`);
399
400
  // A new review replaces the draft.
400
401
  fs.rmSync(path.join(humanDir(featureAbs), "draft.json"), { force: true });
402
+ // 1.14: the review also goes into the usage records, for req stats.
403
+ appendEvent({ kind: "review", at: review.at, requirement: requirementRef(repo, featureDir), review: { verdict: review.status, reviewer, items: items.length, head: review.head } });
401
404
  return { review, committed };
402
405
  }
403
406
 
@@ -127,6 +127,16 @@ const CATALOG: Array<{ id: string; title: string; description: string; keys: Rec
127
127
  description: "步骤 agent 搜索整个磁盘、用户目录、Maven 仓库(.m2)、全局 node_modules 等很大的目录时,一分钟后打断它并告诉它该怎么查;任何搜索运行太久也打断。",
128
128
  display: (v) => { const g = v as PipelineConfig["scanGuard"]; return g.enabled ? `开,搜索超过 ${g.minutes} 分钟也打断${g.paths.length ? `;另外不许整个搜索:${g.paths.join("、")}` : ""}` : "关"; },
129
129
  },
130
+ dangerGuard: {
131
+ label: "拦截危险命令",
132
+ description: "步骤 agent 推送代码、发布制品、改动集群、连接其他机器上的数据库或主机、删除仓库以外的目录、下载脚本直接执行,或命令里出现生产环境的地址时,打断它并通知你。",
133
+ display: (v) => { const g = v as PipelineConfig["dangerGuard"]; return g.enabled ? `开${g.production.length ? `;生产环境标记 ${g.production.length} 个` : ""}${g.allow.length ? `;放行 ${g.allow.length} 条` : ""}` : "关"; },
134
+ },
135
+ secretScan: {
136
+ label: "密钥扫描",
137
+ description: "每次改代码的步骤之后、推送分支之前,检查本需求新增的内容里有没有密钥、令牌、口令;发现了让 agent 去掉,去不掉就不推送。",
138
+ display: (v) => { const g = v as PipelineConfig["secretScan"]; return g.enabled ? `开${g.allow.length ? `;不扫描:${g.allow.join("、")}` : ""}` : "关"; },
139
+ },
130
140
  },
131
141
  },
132
142
  {
@@ -251,6 +251,15 @@ export interface PipelineConfig {
251
251
  * and tell it how to find what it needs instead. `paths`: more directories never to search whole.
252
252
  */
253
253
  scanGuard: import("./scan-guard.js").ScanGuardSettings;
254
+ /**
255
+ * 1.14: stop a step agent's dangerous commands (push, publish, cluster changes, remote databases
256
+ * and hosts, deleting outside the repository, downloaded scripts) and tell the person.
257
+ * `production`: text that marks production systems (host names, addresses); `allow`: regular
258
+ * expressions of commands that are allowed after all.
259
+ */
260
+ dangerGuard: import("./danger-guard.js").DangerGuardSettings;
261
+ /** 1.14: look for keys, tokens and passwords in what the requirement added (quality gate, before push). `allow`: files never scanned. */
262
+ secretScan: import("./secret-scan.js").SecretScanSettings;
254
263
  /** Automatic retries of a step after a transient error (rate limit, dropped connection). */
255
264
  retry: { attempts: number; backoffSeconds: number[] };
256
265
  /**
@@ -362,6 +371,8 @@ export const DEFAULT_CONFIG: PipelineConfig = {
362
371
  autoConfirm: { hours: 0 },
363
372
  budget: { tokens: null, warnPercent: 80 },
364
373
  scanGuard: { enabled: true, minutes: 5, paths: [] },
374
+ dangerGuard: { enabled: true, production: [], allow: [] },
375
+ secretScan: { enabled: true, allow: [] },
365
376
  retry: { attempts: 2, backoffSeconds: [60, 300] },
366
377
  paseoServices: "auto",
367
378
  serviceReadySeconds: 120,
@@ -432,6 +443,8 @@ function merge(base: PipelineConfig, raw: Record<string, unknown>): PipelineConf
432
443
  merged.autoConfirm = { ...base.autoConfirm, ...(isRecord(raw.autoConfirm) ? raw.autoConfirm : {}) } as PipelineConfig["autoConfirm"];
433
444
  merged.budget = { ...base.budget, ...(isRecord(raw.budget) ? raw.budget : {}) } as PipelineConfig["budget"];
434
445
  merged.scanGuard = { ...base.scanGuard, ...(isRecord(raw.scanGuard) ? raw.scanGuard : {}) } as PipelineConfig["scanGuard"];
446
+ merged.dangerGuard = { ...base.dangerGuard, ...(isRecord(raw.dangerGuard) ? raw.dangerGuard : {}) } as PipelineConfig["dangerGuard"];
447
+ merged.secretScan = { ...base.secretScan, ...(isRecord(raw.secretScan) ? raw.secretScan : {}) } as PipelineConfig["secretScan"];
435
448
  if (isRecord(raw.cardSource)) {
436
449
  const c = raw.cardSource;
437
450
  merged.cardSource = {
@@ -657,6 +670,20 @@ function validate(config: PipelineConfig): void {
657
670
  if (typeof sg.enabled !== "boolean") throw new ConfigError("scanGuard.enabled 必须是 true 或 false");
658
671
  if (typeof sg.minutes !== "number" || !(sg.minutes >= 1)) throw new ConfigError("scanGuard.minutes 必须是至少 1 的分钟数(搜索运行多久后打断)");
659
672
  if (!Array.isArray(sg.paths) || !sg.paths.every((p) => typeof p === "string" && p.trim())) throw new ConfigError('scanGuard.paths 必须是目录路径的数组,例如 ["D:/share"]');
673
+ const dg = config.dangerGuard;
674
+ if (typeof dg.enabled !== "boolean") throw new ConfigError("dangerGuard.enabled 必须是 true 或 false");
675
+ if (!Array.isArray(dg.production) || !dg.production.every((p) => typeof p === "string" && p.trim())) throw new ConfigError('dangerGuard.production 必须是字符串数组(生产环境的主机名、地址等),例如 ["prod.example.com"]');
676
+ if (!Array.isArray(dg.allow) || !dg.allow.every((p) => typeof p === "string" && p.trim())) throw new ConfigError('dangerGuard.allow 必须是正则表达式字符串的数组,例如 ["^ssh test-box"]');
677
+ for (const a of dg.allow) {
678
+ try {
679
+ new RegExp(a);
680
+ } catch (error) {
681
+ throw new ConfigError(`dangerGuard.allow 里的「${a}」不是合法的正则表达式:${(error as Error).message}`);
682
+ }
683
+ }
684
+ const ss = config.secretScan;
685
+ if (typeof ss.enabled !== "boolean") throw new ConfigError("secretScan.enabled 必须是 true 或 false");
686
+ if (!Array.isArray(ss.allow) || !ss.allow.every((p) => typeof p === "string" && p.trim())) throw new ConfigError('secretScan.allow 必须是文件路径或通配符的数组,例如 ["test/fixtures/**"]');
660
687
  const f = config.factory;
661
688
  if (typeof f.pollMinutes !== "number" || !(f.pollMinutes >= 1)) throw new ConfigError("factory.pollMinutes 必须是至少 1 的分钟数");
662
689
  if (!Number.isInteger(f.maxNewPerPoll) || f.maxNewPerPoll < 1) throw new ConfigError("factory.maxNewPerPoll 必须是正整数");
@@ -0,0 +1,180 @@
1
+ // Stopping dangerous commands (1.14): step agents usually run commands without asking (the
2
+ // provider's "no confirmation" mode), so the rules "only local commits, never touch production"
3
+ // were only words in the prompt. While a step runs, the pipeline looks at the commands the agent
4
+ // has run or is running (its Paseo timeline, every few seconds to a minute); a command that
5
+ // pushes, publishes, changes a cluster, connects to a remote database or host, deletes outside
6
+ // the repository, runs a downloaded script, or mentions a configured production address is
7
+ // interrupted (if still running) and reported to the person. A plain check of the command
8
+ // text, no model; it catches the common cases, it is not a sandbox.
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ export interface DangerGuardSettings {
13
+ enabled: boolean;
14
+ /** Text that marks a production system (host names, addresses, database names); any command containing it is stopped. */
15
+ production: string[];
16
+ /** Regular expressions of commands that are allowed after all (e.g. "^ssh test-box"). */
17
+ allow: string[];
18
+ }
19
+
20
+ export interface ToolCall {
21
+ callId: string;
22
+ kind: string;
23
+ text: string;
24
+ status: string;
25
+ startedAt: string;
26
+ }
27
+
28
+ export interface Danger {
29
+ /** What kind of operation it is, for the person (e.g. "推送到远程仓库"). */
30
+ label: string;
31
+ /** Why it is not allowed in a pipeline step. */
32
+ why: string;
33
+ }
34
+
35
+ const LOCAL_HOSTS = /^(localhost|127(\.\d+){3}|0\.0\.0\.0|::1|\[::1\]|host\.docker\.internal)$/i;
36
+
37
+ /** Splits a command line into simple commands (on ; && || | and newlines), each a list of words without quotes. */
38
+ export function segments(command: string): string[][] {
39
+ const out: string[][] = [];
40
+ for (const part of command.split(/\|\||&&|[;|\n]|\r/)) {
41
+ const words = (part.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((w) => w.replace(/^["']|["']$/g, ""));
42
+ // Wrappers that run the rest as a command.
43
+ while (words.length && /^(sudo|env|nohup|time|exec|command|xargs|npx|cmd(\.exe)?|\/c|powershell(\.exe)?|pwsh|-Command|-c|bash|sh|zsh|-lc|-l)$/i.test(words[0])) words.shift();
44
+ while (words.length && /^\w+=/.test(words[0])) words.shift();
45
+ // A command given as one quoted argument (bash -lc "git push"): look inside it.
46
+ if (words.length && /\s/.test(words[0])) out.push(...segments(words.join(" ")));
47
+ else if (words.length) out.push(words);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ const base = (w: string) => w.replace(/\\/g, "/").split("/").pop()!.replace(/\.(exe|cmd|bat)$/i, "").toLowerCase();
53
+
54
+ function hostOf(value: string): string {
55
+ const v = value.replace(/^\w+:\/\//, "").replace(/^[^@/]*@/, "");
56
+ return v.replace(/[:/].*$/, "");
57
+ }
58
+
59
+ /** A host that is clearly another machine: an address or a dotted name that is not this machine. Service names of docker compose ("db") count as local. */
60
+ function remoteHost(host: string): boolean {
61
+ if (!host || LOCAL_HOSTS.test(host)) return false;
62
+ return /\./.test(host) || /^\d+(\.\d+){3}$/.test(host);
63
+ }
64
+
65
+ function deletesOutside(words: string[], repo: string | null, home: string): string | null {
66
+ const targets = words.slice(1).filter((w) => !w.startsWith("-") && !/^\/[a-z]$/i.test(w));
67
+ const h = home.replace(/\\/g, "/").replace(/\/+$/, "");
68
+ for (const raw of targets) {
69
+ const t = raw.replace(/\\/g, "/").replace(/\/+$/, "") || "/";
70
+ if (/^(\/|\/\*|~|~\/\*|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE|[a-z]:|[a-z]:\/\*|\.\.(\/\.\.)*(\/\*)?|\*|\/\*\.\*)$/i.test(t)) return raw;
71
+ if (t === h) return raw;
72
+ if (/^(\/|[a-z]:\/)/i.test(t)) {
73
+ const abs = path.resolve(t);
74
+ const inside = (dir: string | null) => !!dir && (abs === path.resolve(dir) || abs.startsWith(path.resolve(dir) + path.sep));
75
+ if (!inside(repo) && !inside(os.tmpdir()) && !/^\/(tmp|var\/tmp)(\/|$)/.test(t)) return raw;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function judgeSegment(words: string[], repo: string | null, home: string): Danger | null {
82
+ const cmd = base(words[0]);
83
+ const rest = words.slice(1);
84
+ const sub = rest.find((w) => !w.startsWith("-"))?.toLowerCase() ?? "";
85
+ const has = (re: RegExp) => rest.some((w) => re.test(w));
86
+
87
+ if (cmd === "git") {
88
+ // Skip global options (-C dir, -c k=v) to find the git command.
89
+ let i = 0;
90
+ while (i < rest.length && rest[i].startsWith("-")) i += /^-[Cc]$/.test(rest[i]) ? 2 : 1;
91
+ const g = rest[i]?.toLowerCase();
92
+ const args = rest.slice(i + 1);
93
+ if (g === "push") {
94
+ const force = args.some((w) => /^(--force|--force-with-lease|-f|--mirror|--delete|-d)$/.test(w) || /^\+/.test(w) || /^--force-with-lease=/.test(w) || /^:/.test(w));
95
+ return force
96
+ ? { label: "强制推送或删除远程分支", why: "会改写或删除远程仓库的历史,别人的提交可能丢失" }
97
+ : { label: "推送到远程仓库", why: "流水线只做本地提交,推送和合并由人决定(接单器模式由流水线自己推送需求分支)" };
98
+ }
99
+ if (g === "remote" && /^(add|set-url|remove|rm)$/.test(args[0] ?? "")) return { label: "修改远程仓库地址", why: "会改变代码推送到哪里" };
100
+ return null;
101
+ }
102
+ if (cmd === "rm" && rest.some((w) => /^-[a-z]*r/i.test(w) || w === "--recursive")) {
103
+ const t = deletesOutside(words, repo, home);
104
+ if (t) return { label: `删除仓库以外的目录(${t})`, why: "会删除仓库以外的文件,无法恢复" };
105
+ }
106
+ if (/^(remove-item|ri|rd|rmdir|del|erase)$/.test(cmd) && has(/^(-recurse|-r|\/s)$/i)) {
107
+ const t = deletesOutside(words.filter((w) => !/^-(recurse|force|r)$/i.test(w) && !/^\/[sq]$/i.test(w)), repo, home);
108
+ if (t) return { label: `删除仓库以外的目录(${t})`, why: "会删除仓库以外的文件,无法恢复" };
109
+ }
110
+ if (/^(npm|pnpm|yarn|cargo|gem|twine|poetry|flit)$/.test(cmd) && (sub === "publish" || (sub === "npm" && rest[1] === "publish") || (cmd === "gem" && sub === "push") || (cmd === "twine" && sub === "upload")))
111
+ return { label: "发布包到制品仓库", why: "发布出去的版本别人马上能用到,撤不回来" };
112
+ if (/^(mvn|mvnw)$/.test(cmd) && has(/^(deploy|release:perform|deploy:deploy(-file)?)$/)) return { label: "发布制品(mvn deploy)", why: "发布出去的版本别人马上能用到,撤不回来" };
113
+ if (/^(gradle|gradlew)$/.test(cmd) && has(/^(publish\w*|uploadArchives)$/)) return { label: "发布制品(gradle publish)", why: "发布出去的版本别人马上能用到,撤不回来" };
114
+ if (cmd === "docker" && sub === "push") return { label: "推送镜像到镜像仓库", why: "镜像仓库是共享的,部署可能直接用到" };
115
+ if (cmd === "docker" && sub === "login") return { label: "登录镜像仓库", why: "会在这台机器上保存镜像仓库的凭据" };
116
+ if (cmd === "dotnet" && sub === "nuget" && rest[1] === "push") return { label: "发布包到制品仓库", why: "发布出去的版本别人马上能用到,撤不回来" };
117
+ if (/^(kubectl|oc)$/.test(cmd) && /^(apply|create|delete|replace|patch|scale|rollout|edit|drain|cordon|exec|cp|set|label|annotate|run)$/.test(sub))
118
+ return { label: "修改集群(kubectl)", why: "会改动共享的环境,流水线只在本地或测试环境验证" };
119
+ if (cmd === "helm" && /^(install|upgrade|uninstall|delete|rollback)$/.test(sub)) return { label: "修改集群(helm)", why: "会改动共享的环境,流水线只在本地或测试环境验证" };
120
+ if (cmd === "terraform" && /^(apply|destroy|import|taint)$/.test(sub)) return { label: "修改基础设施(terraform)", why: "会改动共享的环境,流水线只在本地或测试环境验证" };
121
+ if (cmd === "ansible-playbook" || (cmd === "ansible" && !has(/^(--check|-C)$/))) return { label: "在服务器上执行(ansible)", why: "会改动其他机器" };
122
+ if (/^(mysql|mysqldump|psql|pg_dump|mongo|mongosh|redis-cli|sqlcmd|sqlplus|clickhouse-client|clickhouse)$/.test(cmd)) {
123
+ for (let i = 0; i < rest.length; i++) {
124
+ const w = rest[i];
125
+ let host: string | null = null;
126
+ const m = /^(-h|--host|-H|-S|--server)(=?)(.*)$/.exec(w);
127
+ if (m) host = m[3] || rest[i + 1] || "";
128
+ else if (/^\w+:\/\//.test(w)) host = hostOf(w);
129
+ else if (cmd === "sqlplus" && /@/.test(w)) host = hostOf(w.split("@").pop() ?? "");
130
+ if (host !== null && remoteHost(host.replace(/,\d+$/, ""))) return { label: `连接其他机器上的数据库(${host})`, why: "可能是共享或生产的数据库;流水线只用本机或测试用的数据库" };
131
+ }
132
+ }
133
+ if (/^(ssh|scp|sftp|rsync)$/.test(cmd)) {
134
+ const target = rest.find((w) => !w.startsWith("-") && (cmd === "ssh" || cmd === "sftp" || /:/.test(w)));
135
+ if (target && !/^git@/.test(target)) {
136
+ const host = cmd === "ssh" || cmd === "sftp" ? hostOf(target) : hostOf(target.split(":")[0]);
137
+ if (host && !LOCAL_HOSTS.test(host)) return { label: `登录或复制文件到其他机器(${host})`, why: "会改动其他机器;流水线只在本地或测试环境验证" };
138
+ }
139
+ }
140
+ if (/^(shutdown|reboot|halt|poweroff|mkfs|diskpart|format|stop-computer|restart-computer)$/.test(cmd) || /^mkfs\./.test(cmd)) return { label: "系统级操作", why: "会影响整台机器" };
141
+ if (cmd === "dd" && has(/^of=\/dev\//)) return { label: "直接写磁盘", why: "会破坏磁盘上的数据" };
142
+ if (/^(chmod|chown)$/.test(cmd) && has(/^-R$/) && rest.some((w) => /^(\/|~|\$HOME)\/?$/.test(w))) return { label: "修改整个系统的权限", why: "会影响整台机器" };
143
+ return null;
144
+ }
145
+
146
+ /** Whether a command (or a fetched address) is one a pipeline step must not run, and why. */
147
+ export function dangerOf(text: string, settings: Pick<DangerGuardSettings, "production" | "allow">, options: { repo?: string | null; home?: string } = {}): Danger | null {
148
+ if (!text.trim()) return null;
149
+ for (const a of settings.allow) {
150
+ try {
151
+ if (new RegExp(a, "i").test(text)) return null;
152
+ } catch {
153
+ /* validated with the configuration */
154
+ }
155
+ }
156
+ const lower = text.toLowerCase();
157
+ for (const p of settings.production) {
158
+ if (p.trim() && lower.includes(p.trim().toLowerCase())) return { label: `访问生产环境(${p.trim()})`, why: "流水线不连接生产环境、不使用生产凭据" };
159
+ }
160
+ if (/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/i.test(text) || /\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b[^|]*\|\s*(iex|invoke-expression)\b/i.test(text))
161
+ return { label: "下载脚本直接执行", why: "执行的内容没有经过检查" };
162
+ const home = options.home ?? os.homedir();
163
+ for (const words of segments(text)) {
164
+ const d = judgeSegment(words, options.repo ?? null, home);
165
+ if (d) return d;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /** What the agent is told when the command is stopped (or was found after it ran). */
171
+ export function dangerMessage(command: string, danger: Danger, ran: boolean): string {
172
+ const one = command.replace(/\s+/g, " ").trim();
173
+ const clip = one.length > 160 ? `${one.slice(0, 159)}…` : one;
174
+ return [
175
+ `【流水线提示】${ran ? "你刚才运行了" : "已停止你正在运行的"}命令「${clip}」:这是${danger.label},${danger.why}。需求开发流水线里不允许这类操作,用户已收到通知。`,
176
+ "- 不要再运行这条命令,也不要换一种写法做同样的事。",
177
+ `- ${ran ? "如果它已经改动了仓库以外或远程的东西,不要自己试图撤销;" : ""}在本步骤产物里写一节\"风险操作\":运行了什么、为什么、${ran ? "造成了什么影响" : "是否已经产生影响"}。`,
178
+ "- 然后继续完成本步骤的任务:需要推送、发布、部署或连接其他环境的,写进产物的\"需要人处理\",由人来做。",
179
+ ].join("\n");
180
+ }
@@ -83,7 +83,7 @@ interface RawState {
83
83
  * Files only the pipeline writes: their presence means it ran, even where its state is not (a
84
84
  * copy merged into the main branch: the state file under .pipeline/ is never committed).
85
85
  */
86
- const PIPELINE_OUTPUTS = ["design.md", "assessment.md", "diagnosis.md", "implementation.md", "handoff.md", "verification.md", "report.md"];
86
+ export const PIPELINE_OUTPUTS = ["design.md", "assessment.md", "diagnosis.md", "implementation.md", "handoff.md", "verification.md", "report.md"];
87
87
 
88
88
  export function developedIn(dir: string): boolean {
89
89
  return PIPELINE_OUTPUTS.some((f) => fs.existsSync(path.join(dir, f)));