paseo-req-kit 1.13.0-beta.5 → 1.14.0-beta.1

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 (127) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/contracts/cli-json.md +4 -4
  3. package/contracts/state.md +1 -1
  4. package/contracts/usage-record.md +5 -1
  5. package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +2 -0
  6. package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +1 -1
  7. 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 -2
  8. package/engine/prompts/00-shared-context.md +2 -0
  9. package/engine/src/activity.ts +23 -0
  10. package/engine/src/api.ts +106 -5
  11. package/engine/src/cli-args.ts +3 -0
  12. package/engine/src/config-view.ts +5 -0
  13. package/engine/src/config.ts +12 -0
  14. package/engine/src/control.ts +8 -1
  15. package/engine/src/doctor.ts +18 -0
  16. package/engine/src/features.ts +21 -1
  17. package/engine/src/launch.ts +25 -4
  18. package/engine/src/paseo-runtime.ts +14 -1
  19. package/engine/src/pipeline.ts +45 -1
  20. package/engine/src/preflight.ts +2 -0
  21. package/engine/src/records.ts +24 -2
  22. package/engine/src/review.ts +3 -1
  23. package/engine/src/runtime.ts +4 -0
  24. package/engine/src/scan-guard.ts +132 -0
  25. package/engine/src/state.ts +3 -1
  26. package/engine/src/stats.ts +1 -0
  27. package/engine/src/worktree.ts +180 -25
  28. package/package.json +1 -1
  29. package/paseo-plugin/client/format.ts +6 -2
  30. package/paseo-plugin/client/inbox.tsx +6 -1
  31. package/paseo-plugin/client/review.tsx +11 -1
  32. package/paseo-plugin/client/web.ts +17 -0
  33. package/paseo-plugin/package.json +1 -1
  34. package/paseo-plugin/shared/contracts.ts +4 -2
  35. package/site-dist/404.html +2 -2
  36. package/site-dist/assets/{app.BhERz0tz.js → app.ByJ4x-zl.js} +1 -1
  37. package/site-dist/assets/changelog.md.BUTM13q-.js +1 -0
  38. package/site-dist/assets/{changelog.md.Cu4SyOh_.lean.js → changelog.md.BUTM13q-.lean.js} +1 -1
  39. package/site-dist/assets/chunks/@localSearchIndexroot.BJ01YY7h.js +1 -0
  40. package/site-dist/assets/chunks/{VPLocalSearchBox.BZONO_Xu.js → VPLocalSearchBox.LeFVc-_M.js} +1 -1
  41. package/site-dist/assets/chunks/{theme.Cdyvhz0n.js → theme.BMoBTK1B.js} +2 -2
  42. package/site-dist/assets/{guide_getting-started.md.CspCz67n.js → guide_getting-started.md.DDZak2vT.js} +1 -1
  43. package/site-dist/assets/{guide_getting-started.md.CspCz67n.lean.js → guide_getting-started.md.DDZak2vT.lean.js} +1 -1
  44. package/site-dist/assets/guide_maintainer.md.C1v7EpSY.js +1 -0
  45. package/site-dist/assets/guide_project.md.Csi5wiyt.js +77 -0
  46. package/site-dist/assets/guide_project.md.Csi5wiyt.lean.js +77 -0
  47. package/site-dist/assets/{guide_user.md.Cenm9vlN.js → guide_user.md.2v7b_fzd.js} +1 -1
  48. package/site-dist/assets/{guide_user.md.Cenm9vlN.lean.js → guide_user.md.2v7b_fzd.lean.js} +1 -1
  49. package/site-dist/assets/reference_contracts_cli-json.md.BR6gwq-e.js +1 -0
  50. package/site-dist/assets/reference_contracts_cli-json.md.BR6gwq-e.lean.js +1 -0
  51. package/site-dist/assets/reference_contracts_state.md.EEBd_l2P.js +1 -0
  52. package/site-dist/assets/reference_contracts_usage-record.md.D5krNLW7.js +10 -0
  53. package/site-dist/assets/reference_contracts_usage-record.md.D5krNLW7.lean.js +10 -0
  54. package/site-dist/changelog.html +5 -5
  55. package/site-dist/guide/getting-started.html +5 -5
  56. package/site-dist/guide/how-it-works.html +4 -4
  57. package/site-dist/guide/index.html +4 -4
  58. package/site-dist/guide/install.html +4 -4
  59. package/site-dist/guide/maintainer.html +5 -5
  60. package/site-dist/guide/phases.html +4 -4
  61. package/site-dist/guide/project.html +7 -7
  62. package/site-dist/guide/user.html +5 -5
  63. package/site-dist/hashmap.json +1 -1
  64. package/site-dist/index.html +4 -4
  65. package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +4 -4
  66. package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +4 -4
  67. package/site-dist/reference/adr/0003-script-engine-not-hub.html +4 -4
  68. package/site-dist/reference/adr/0004-result-files.html +4 -4
  69. package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +4 -4
  70. package/site-dist/reference/adr/0006-skill-install-targets.html +4 -4
  71. package/site-dist/reference/adr/0007-own-interview-skill.html +4 -4
  72. package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +4 -4
  73. package/site-dist/reference/adr/0009-versioned-install-layout.html +4 -4
  74. package/site-dist/reference/adr/0010-generated-feature-overview.html +4 -4
  75. package/site-dist/reference/adr/0011-resume-after-completion.html +4 -4
  76. package/site-dist/reference/adr/0012-review-panel.html +4 -4
  77. package/site-dist/reference/adr/0013-no-release-tooling.html +4 -4
  78. package/site-dist/reference/adr/0014-notifier-slot.html +4 -4
  79. package/site-dist/reference/adr/0015-incremental-continue.html +4 -4
  80. package/site-dist/reference/adr/0016-upgrade-from-tags.html +4 -4
  81. package/site-dist/reference/adr/0017-use-paseo-capabilities.html +4 -4
  82. package/site-dist/reference/adr/0018-own-worktree-option.html +4 -4
  83. package/site-dist/reference/adr/0019-parallel-orchestration.html +4 -4
  84. package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +4 -4
  85. package/site-dist/reference/adr/0021-npm-version-manager.html +4 -4
  86. package/site-dist/reference/adr/0022-develop-and-verify-phases.html +4 -4
  87. package/site-dist/reference/adr/0023-types-gate-slices.html +4 -4
  88. package/site-dist/reference/adr/0024-confirm-early-review-axes.html +4 -4
  89. package/site-dist/reference/adr/0025-joint-requirements.html +4 -4
  90. package/site-dist/reference/adr/0026-machine-records-and-api.html +4 -4
  91. package/site-dist/reference/adr/0027-paseo-plugin.html +4 -4
  92. package/site-dist/reference/adr/0028-external-integrations.html +4 -4
  93. package/site-dist/reference/adr/0029-requirements-from-cards.html +4 -4
  94. package/site-dist/reference/adr/0030-card-intake-process.html +4 -4
  95. package/site-dist/reference/adr/0031-fewer-interventions.html +4 -4
  96. package/site-dist/reference/adr/0032-npm-only-install.html +4 -4
  97. package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +4 -4
  98. package/site-dist/reference/adr/index.html +4 -4
  99. package/site-dist/reference/commands.html +4 -4
  100. package/site-dist/reference/config.html +4 -4
  101. package/site-dist/reference/contracts/cli-json.html +5 -5
  102. package/site-dist/reference/contracts/feature-files.html +4 -4
  103. package/site-dist/reference/contracts/index.html +4 -4
  104. package/site-dist/reference/contracts/notifier-event.html +4 -4
  105. package/site-dist/reference/contracts/result-file.html +4 -4
  106. package/site-dist/reference/contracts/spec.html +4 -4
  107. package/site-dist/reference/contracts/state.html +5 -5
  108. package/site-dist/reference/contracts/usage-record.html +6 -6
  109. package/site-dist/reference/contracts/verify.html +4 -4
  110. package/site-dist/reference/scope.html +4 -4
  111. package/skills/req/SKILL.md +1 -0
  112. package/skills/req/playbooks/incident.md +3 -3
  113. package/skills/req/playbooks/new.md +1 -1
  114. package/skills/req/playbooks/small.md +1 -1
  115. package/skills/spec-writing/SKILL.md +3 -1
  116. package/site-dist/assets/changelog.md.Cu4SyOh_.js +0 -1
  117. package/site-dist/assets/chunks/@localSearchIndexroot.C1vUA1BF.js +0 -1
  118. package/site-dist/assets/guide_maintainer.md.RZIzyCTV.js +0 -1
  119. package/site-dist/assets/guide_project.md.DnD2-Dsd.js +0 -77
  120. package/site-dist/assets/guide_project.md.DnD2-Dsd.lean.js +0 -77
  121. package/site-dist/assets/reference_contracts_cli-json.md.B7o8deSY.js +0 -1
  122. package/site-dist/assets/reference_contracts_cli-json.md.B7o8deSY.lean.js +0 -1
  123. package/site-dist/assets/reference_contracts_state.md.TRfzD5bc.js +0 -1
  124. package/site-dist/assets/reference_contracts_usage-record.md.DBMNY4OP.js +0 -10
  125. package/site-dist/assets/reference_contracts_usage-record.md.DBMNY4OP.lean.js +0 -10
  126. /package/site-dist/assets/{guide_maintainer.md.RZIzyCTV.lean.js → guide_maintainer.md.C1v7EpSY.lean.js} +0 -0
  127. /package/site-dist/assets/{reference_contracts_state.md.TRfzD5bc.lean.js → reference_contracts_state.md.EEBd_l2P.lean.js} +0 -0
@@ -16,6 +16,7 @@ import { loadConfig, PHASE_MODE_LABELS, type PipelineConfig, PROFILE_LABELS } fr
16
16
  import { cardFieldsText } from "./integrations.js";
17
17
  import { defaultConfigPath, ENGINE_DIR } from "./locations.js";
18
18
  import { describeLiveActivity, describeStatus, stopPipeline } from "./control.js";
19
+ import { trackRequirement } from "./records.js";
19
20
  import { describeFeatures, featuresWithRuns, findWorktreeRun, listFeatures, resolveFeatureArg } from "./features.js";
20
21
  import { checkChatCwd, normalizeFeatureDir, preflight, type PreflightResult, specProfile, verifyPhaseProblem } from "./preflight.js";
21
22
  import { loadRuntimeFactory } from "./runtime-loader.js";
@@ -92,6 +93,22 @@ if (args.cardFields) {
92
93
  }
93
94
  }
94
95
 
96
+ if (args.track) {
97
+ // 1.14: the requirement shows in the plugin from the start of the interview, with its chat.
98
+ try {
99
+ const repo = fs.realpathSync(args.repo);
100
+ const featureDir = normalizeFeatureDir(repo, args.featureDir);
101
+ const dir = path.join(repo, featureDir);
102
+ if (!fs.existsSync(dir)) throw new Error(`功能目录不存在:${featureDir}`);
103
+ trackRequirement(repo, featureDir, args.originAgentId);
104
+ console.log(`已登记需求:${featureDir}(Paseo 的 req-kit 插件里能看到它,状态"对齐中"${args.originAgentId ? ",关联这个对话" : ""})`);
105
+ process.exit(0);
106
+ } catch (error) {
107
+ console.error(`没能登记需求:${(error as Error).message}`);
108
+ process.exit(2);
109
+ }
110
+ }
111
+
95
112
  if (args.list) {
96
113
  console.log(describeFeatures(listFeatures(args.repo), args.repo));
97
114
  process.exit(0);
@@ -344,7 +361,9 @@ if (args.worktree) {
344
361
  try {
345
362
  ownWorktree = await Promise.race([
346
363
  create(),
347
- new Promise<never>((_, reject) => setTimeout(() => reject(new Error("连接 Paseo 新建工作区超过 2 分钟没有完成")), 120_000).unref()),
364
+ // 1.14: big repositories (Windows especially) can take minutes to check out. Giving up early
365
+ // left a branch with only the spec on it; now it waits, and a later start continues there.
366
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error("连接 Paseo 新建工作区超过 15 分钟没有完成。工作区可能稍后才建好:再执行一次启动命令,会在已建好的工作区里接着开始")), 15 * 60_000).unref()),
348
367
  ]);
349
368
  } catch (error) {
350
369
  fail(`没能为这个需求新建工作区:${(error as Error).message}`);
@@ -356,7 +375,7 @@ if (args.worktree) {
356
375
  try {
357
376
  checked = preflight(args, hashFile);
358
377
  } catch (error) {
359
- fail(`新工作区 ${ownWorktree.repo} 已建好,但无法在那里启动:${(error as Error).message}`);
378
+ fail(`新工作区 ${ownWorktree.repo} 已建好,但无法在那里启动:${(error as Error).message}\n问题解决后再执行一次启动命令,会在这个工作区里接着开始(不会再新建)。`);
360
379
  }
361
380
  }
362
381
 
@@ -403,8 +422,10 @@ console.log(
403
422
  ? "只做研发:写好提测说明和研发报告后结束,测试由人完成。"
404
423
  : null,
405
424
  ownWorktree
406
- ? `已为这个需求新建工作区:${ownWorktree.repo}(分支 ${ownWorktree.branch},在 Paseo 侧边栏中可以看到)。开发在那里进行,步骤仍挂在这个对话下;这个对话的工作目录不变。` +
407
- `需求文件已移到 ${path.join(ownWorktree.repo, checked.featureDir)}${ownWorktree.removedSource ? "" : "(这里原有的已提交版本保留不动)"},之后读写需求文件请用这个路径。`
425
+ ? `${ownWorktree.reused ? "在上次已建好的工作区中开始" : "已为这个需求新建工作区"}:${ownWorktree.repo}(分支 ${ownWorktree.branch},在 Paseo 侧边栏中可以看到)。开发在那里进行,步骤仍挂在这个对话下;这个对话的工作目录不变。` +
426
+ `需求文件已移到 ${path.join(ownWorktree.repo, checked.featureDir)}${ownWorktree.removedSource ? "" : "(这里原有的已提交版本保留不动)"},之后读写需求文件请用这个路径。` +
427
+ (ownWorktree.carried.length ? `\n访谈中改过的 ${ownWorktree.carried.join("、")} 也一起带过去提交了;这里的未提交改动保留不动,确认无误后可以撤销。` : "") +
428
+ ownWorktree.warnings.map((w) => `\n注意:${w}`).join("")
408
429
  : args.sourceRepo
409
430
  ? `这个需求在为它新建的工作区中开发:${checked.repo}`
410
431
  : null,
@@ -1,5 +1,5 @@
1
1
  import type { PaseoAgentHandle, PaseoClient } from "@getpaseo/client";
2
- import { summarizeActivity } from "./activity.js";
2
+ import { runningToolOf, summarizeActivity } from "./activity.js";
3
3
  import { type AgentProfile, isResolved, type PipelineConfig } from "./config.js";
4
4
  import { profilesFromDaemonConfig } from "./paseo-profiles.js";
5
5
  import { cronAt, localTimeZone } from "./schedule-time.js";
@@ -288,6 +288,14 @@ export function createRuntimeWithClient(
288
288
  return null;
289
289
  }
290
290
  },
291
+ async runningTool(agentId) {
292
+ try {
293
+ const page = await client.agents.ref(agentId).timeline.refetch({ direction: "tail", limit: 40 });
294
+ return runningToolOf(page.entries);
295
+ } catch {
296
+ return null;
297
+ }
298
+ },
291
299
  async createWorktree(request) {
292
300
  if (!client.workspaces) throw new Error("这个 Paseo 版本不支持新建工作区");
293
301
  const workspace = await client.workspaces.create({
@@ -299,6 +307,11 @@ export function createRuntimeWithClient(
299
307
  if (!directory) throw new Error("Paseo 没有返回新工作区的目录");
300
308
  return { workspaceId: workspace.id, directory };
301
309
  },
310
+ async openWorkspace(directory) {
311
+ if (!client.workspaces) throw new Error("这个 Paseo 版本不支持接手已有的工作区目录");
312
+ const handle = await client.workspaces.open({ cwd: directory });
313
+ return { workspaceId: handle.id };
314
+ },
302
315
  async waitForWorkspaceSetup(workspaceId, timeoutMs) {
303
316
  const api = await getScripts();
304
317
  if (!api) return { status: "none", error: null };
@@ -1,3 +1,4 @@
1
+ import { judge as judgeScan } from "./scan-guard.js";
1
2
  import { execFileSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -200,6 +201,13 @@ async function checkBudget(ctx: PipelineContext, stepTitle: string): Promise<voi
200
201
  const budget = (ctx.state.budget ??= { allowance: limit, warnedAt: null });
201
202
  const used = tokensUsed(ctx.state);
202
203
  if (used >= budget.allowance) throw new BudgetReached(used, budget.allowance);
204
+ // 1.13: agents that report no usage make the budget do nothing; say so once instead of keeping quiet.
205
+ const finished = ctx.state.history.filter((h) => h.endedAt);
206
+ if (used === 0 && finished.length > 0 && !budget.noUsageNoticed && finished.every((h) => !h.usage || (h.usage.inputTokens === undefined && h.usage.outputTokens === undefined))) {
207
+ budget.noUsageNoticed = true;
208
+ save(ctx);
209
+ await notifyOrigin(ctx, noUsageMessage(shownFeatureDir(ctx), limit));
210
+ }
203
211
  if (used >= (budget.allowance * ctx.config.budget.warnPercent) / 100 && budget.warnedAt !== budget.allowance) {
204
212
  budget.warnedAt = budget.allowance;
205
213
  save(ctx);
@@ -209,6 +217,10 @@ async function checkBudget(ctx: PipelineContext, stepTitle: string): Promise<voi
209
217
  }
210
218
  }
211
219
 
220
+ export function noUsageMessage(featureDir: string, limit: number): string {
221
+ return `【需求开发流水线通知】${featureDir} 设了每个需求 ${tokenText(limit)} tokens 的预算,但已完成的步骤里 agent 都没有报告 token 用量,所以预算不会起作用,token 统计和预计也没有数据。流水线照常继续。要让预算生效,请换用会报告用量的 agent(Paseo 里能看到用量的那种);不需要预算时可以在配置里把 budget.tokens 设为 null。这条消息只是告知,不需要处理。`;
222
+ }
223
+
212
224
  class BusinessDecisionNeeded extends Error {
213
225
  constructor(
214
226
  public readonly questions: string[],
@@ -621,8 +633,37 @@ interface Deadline {
621
633
  record?: StepRecord;
622
634
  }
623
635
 
636
+ /** How often the scan guard looks at the running tool call. */
637
+ const SCAN_CHECK_SECONDS = 60;
638
+ /** At most this many interruptions per turn; after that the step's time limit takes over. */
639
+ const SCAN_MAX_INTERRUPTS = 5;
640
+
641
+ interface ScanGuardMemory {
642
+ interrupted: Set<string>;
643
+ count: number;
644
+ }
645
+
646
+ /** 1.13: interrupts a search of a very large directory (or a search that runs too long), once per tool call. */
647
+ async function guardScan(ctx: PipelineContext, agent: StepAgent, def: StepDef, key: string, memory: ScanGuardMemory): Promise<void> {
648
+ if (!ctx.config.scanGuard.enabled || memory.count >= SCAN_MAX_INTERRUPTS) return;
649
+ const tool = await ctx.runtime.runningTool(agent.id).catch(() => null);
650
+ if (!tool || memory.interrupted.has(tool.callId)) return;
651
+ const verdict = judgeScan(tool, ctx.config.scanGuard);
652
+ if (!verdict) return;
653
+ memory.interrupted.add(tool.callId);
654
+ memory.count += 1;
655
+ try {
656
+ await ctx.runtime.interruptAgent(agent.id, verdict.message);
657
+ } catch (error) {
658
+ ctx.log(`打断「${def.title}」的搜索失败:${(error as Error).message}`);
659
+ return;
660
+ }
661
+ recover(ctx, key, "stop_scan", `「${def.title}」的命令${verdict.reason},已打断并告诉 agent 改用别的办法:${tool.text.replace(/\s+/g, " ").slice(0, 120)}`);
662
+ }
663
+
624
664
  async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef, agentTitle: string, deadline: Deadline): Promise<AgentUsage | null> {
625
665
  const notifiedPermissions = new Set<string>();
666
+ const guard: ScanGuardMemory = { interrupted: new Set(), count: 0 };
626
667
  let consecutiveErrors = 0;
627
668
  for (;;) {
628
669
  let remaining = deadline.at - Date.now();
@@ -644,7 +685,9 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
644
685
  }
645
686
  let outcome;
646
687
  try {
647
- outcome = await agent.wait(Math.min(ctx.config.waitChunkSeconds * 1000, remaining));
688
+ // 1.13: with the scan guard on, look at the running tool call about once a minute.
689
+ const chunk = ctx.config.scanGuard.enabled ? Math.min(ctx.config.waitChunkSeconds, SCAN_CHECK_SECONDS) : ctx.config.waitChunkSeconds;
690
+ outcome = await agent.wait(Math.min(chunk * 1000, remaining));
648
691
  consecutiveErrors = 0;
649
692
  } catch (error) {
650
693
  consecutiveErrors += 1;
@@ -665,6 +708,7 @@ async function waitForTurn(ctx: PipelineContext, agent: StepAgent, def: StepDef,
665
708
  case "idle":
666
709
  return outcome.usage ?? null;
667
710
  case "timeout":
711
+ await guardScan(ctx, agent, def, deadline.key, guard);
668
712
  continue;
669
713
  case "error":
670
714
  throw new AgentErrored(def.title, `agent 报告错误:${outcome.error ?? "未知错误"}`);
@@ -31,6 +31,8 @@ export interface LaunchArgs {
31
31
  list: boolean;
32
32
  /** --card-fields: what /req confirms with the spec for the external systems (card.json). */
33
33
  cardFields?: boolean;
34
+ /** 1.14 --track: register a requirement being aligned, before the pipeline runs (shown in the plugin). */
35
+ track?: boolean;
34
36
  /** --spec-review: independent review of the draft spec, before confirmation. */
35
37
  specReview: boolean;
36
38
  /** --worktree: create a Paseo worktree workspace for this requirement and develop there. */
@@ -47,6 +47,8 @@ export interface IndexEntry {
47
47
  /** Name of the project: the source checkout's directory name. */
48
48
  project: string;
49
49
  registeredAt: string;
50
+ /** 1.14: the chat the requirement is being aligned in (registered before the pipeline ran). */
51
+ chatAgentId?: string | null;
50
52
  }
51
53
 
52
54
  export function indexDir(env: NodeJS.ProcessEnv = process.env): string {
@@ -59,7 +61,7 @@ export function requirementKey(sourceOrRepo: string, featureDir: string): string
59
61
  }
60
62
 
61
63
  /** Records where this requirement lives, if it is not recorded that way already. Never throws. */
62
- export function registerRequirement(state: Pick<PipelineState, "repo" | "featureDir" | "worktree">, env: NodeJS.ProcessEnv = process.env): void {
64
+ export function registerRequirement(state: Pick<PipelineState, "repo" | "featureDir" | "worktree">, env: NodeJS.ProcessEnv = process.env, chatAgentId: string | null = null): void {
63
65
  if (recordingOff(env)) return;
64
66
  try {
65
67
  const source = state.worktree?.sourceRepo ?? null;
@@ -75,6 +77,7 @@ export function registerRequirement(state: Pick<PipelineState, "repo" | "feature
75
77
  workspaceId: state.worktree?.workspaceId ?? null,
76
78
  project: path.basename(real(source ?? state.repo)),
77
79
  registeredAt: existing?.registeredAt ?? new Date().toISOString(),
80
+ ...(chatAgentId ?? existing?.chatAgentId ? { chatAgentId: chatAgentId ?? existing?.chatAgentId ?? null } : {}),
78
81
  };
79
82
  if (existing && JSON.stringify(existing) === JSON.stringify(entry)) return;
80
83
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -86,6 +89,24 @@ export function registerRequirement(state: Pick<PipelineState, "repo" | "feature
86
89
  }
87
90
  }
88
91
 
92
+ /**
93
+ * 1.14: a requirement whose interview has started: marked in its feature directory
94
+ * (.pipeline/tracked.json, not committed) and registered with its chat, so the plugin lists it
95
+ * before the pipeline ever runs.
96
+ */
97
+ export function trackRequirement(repo: string, featureDir: string, chatAgentId: string | null, env: NodeJS.ProcessEnv = process.env): void {
98
+ const marker = path.join(repo, featureDir, ".pipeline", "tracked.json");
99
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
100
+ let since = new Date().toISOString();
101
+ try {
102
+ since = (JSON.parse(fs.readFileSync(marker, "utf8")) as { since?: string }).since ?? since;
103
+ } catch {
104
+ // First time.
105
+ }
106
+ fs.writeFileSync(marker, `${JSON.stringify({ since, chatAgentId }, null, 2)}\n`);
107
+ registerRequirement({ repo, featureDir, worktree: null }, env, chatAgentId);
108
+ }
109
+
89
110
  function readEntry(file: string): IndexEntry | null {
90
111
  try {
91
112
  const raw = JSON.parse(fs.readFileSync(file, "utf8")) as IndexEntry;
@@ -104,7 +125,8 @@ export function readIndex(env: NodeJS.ProcessEnv = process.env): Array<IndexEntr
104
125
  .filter((n) => n.endsWith(".json"))
105
126
  .map((n) => readEntry(path.join(dir, n)))
106
127
  .filter((e): e is IndexEntry => e !== null)
107
- .map((e) => ({ ...e, present: fs.existsSync(path.join(e.repo, e.featureDir, ".pipeline", "state.json")) }));
128
+ // 1.14: a requirement still in its interview has no state yet, only its notes.
129
+ .map((e) => ({ ...e, present: ["spec.md", "alignment.md", ".pipeline/state.json", ".pipeline/tracked.json"].some((f) => fs.existsSync(path.join(e.repo, e.featureDir, f))) }));
108
130
  }
109
131
 
110
132
  /** Removes the entries whose requirement is gone. Returns how many. */
@@ -11,6 +11,7 @@
11
11
  import { spawn, spawnSync } from "node:child_process";
12
12
  import crypto from "node:crypto";
13
13
  import fs from "node:fs";
14
+ import os from "node:os";
14
15
  import http from "node:http";
15
16
  import path from "node:path";
16
17
  import { ENGINE_DIR, KIT_ROOT, kitHome, kitVersion } from "./locations.js";
@@ -510,7 +511,8 @@ async function serve(env: NodeJS.ProcessEnv): Promise<void> {
510
511
  token,
511
512
  readToken,
512
513
  version: kitVersion(),
513
- publicUrl: env.REQ_KIT_REVIEW_PUBLIC_URL?.trim() || `http://127.0.0.1:${port}`,
514
+ // Listening for other computers without a public address: this computer's name is the best guess.
515
+ publicUrl: env.REQ_KIT_REVIEW_PUBLIC_URL?.trim() || `http://${/^(127\.|localhost$|::1$)/.test(host) ? "127.0.0.1" : os.hostname()}:${port}`,
514
516
  startedAt: new Date().toISOString(),
515
517
  };
516
518
  fs.mkdirSync(kitHome(env), { recursive: true });
@@ -135,12 +135,16 @@ export interface AgentRuntime {
135
135
  stopServices(target: ServiceTarget, names: string[]): Promise<void>;
136
136
  /** Creates a Paseo worktree workspace (paseo.json setup runs in the background). */
137
137
  createWorktree(request: WorktreeRequest): Promise<{ workspaceId: string; directory: string }>;
138
+ /** 1.14: the Paseo workspace of an existing directory (opened as one if it is not yet). */
139
+ openWorkspace?(directory: string): Promise<{ workspaceId: string }>;
138
140
  /** Waits for the workspace's paseo.json setup; "none" when there is nothing to wait for or it cannot be read. */
139
141
  waitForWorkspaceSetup(workspaceId: string, timeoutMs: number): Promise<{ status: "completed" | "failed" | "none" | "timeout"; error: string | null }>;
140
142
  /** Subscription windows of the given providers; null when Paseo has no usage data. */
141
143
  providerQuota(providers: string[]): Promise<ProviderQuota[] | null>;
142
144
  /** The agent's latest actions, oldest first; null when they cannot be read. */
143
145
  recentActivity(agentId: string, limit: number): Promise<ActivityItem[] | null>;
146
+ /** 1.13: the tool call the agent is running now (the latest one still running), or null. */
147
+ runningTool(agentId: string): Promise<import("./scan-guard.js").RunningTool | null>;
144
148
  /** Which Paseo tools step agents get (Settings → Agents); null when unknown. */
145
149
  capabilities(): Promise<{ paseoTools: boolean; browser: boolean } | null>;
146
150
  /** 1.12: whether each agent provider is installed and logged in ("ready"); null when the daemon does not say. */
@@ -0,0 +1,132 @@
1
+ // Stopping commands that search very large directories (1.13): a step agent that looks for a
2
+ // class or a package by searching the whole home directory, the Maven repository (~/.m2), the
3
+ // global node_modules or a drive can sit for many minutes and never finish the step. While a step
4
+ // runs, the pipeline looks at the agent's running tool call about once a minute; a search of such
5
+ // a directory (after a minute) or any search that runs too long is interrupted, and the agent is
6
+ // told how to find what it needs without the scan. A plain check of the command text, no model.
7
+ import os from "node:os";
8
+
9
+ export interface RunningTool {
10
+ /** Paseo's tool kind: "shell", "search", … */
11
+ kind: string;
12
+ /** The command (shell) or the pattern (search). */
13
+ text: string;
14
+ /** When the call started (ISO time). */
15
+ startedAt: string;
16
+ /** The tool call's ID, so the same call is interrupted at most once. */
17
+ callId: string;
18
+ }
19
+
20
+ export interface ScanGuardSettings {
21
+ enabled: boolean;
22
+ /** A search that runs this long is interrupted wherever it searches. */
23
+ minutes: number;
24
+ /** More directories never to be searched as a whole (e.g. a shared drive). */
25
+ paths: string[];
26
+ }
27
+
28
+ /** A search of a large directory gets this long before it is interrupted (most are quick to fail or finish). */
29
+ export const BIG_DIR_GRACE_MS = 60_000;
30
+
31
+ /** Commands that walk a directory tree. */
32
+ const SCAN_COMMAND =
33
+ /(^|[\s;&|("'`])(find|fd|locate|mlocate|tree|du|rg|ag|ack|grep\s+(-\w*[rR]\w*|--recursive)|egrep\s+-\w*[rR]|ls\s+(-\w*R\w*)|Get-ChildItem\b[^|;]*-r(ecurse)?\b|gci\b[^|;]*-r(ecurse)?\b|dir\b[^|;]*(\/s\b|-r(ecurse)?\b)|findstr\b[^|;]*\/s\b|where\s+\/r|Select-String\b[^|;]*-Path\b[^|;]*\*\*)(?=$|[\s;&|)"'`])/i;
34
+
35
+ /** Option values that keep a directory out of a search: not what it searches. */
36
+ const EXCLUSIONS = [
37
+ /--exclude(-dir)?(=|\s+)("[^"]*"|'[^']*'|\S+)/gi,
38
+ /(-g|--glob|--iglob)(=|\s+)("![^"]*"|'![^']*'|!\S+)/gi,
39
+ /(-not|!)\s+-(i?path|i?name|wholename)\s+("[^"]*"|'[^']*'|\S+)/gi,
40
+ /-(i?path|i?name|wholename)\s+("[^"]*"|'[^']*'|\S+)\s+-prune/gi,
41
+ /--(ignore-dir|ignore-directory|exclude-from)(=|\s+)("[^"]*"|'[^']*'|\S+)/gi,
42
+ /-Exclude\s+("[^"]*"|'[^']*'|\S+)/gi,
43
+ ];
44
+
45
+ const END = String.raw`(?=$|[\s"'*;|&)\]` + "`" + `])`;
46
+ const START = String.raw`(?:^|[\s"'=(\[` + "`" + `])`;
47
+
48
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
49
+ const norm = (s: string) => s.replace(/\\/g, "/");
50
+
51
+ interface BigDir {
52
+ re: RegExp;
53
+ label: string;
54
+ }
55
+
56
+ function bigDirs(home: string, extra: string[]): BigDir[] {
57
+ const h = escape(norm(home).replace(/\/+$/, ""));
58
+ const homes = String.raw`(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE|\$env:HOME${h ? `|${h}` : ""})`;
59
+ const sub = (p: string) => new RegExp(`${START}(?:${homes}/|[^\\s"']*/)?${p}/?${END}`, "i");
60
+ const list: BigDir[] = [
61
+ { re: new RegExp(`${START}/${END}`), label: "整个磁盘(/)" },
62
+ { re: new RegExp(`${START}[a-z]:/?${END}`, "i"), label: "整个磁盘" },
63
+ { re: new RegExp(`${START}${homes}/?${END}`, "i"), label: "用户目录" },
64
+ { re: sub(String.raw`\.m2(?:/repository)?`), label: "Maven 本地仓库(.m2)" },
65
+ { re: sub(String.raw`\.gradle(?:/caches)?`), label: "Gradle 缓存(.gradle)" },
66
+ { re: sub(String.raw`\.ivy2`), label: "Ivy 缓存(.ivy2)" },
67
+ { re: sub(String.raw`(?:\.npm|\.pnpm-store|\.yarn|AppData/Local/(?:npm-cache|pnpm|Yarn))`), label: "npm/pnpm/yarn 缓存" },
68
+ { re: sub(String.raw`(?:\.nvm|\.volta|\.fnm|AppData/Roaming/nvm)`), label: "Node.js 版本目录" },
69
+ { re: sub(String.raw`\.cache`), label: "缓存目录(.cache)" },
70
+ { re: sub(String.raw`(?:\.cargo|go/pkg/mod|\.rustup)`), label: "Rust/Go 依赖缓存" },
71
+ { re: sub(String.raw`AppData(?:/Roaming|/Local)?`), label: "AppData" },
72
+ { re: sub(String.raw`(?:usr/(?:local/)?lib/node_modules|AppData/Roaming/npm(?:/node_modules)?)`), label: "全局 node_modules" },
73
+ { re: /npm\s+root\s+-g|pnpm\s+root\s+-g/i, label: "全局 node_modules" },
74
+ { re: sub("node_modules"), label: "node_modules" },
75
+ { re: new RegExp(`${START}(?:[a-z]:)?/(?:usr(?:/lib|/share|/local)?|opt|var|Library|Program Files(?: \\(x86\\))?|Windows)/?${END}`, "i"), label: "系统目录" },
76
+ { re: sub(String.raw`site-packages`), label: "Python 依赖目录(site-packages)" },
77
+ ];
78
+ for (const p of extra) {
79
+ const n = escape(norm(p).replace(/\/+$/, ""));
80
+ if (n) list.push({ re: new RegExp(`${START}${n}/?${END}`, "i"), label: p });
81
+ }
82
+ return list;
83
+ }
84
+
85
+ /** The large directory a command searches as a whole, or null. */
86
+ export function bigDirOf(command: string, options: { home?: string; paths?: string[] } = {}): string | null {
87
+ if (!SCAN_COMMAND.test(command)) return null;
88
+ let text = command;
89
+ for (const re of EXCLUSIONS) text = text.replace(re, " ");
90
+ text = norm(text);
91
+ for (const d of bigDirs(options.home ?? os.homedir(), options.paths ?? [])) if (d.re.test(text)) return d.label;
92
+ return null;
93
+ }
94
+
95
+ export function isScan(tool: Pick<RunningTool, "kind" | "text">): boolean {
96
+ return tool.kind === "search" || (tool.kind === "shell" && SCAN_COMMAND.test(tool.text));
97
+ }
98
+
99
+ export interface ScanVerdict {
100
+ /** Why: the large directory, or how long the search has run. */
101
+ reason: string;
102
+ /** What the agent is told (the interrupt message). */
103
+ message: string;
104
+ }
105
+
106
+ const clip = (s: string, n = 160) => {
107
+ const one = s.replace(/\s+/g, " ").trim();
108
+ return one.length > n ? `${one.slice(0, n - 1)}…` : one;
109
+ };
110
+
111
+ /** Whether the running tool call should be interrupted, and what to tell the agent. */
112
+ export function judge(tool: RunningTool | null, settings: ScanGuardSettings, now = new Date(), home = os.homedir()): ScanVerdict | null {
113
+ if (!tool || !settings.enabled) return null;
114
+ const ran = now.getTime() - new Date(tool.startedAt).getTime();
115
+ if (!(ran >= 0)) return null;
116
+ const big = tool.kind === "shell" ? bigDirOf(tool.text, { home, paths: settings.paths }) : null;
117
+ let reason: string | null = null;
118
+ if (big && ran >= BIG_DIR_GRACE_MS) reason = `在搜索${big}`;
119
+ else if (isScan(tool) && ran >= settings.minutes * 60_000) reason = `搜索已经运行了 ${Math.round(ran / 60_000)} 分钟`;
120
+ if (!reason) return null;
121
+ return { reason, message: scanMessage(tool.text, reason) };
122
+ }
123
+
124
+ export function scanMessage(command: string, reason: string): string {
125
+ return [
126
+ `【流水线提示】已停止你刚才的${command ? `命令「${clip(command)}」` : "搜索"}:它${reason},这类搜索要很久,会让这一步一直做不完。请继续完成本步骤的任务,但不要再搜索整个磁盘、用户目录或依赖缓存,改用下面的办法:`,
127
+ "- 在仓库里找:用 `git grep -n <内容>` 或 `git ls-files | grep <文件名>`;用 rg 时只在仓库目录里搜,它会跳过 .gitignore 里的目录。",
128
+ "- 找某个 Maven/Gradle 依赖里的类:先在 pom.xml / build.gradle 里确定 groupId、artifactId 和版本,直接打开 `~/.m2/repository/<groupId 的点换成 />/<artifactId>/<版本>/` 这一个目录,用 `unzip -l <jar> | grep <类名>` 或 `jar tf` 看那一个 jar;要源码时用 `mvn dependency:sources -DincludeArtifactIds=<artifactId>`。不知道是哪个依赖时,`mvn dependency:tree` 比搜索快。",
129
+ "- 找某个 npm 包:直接看仓库里的 `node_modules/<包名>/package.json`,以及其中 main、types 指向的文件;不要搜索整个 node_modules 或全局 node_modules。",
130
+ "- 实在找不到就不要再找了:按项目代码和文档里能确认的信息继续,把不确定的地方写进本步骤产物的\"假设\"一节。",
131
+ ].join("\n");
132
+ }
@@ -50,7 +50,7 @@ export type StopKind =
50
50
  export interface Recovery {
51
51
  at: string;
52
52
  key: string;
53
- kind: "retry" | "reattach" | "reuse_result" | "extend_timeout" | "redo" | "fallback" | "auto_resume" | "quota_wait" | "self_answer";
53
+ kind: "retry" | "reattach" | "reuse_result" | "extend_timeout" | "redo" | "fallback" | "auto_resume" | "quota_wait" | "self_answer" | "stop_scan";
54
54
  detail: string;
55
55
  }
56
56
 
@@ -89,6 +89,8 @@ export interface BudgetState {
89
89
  allowance: number;
90
90
  /** The allowance a warning was given for, so each level warns once. */
91
91
  warnedAt: number | null;
92
+ /** 1.13: the person was told the agents report no token usage (so the budget cannot work). */
93
+ noUsageNoticed?: boolean;
92
94
  }
93
95
 
94
96
  export interface PipelineState {
@@ -506,6 +506,7 @@ export function describeStats(s: StatsSummary): string {
506
506
  reattach: "接着等还在做的 agent",
507
507
  reuse_result: "沿用旧 agent 的结果",
508
508
  extend_timeout: "超时后多等一轮",
509
+ stop_scan: "打断大目录搜索",
509
510
  redo: "新开 agent 重做",
510
511
  fallback: "换备用模型",
511
512
  auto_resume: "自动继续",