dsh-grill-first 0.1.3 → 0.1.5

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.
@@ -50,10 +50,11 @@ export const READ_ONLY_TOOLS = [
50
50
  'update_goal',
51
51
  'grill_start',
52
52
  'grill_confirm',
53
- 'grill_memory',
54
53
  ];
55
54
  /** 拦截时给模型看的拒绝文案(工作表 3.6 原文)。 */
56
55
  export const GATE_DENY_REASON = "需求尚未确认:请先调用 grill_confirm 完成需求确认,或让用户说'直接做'跳过。";
56
+ /** ask 档弹给用户看的说明(宿主用 displayReason 渲染卡片正文)。 */
57
+ export const GATE_ASK_REASON = '这次任务的需求还没确认,要照现在的理解直接动手吗?';
57
58
  const READ_ONLY_SET = new Set(READ_ONLY_TOOLS.map((n) => n.toLowerCase()));
58
59
  const MUTATING_SET = new Set(MUTATING_TOOLS.map((n) => n.toLowerCase()));
59
60
  /** univer_* 里只有这几个是只读的,其余按写类处理。 */
@@ -81,14 +82,46 @@ export function isMutatingTool(name) {
81
82
  * @param input 工具名 + 当前门禁状态 + 档位。
82
83
  */
83
84
  export function decideGate(input) {
84
- if (input.mode !== 'strict')
85
+ // off / warn 只记录不拦截(warn 的提示在 registerGate 里单独打日志)。
86
+ if (input.mode === 'off' || input.mode === 'warn')
85
87
  return { action: 'allow' };
86
88
  if (input.state !== 'closed')
87
89
  return { action: 'allow' };
88
90
  if (!isMutatingTool(input.toolName))
89
91
  return { action: 'allow' };
92
+ if (input.mode === 'ask')
93
+ return { action: 'ask', reason: GATE_DENY_REASON };
90
94
  return { action: 'deny', reason: GATE_DENY_REASON };
91
95
  }
96
+ /**
97
+ * 从宿主的 inbox 负载里尽量捞出用户消息文本。
98
+ *
99
+ * 不同宿主版本的字段名不统一(text / content / parts),取不到时返回空串;
100
+ * 空串表示"不知道用户说了什么",此时保持旧行为(关门禁)最安全。
101
+ * @param payload `agent/inbox/inserted` 的事件负载。
102
+ */
103
+ function extractMessageText(payload) {
104
+ const message = payload?.message;
105
+ if (typeof message === 'string')
106
+ return message.trim();
107
+ if (!message || typeof message !== 'object')
108
+ return '';
109
+ const record = message;
110
+ for (const key of ['text', 'content', 'body', 'prompt', 'parts']) {
111
+ const value = record[key];
112
+ if (typeof value === 'string' && value.trim() !== '')
113
+ return value.trim();
114
+ if (!Array.isArray(value))
115
+ continue;
116
+ const joined = value
117
+ .map((part) => (typeof part === 'string' ? part : part?.text))
118
+ .filter((part) => typeof part === 'string' && part.trim() !== '')
119
+ .join('\n');
120
+ if (joined.trim() !== '')
121
+ return joined.trim();
122
+ }
123
+ return '';
124
+ }
92
125
  /** 每个 agent 的门禁状态表(agent 级隔离)。 */
93
126
  export class GateRegistry {
94
127
  states = new Map();
@@ -144,6 +177,17 @@ export function registerGate(ctx, gates, options) {
144
177
  ctx.logger?.info?.(`[grill-first] 拦截 ${exec.name}(需求未确认,agent=${agentId})`);
145
178
  return { kind: 'deny', reason: decision.reason ?? GATE_DENY_REASON };
146
179
  }
180
+ if (decision.action === 'ask') {
181
+ // 只问一次:卡片弹过之后无论用户点头还是否决,都由用户决定,插件不再逐次弹
182
+ // (否则一次任务里写 20 个文件就会弹 20 张卡片)。
183
+ ctx.logger?.info?.(`[grill-first] 询问 ${exec.name}(需求未确认,agent=${agentId})`);
184
+ gates.open(agentId);
185
+ return {
186
+ kind: 'ask',
187
+ reason: decision.reason ?? GATE_DENY_REASON,
188
+ displayReason: GATE_ASK_REASON,
189
+ };
190
+ }
147
191
  if (options.mode === 'warn' && state === 'closed' && isMutatingTool(exec?.name ?? '')) {
148
192
  options.onWarning?.(exec.name, agentId);
149
193
  }
@@ -159,6 +203,9 @@ export function registerGate(ctx, gates, options) {
159
203
  const agentId = payload?.agent?.id ?? '';
160
204
  if (agentId === '' || !isRootAgent(ctx, agentId))
161
205
  return;
206
+ const text = extractMessageText(payload);
207
+ if (text !== '' && options.shouldResetOnMessage && !options.shouldResetOnMessage(text))
208
+ return;
162
209
  gates.close(agentId);
163
210
  });
164
211
  }
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * 在插件卸载/配置变更重挂载时自动清理。
6
6
  */
7
7
  import { normalizeConfig } from './config.js';
8
+ import { judgeTask } from './judge/heuristics.js';
8
9
  import { Config } from './configSchema.js';
9
10
  import { GateRegistry, registerGate } from './gate/preExecute.js';
10
11
  import { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, buildProtocolText } from './prompt/protocol.js';
@@ -68,6 +69,7 @@ export function apply(ctx, rawConfig) {
68
69
  gates,
69
70
  sessions,
70
71
  workspaceRoot,
72
+ memoryScope: () => config.memoryScope,
71
73
  ...(ctx.logger ? { logger: ctx.logger } : {}),
72
74
  }));
73
75
  register(createGrillConfirmTool({
@@ -75,6 +77,7 @@ export function apply(ctx, rawConfig) {
75
77
  gates,
76
78
  sessions,
77
79
  workspaceRoot,
80
+ memoryScope: () => config.memoryScope,
78
81
  questions: () => ctx.get('userQuestions'),
79
82
  ...(ctx.logger ? { logger: ctx.logger } : {}),
80
83
  }));
@@ -99,6 +102,16 @@ export function apply(ctx, rawConfig) {
99
102
  installProtocolSection(ctx, config);
100
103
  registerGate(ctx, gates, {
101
104
  mode: config.gateMode,
105
+ // 新消息到达时先看一眼内容:追问/询问类不关门禁,否则"刚确认完又被拦"。
106
+ shouldResetOnMessage: (text) => {
107
+ const judged = judgeTask({
108
+ text,
109
+ mode: 'new-task',
110
+ bypassKeywords: config.bypassKeywords,
111
+ intensity: config.intensity,
112
+ });
113
+ return judged.verdict !== 'clear';
114
+ },
102
115
  onWarning: (toolName, agentId) => {
103
116
  ctx.logger?.warn(`[grill-first] 需求尚未确认(warn 模式,仅记录未拦截):${toolName}(agent=${agentId})`);
104
117
  },
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * 纯函数启发式判定(R1):不调 LLM,只看任务文本判断"需求是否已经够清楚"。
3
3
  *
4
- * 判定顺序(先命中先返回):bypass 关键词 → 追问/继续类消息 → 文本过短且无动作 →
4
+ * 判定顺序(先命中先返回):bypass 关键词 → 追问/继续类消息 → 纯询问(问句/只读咨询)→ 文本过短且无动作 →
5
5
  * 四个需求维度(目标/范围/约束/验收标准)的覆盖度。
6
6
  */
7
7
  /** 判定模式:新任务 / 任务中途的需求变更检查。 */
8
8
  export type JudgeMode = 'new-task' | 'drift-check';
9
+ /** 追问力度:只影响判定阈值,不改变判定顺序。 */
10
+ export type JudgeIntensity = 'gentle' | 'standard' | 'grill';
9
11
  /** 判定结论。 */
10
12
  export type JudgeVerdict = 'clear' | 'needs-clarification';
11
13
  /** 四个需求维度是否在文本里被提到。 */
@@ -20,6 +22,8 @@ export interface JudgeInput {
20
22
  text: string;
21
23
  mode: JudgeMode;
22
24
  bypassKeywords?: readonly string[];
25
+ /** 追问力度(默认 standard,保持旧行为)。 */
26
+ intensity?: JudgeIntensity;
23
27
  }
24
28
  /** 判定输出。 */
25
29
  export interface JudgeResult {
@@ -45,6 +49,13 @@ export declare function hasBypassKeyword(text: string, keywords: readonly string
45
49
  export declare function isFollowUpMessage(text: string): boolean;
46
50
  /** 由缺失维度生成追问提示。 */
47
51
  export declare function buildFocusHints(hit: DimensionHit, extra?: string[]): string[];
52
+ /**
53
+ * 是否是"纯询问"消息:问句或让我查/看/评估,且没有任何动手信号。
54
+ *
55
+ * 这类消息不需要澄清——用户就是要一个回答,继续追问反而是打扰。
56
+ * @param text 用户原文。
57
+ */
58
+ export declare function isInquiry(text: string): boolean;
48
59
  /**
49
60
  * 判定任务文本是否需要澄清(纯函数,可单测)。
50
61
  * @param input 任务文本 + 模式 + 跳过关键词。
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 纯函数启发式判定(R1):不调 LLM,只看任务文本判断"需求是否已经够清楚"。
3
3
  *
4
- * 判定顺序(先命中先返回):bypass 关键词 → 追问/继续类消息 → 文本过短且无动作 →
4
+ * 判定顺序(先命中先返回):bypass 关键词 → 追问/继续类消息 → 纯询问(问句/只读咨询)→ 文本过短且无动作 →
5
5
  * 四个需求维度(目标/范围/约束/验收标准)的覆盖度。
6
6
  */
7
7
  /** 默认跳过关键词(R4)。 */
@@ -14,17 +14,23 @@ export const DIMENSION_HINTS = {
14
14
  acceptance: '验收标准缺失:怎样算完成?用什么方式验证?',
15
15
  };
16
16
  /** 维度关键词:目标(有动作、有产出)。 */
17
- const GOAL_WORDS = /(做|写|实现|搭建|开发|创建|新增|加个|修复|改|优化|重构|迁移|升级|翻译|整理|分析|生成|设计|部署|发布|测试|排查|调研|对比|总结|转换|导出|导入|接入|对接|编写|画|制作|搞|弄)/;
17
+ const GOAL_WORDS = /(做|写|实现|搭建|开发|创建|新增|加个|修复|改|优化|重构|迁移|升级|翻译|整理|分析|生成|设计|部署|发布|测试|排查|调研|对比|总结|转换|导出|导入|接入|对接|编写|画|制作|搞|弄|\b(?:fix|add|implement|build|create|refactor|migrate|write|update|remove|delete|optimi[sz]e|translate|generate|analy[sz]e|design|deploy|publish|rewrite|upgrade|install|configure|support|enable|disable|clean|split|merge|rename)\b)/i;
18
18
  /** 维度关键词:范围(边界、对象)。 */
19
- const SCOPE_WORDS = /(只|仅|只要|只需要|范围|限定|针对|面向|所有|全部|部分|哪些|哪个|模块|页面|文件|目录|接口|字段|表|库|版本|第一步|先做|不含|除外)/;
19
+ const SCOPE_WORDS = /(只|仅|只要|只需要|范围|限定|针对|面向|所有|全部|部分|哪些|哪个|模块|页面|文件|目录|接口|字段|表|库|版本|第一步|先做|不含|除外|\b(?:only|all|each|every|field|fields|page|pages|file|files|directory|module|api|endpoint|component|table|schema|version|repo|repository|branch|config|first)\b)/i;
20
20
  /** 维度关键词:约束(技术栈、限制、风格)。忽略大小写,便于匹配 Python / UTF-8 这类写法。 */
21
- const CONSTRAINT_WORDS = /(用|基于|必须|不能|不要|需要|依赖|兼容|保持|风格|注释|技术栈|框架|语言|不许|禁止|限制|预算|时间|截止|格式|规范|utf-?8|中文|英文|node|python|js|ts|react|vue|java|go|rust)/i;
21
+ const CONSTRAINT_WORDS = /(用|基于|必须|不能|不要|需要|依赖|兼容|保持|风格|注释|技术栈|框架|语言|不许|禁止|限制|预算|时间|截止|格式|规范|utf-?8|中文|英文|node|python|js|ts|react|vue|java|go|rust|\b(?:must|should|shall|need|needs|require|requires|required|use|using|based\s+on|compatible|compatibility|keep|preserve|avoid|without|existing)\b)/i;
22
22
  /** 维度关键词:验收标准。 */
23
- const ACCEPTANCE_WORDS = /(验收|标准|测试|通过|算完成|完成标准|结果|输出|期望|应该|返回|效果|达到|满足|可运行|跑通|全绿|正确|不报错|可用|验证|检查|示例|样例|截图|对比)/;
23
+ const ACCEPTANCE_WORDS = /(验收|标准|测试|通过|算完成|完成标准|结果|输出|期望|应该|返回|效果|达到|满足|可运行|跑通|全绿|正确|不报错|可用|验证|检查|示例|样例|截图|对比|\b(?:acceptance|criteria|test|tests|pass|passes|passing|expect|expected|output|verify|verified|green|works|working|screenshot|sample|example)\b)/i;
24
24
  /** 动作动词(用于"短文本是否已构成一个可执行指令")。 */
25
25
  const ACTION_VERB = /(做|写|改|加|删|实现|搭建|开发|修复|优化|翻译|整理|分析|生成|设计|部署|测试|排查|调研|总结|转换|导出|接入|创建|重构|迁移|升级|安装|配置|画|制作)/;
26
26
  /** 追问/继续类消息的开头。 */
27
27
  const FOLLOW_UP_LEAD = /^(继续|接着|然后呢|然后|下一步|再来|嗯|好|可以|行|ok|OK|对|是的|收到|谢谢|辛苦)/;
28
+ /** 问句收尾:出现即说明用户在提问,而不是在派活。 */
29
+ const QUESTION_TAIL = /(吗|呢|么|嘛|是不是|能不能|可不可以|有没有|怎么样|如何|为什么|为啥|啥意思|多少|哪个好)\s*[??]?$|[??]\s*$/;
30
+ /** 询问式开头:让我查/看/评估/解释,而不是让我动手。 */
31
+ const INQUIRY_LEAD = /^(?:please\s+|pls\s+|could you\s+|can you\s+)?(请问|问一下|想问|问下|看看|看一下|检查|查一下|查查|列出|列一下|统计|解释|说明|对比|评估|推荐|你觉得|你认为|这个|那个|现在|目前|为啥|为什么|look|check|see|show|list|tell|explain|describe|review|evaluate|summari[sz]e|search|find|verify|what|why|how|which)/i;
32
+ /** 明确的动手信号:一旦出现就不算纯询问("改一下这个吗"仍然要先澄清)。 */
33
+ const WRITE_SIGNAL = /(改|写|删|新建|实现|修复|重构|迁移|生成|替换|安装|部署|发布|合并|提交|制作|画|创建|搭建|开发|添加|增加|移除)|\b(?:fix|add|implement|build|create|refactor|migrate|write|update|remove|delete|change|edit|rename|move|split|merge|install|deploy|publish|upgrade|generate|replace)\b/i;
28
34
  /** 具体化信号:出现即说明用户给了可落地的细节。 */
29
35
  const SPECIFIC_PATTERNS = [
30
36
  /[A-Za-z0-9_\-./\\]+\.(ts|js|mjs|cjs|tsx|jsx|json|md|py|go|rs|java|c|cc|cpp|h|hpp|yml|yaml|toml|txt|csv|xlsx|png|jpg|jpeg|svg|html|css|vue|sh|ps1)/i,
@@ -92,6 +98,26 @@ export function buildFocusHints(hit, extra = []) {
92
98
  }
93
99
  return [...extra, ...hints].slice(0, 4);
94
100
  }
101
+ /**
102
+ * 是否是"纯询问"消息:问句或让我查/看/评估,且没有任何动手信号。
103
+ *
104
+ * 这类消息不需要澄清——用户就是要一个回答,继续追问反而是打扰。
105
+ * @param text 用户原文。
106
+ */
107
+ export function isInquiry(text) {
108
+ const flat = text.trim();
109
+ if (flat === '')
110
+ return false;
111
+ if (WRITE_SIGNAL.test(flat))
112
+ return false;
113
+ return QUESTION_TAIL.test(flat) || INQUIRY_LEAD.test(flat);
114
+ }
115
+ /** 各力度档位的判定阈值:维度下限 + 是否强制覆盖验收标准。 */
116
+ const INTENSITY_THRESHOLDS = {
117
+ gentle: { dims: 2, needAcceptance: false },
118
+ standard: { dims: 3, needAcceptance: false },
119
+ grill: { dims: 3, needAcceptance: true },
120
+ };
95
121
  /**
96
122
  * 判定任务文本是否需要澄清(纯函数,可单测)。
97
123
  * @param input 任务文本 + 模式 + 跳过关键词。
@@ -102,6 +128,8 @@ export function judgeTask(input) {
102
128
  const dimensions = detectDimensions(text);
103
129
  const satisfied = satisfiedCount(dimensions);
104
130
  const score = satisfied * 25;
131
+ const intensity = input.intensity ?? 'standard';
132
+ const threshold = INTENSITY_THRESHOLDS[intensity] ?? INTENSITY_THRESHOLDS.standard;
105
133
  if (text === '') {
106
134
  return {
107
135
  verdict: 'needs-clarification',
@@ -117,9 +145,12 @@ export function judgeTask(input) {
117
145
  if (isFollowUpMessage(text)) {
118
146
  return { verdict: 'clear', score: 95, focus_hints: [], dimensions, reason: 'follow-up' };
119
147
  }
148
+ if (isInquiry(text)) {
149
+ return { verdict: 'clear', score: 90, focus_hints: [], dimensions, reason: 'inquiry' };
150
+ }
120
151
  if (input.mode === 'drift-check') {
121
152
  const drift = DRIFT_MARKERS.test(text);
122
- if (satisfied >= 3 && text.length >= 20) {
153
+ if (satisfied >= threshold.dims && text.length >= 20) {
123
154
  return { verdict: 'clear', score, focus_hints: [], dimensions, reason: 'drift-well-specified' };
124
155
  }
125
156
  return {
@@ -141,7 +172,7 @@ export function judgeTask(input) {
141
172
  reason: 'too-short',
142
173
  };
143
174
  }
144
- if (satisfied >= 3) {
175
+ if (satisfied >= threshold.dims && (!threshold.needAcceptance || dimensions.acceptance)) {
145
176
  return { verdict: 'clear', score, focus_hints: [], dimensions, reason: 'dimensions-covered' };
146
177
  }
147
178
  if (satisfied === 2 && (hasSpecifics(text) || TRANSFORM_PATTERN.test(text))) {
@@ -26,6 +26,10 @@ export interface MemorySnapshot {
26
26
  }
27
27
  /** forget() 的作用范围。 */
28
28
  export type MemoryScope = 'global' | 'project' | 'all';
29
+ /** 项目层记忆的落点(0.1.4):跟着工作区走,或全局共享一份。 */
30
+ export type ProjectScope = 'project' | 'global';
31
+ /** 项目层历史条目的保留上限(0.1.4 修:以前只增不减,文件会越长越大)。 */
32
+ export declare const MAX_HISTORY = 100;
29
33
  /** 摘要长度上限(字符数),供注入提示用。 */
30
34
  export declare const SUMMARY_LIMIT = 500;
31
35
  /** 空全局层。 */
@@ -44,13 +48,18 @@ export declare function resolveWorkspace(workspaceRoot?: string): string;
44
48
  * 固定到 `~/.dsh/grill-first/`(跨 profile 共享用户偏好,语义上也更合理)。
45
49
  */
46
50
  export declare function globalMemoryPath(): string;
47
- /** 项目记忆文件路径:`<workspace>/.dsh/grill-first/memory.json`。 */
48
- export declare function projectMemoryPath(workspaceRoot?: string): string;
51
+ /**
52
+ * 项目记忆文件路径。
53
+ * @param workspaceRoot 工作区根目录。
54
+ * @param scope project(默认)= `<workspace>/.dsh/grill-first/memory.json`;
55
+ * global = 与 global.json 同目录的 `project.json`(不想往项目里写文件时用)。
56
+ */
57
+ export declare function projectMemoryPath(workspaceRoot?: string, scope?: ProjectScope): string;
49
58
  /**
50
59
  * 读取两层记忆。
51
60
  * @param workspaceRoot 工作区根目录;省略时用 `process.cwd()`。
52
61
  */
53
- export declare function loadMemory(workspaceRoot?: string): Promise<MemorySnapshot>;
62
+ export declare function loadMemory(workspaceRoot?: string, scope?: ProjectScope): Promise<MemorySnapshot>;
54
63
  /**
55
64
  * 追加一条全局偏好(按文本去重,重复则刷新 updated 时间)。
56
65
  * @param text 偏好原文,例如"注释一律用中文"。
@@ -63,14 +72,14 @@ export declare function savePreference(text: string, tags: string[], workspaceRo
63
72
  * @param text 项目背景原文。
64
73
  * @param workspaceRoot 工作区根目录。
65
74
  */
66
- export declare function saveProjectContext(text: string, workspaceRoot?: string): Promise<void>;
75
+ export declare function saveProjectContext(text: string, workspaceRoot?: string, scope?: ProjectScope): Promise<void>;
67
76
  /**
68
77
  * 追加一条历史需求记录(R3:需求清单只进这里,绝不落盘成需求文件)。
69
78
  * @param task 任务原文或摘要。
70
79
  * @param requirements 用户确认过的四段式需求清单。
71
80
  * @param workspaceRoot 工作区根目录。
72
81
  */
73
- export declare function saveHistory(task: string, requirements: string, workspaceRoot?: string): Promise<void>;
82
+ export declare function saveHistory(task: string, requirements: string, workspaceRoot?: string, scope?: ProjectScope): Promise<void>;
74
83
  /**
75
84
  * 清除记忆。
76
85
  * @param scope `global` 只清全局层、`project` 只清项目层、`all` 两层都清。
@@ -8,6 +8,8 @@
8
8
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
9
9
  import { homedir } from 'node:os';
10
10
  import path from 'node:path';
11
+ /** 项目层历史条目的保留上限(0.1.4 修:以前只增不减,文件会越长越大)。 */
12
+ export const MAX_HISTORY = 100;
11
13
  /** 摘要长度上限(字符数),供注入提示用。 */
12
14
  export const SUMMARY_LIMIT = 500;
13
15
  /** 摘要里各段的条数上限。 */
@@ -43,8 +45,15 @@ export function globalMemoryPath() {
43
45
  const base = override && override.trim() !== '' ? path.resolve(override) : path.join(homedir(), '.dsh', 'grill-first');
44
46
  return path.join(base, 'global.json');
45
47
  }
46
- /** 项目记忆文件路径:`<workspace>/.dsh/grill-first/memory.json`。 */
47
- export function projectMemoryPath(workspaceRoot) {
48
+ /**
49
+ * 项目记忆文件路径。
50
+ * @param workspaceRoot 工作区根目录。
51
+ * @param scope project(默认)= `<workspace>/.dsh/grill-first/memory.json`;
52
+ * global = 与 global.json 同目录的 `project.json`(不想往项目里写文件时用)。
53
+ */
54
+ export function projectMemoryPath(workspaceRoot, scope = 'project') {
55
+ if (scope === 'global')
56
+ return path.join(path.dirname(globalMemoryPath()), 'project.json');
48
57
  return path.join(resolveWorkspace(workspaceRoot), '.dsh', 'grill-first', 'memory.json');
49
58
  }
50
59
  /** 归一化用于去重比较的文本(去空白、统一小写、去尾部标点)。 */
@@ -109,9 +118,9 @@ function coerceProject(value) {
109
118
  * 读取两层记忆。
110
119
  * @param workspaceRoot 工作区根目录;省略时用 `process.cwd()`。
111
120
  */
112
- export async function loadMemory(workspaceRoot) {
121
+ export async function loadMemory(workspaceRoot, scope = 'project') {
113
122
  const global = coerceGlobal(await readJson(globalMemoryPath(), emptyGlobal()));
114
- const project = coerceProject(await readJson(projectMemoryPath(workspaceRoot), emptyProject()));
123
+ const project = coerceProject(await readJson(projectMemoryPath(workspaceRoot, scope), emptyProject()));
115
124
  return { global, project };
116
125
  }
117
126
  /**
@@ -152,11 +161,11 @@ export async function savePreference(text, tags, workspaceRoot) {
152
161
  * @param text 项目背景原文。
153
162
  * @param workspaceRoot 工作区根目录。
154
163
  */
155
- export async function saveProjectContext(text, workspaceRoot) {
164
+ export async function saveProjectContext(text, workspaceRoot, scope = 'project') {
156
165
  const trimmed = text.trim();
157
166
  if (trimmed === '')
158
167
  return;
159
- const file = projectMemoryPath(workspaceRoot);
168
+ const file = projectMemoryPath(workspaceRoot, scope);
160
169
  const memory = coerceProject(await readJson(file, emptyProject()));
161
170
  const key = normalizeKey(trimmed);
162
171
  if (!memory.project_context.some((t) => normalizeKey(t) === key)) {
@@ -170,14 +179,17 @@ export async function saveProjectContext(text, workspaceRoot) {
170
179
  * @param requirements 用户确认过的四段式需求清单。
171
180
  * @param workspaceRoot 工作区根目录。
172
181
  */
173
- export async function saveHistory(task, requirements, workspaceRoot) {
174
- const file = projectMemoryPath(workspaceRoot);
182
+ export async function saveHistory(task, requirements, workspaceRoot, scope = 'project') {
183
+ const file = projectMemoryPath(workspaceRoot, scope);
175
184
  const memory = coerceProject(await readJson(file, emptyProject()));
176
185
  memory.history.push({
177
186
  task: task.trim().slice(0, 200),
178
187
  requirements: requirements.trim().slice(0, 2000),
179
188
  date: new Date().toISOString().slice(0, 10),
180
189
  });
190
+ // 只保留最近 MAX_HISTORY 条:摘要只用最后 3 条,其余留着也读不到。
191
+ if (memory.history.length > MAX_HISTORY)
192
+ memory.history = memory.history.slice(-MAX_HISTORY);
181
193
  await writeJsonAtomic(file, memory);
182
194
  }
183
195
  /**
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * R3 红线:需求清单只进记忆的 history 字段,绝不写任何需求文件。
11
11
  */
12
- import type { GrillConfig } from '../config.js';
12
+ import type { GrillConfig, MemoryScope } from '../config.js';
13
13
  import type { GateRegistry } from '../gate/preExecute.js';
14
14
  import type { GrillAgent, ToolDefinition, UserQuestionsService } from '../types.js';
15
15
  import type { SessionRegistry } from './sessions.js';
@@ -20,6 +20,8 @@ export interface GrillConfirmDeps {
20
20
  sessions: SessionRegistry;
21
21
  /** 解析当前会话的工作区根目录(项目层记忆落点)。 */
22
22
  workspaceRoot: (agent?: GrillAgent) => string;
23
+ /** 项目层记忆落在工作区还是插件家目录(memoryScope 配置)。 */
24
+ memoryScope?: () => MemoryScope;
23
25
  /** 结构化提问服务(可能不存在)。 */
24
26
  questions: () => UserQuestionsService | undefined;
25
27
  logger?: {
@@ -38,6 +40,9 @@ export interface GrillConfirmResult {
38
40
  skipped?: boolean;
39
41
  reason?: string;
40
42
  error?: string;
43
+ /** 已追问轮数 / 剩余轮数(选"要改"时给出,供模型判断还要不要继续问)。 */
44
+ rounds_used?: number;
45
+ rounds_left?: number;
41
46
  saved?: {
42
47
  history: boolean;
43
48
  preferences: number;
@@ -104,14 +104,15 @@ export function createGrillConfirmTool(deps) {
104
104
  if (config.memoryEnabled) {
105
105
  const root = deps.workspaceRoot(exec?.agent);
106
106
  try {
107
- await saveHistory(task, requirements, root);
107
+ const scope = deps.memoryScope?.() ?? 'project';
108
+ await saveHistory(task, requirements, root, scope);
108
109
  saved.history = true;
109
110
  for (const preference of preferences) {
110
111
  await savePreference(preference, ['用户确认'], root);
111
112
  saved.preferences += 1;
112
113
  }
113
114
  const context = requirements.replace(/\s+/g, ' ').slice(0, PROJECT_CONTEXT_LIMIT);
114
- await saveProjectContext(context, root);
115
+ await saveProjectContext(context, root, scope);
115
116
  saved.project_context = true;
116
117
  }
117
118
  catch (error) {
@@ -176,12 +177,22 @@ export function createGrillConfirmTool(deps) {
176
177
  const result = await accept('structured-question');
177
178
  return custom === '' ? result : { ...result, user_note: custom };
178
179
  }
180
+ const rounds = deps.sessions.bumpRound(agentId);
181
+ const maxRounds = config.maxRounds;
182
+ const roundsLeft = Math.max(0, maxRounds - rounds);
179
183
  return {
180
184
  confirmed: false,
181
185
  requirements,
182
186
  feedback: custom !== ''
183
187
  ? custom
184
188
  : '用户选择了"要改",但没有写明改哪里:请追问具体修改点,修订需求清单后再次调用 grill_confirm。',
189
+ rounds_used: rounds,
190
+ rounds_left: roundsLeft,
191
+ ...(roundsLeft === 0
192
+ ? {
193
+ instructions: `已达追问轮数上限(${maxRounds} 轮):不要再问,直接按当前理解开工——下次调用 grill_start 会返回 clear。`,
194
+ }
195
+ : {}),
185
196
  };
186
197
  },
187
198
  };
@@ -4,7 +4,7 @@
4
4
  * - clear:放行(门禁置 open),模型直接开工;
5
5
  * - needs-clarification:门禁置 closed,并返回记忆摘要、追问方向与轮次上限。
6
6
  */
7
- import type { GrillConfig } from '../config.js';
7
+ import type { GrillConfig, MemoryScope } from '../config.js';
8
8
  import type { GateRegistry } from '../gate/preExecute.js';
9
9
  import type { GrillAgent, ToolDefinition } from '../types.js';
10
10
  import type { SessionRegistry } from './sessions.js';
@@ -15,6 +15,8 @@ export interface GrillStartDeps {
15
15
  sessions: SessionRegistry;
16
16
  /** 解析当前会话的工作区根目录(项目层记忆落点)。 */
17
17
  workspaceRoot: (agent?: GrillAgent) => string;
18
+ /** 项目层记忆落在工作区还是插件家目录(memoryScope 配置)。 */
19
+ memoryScope?: () => MemoryScope;
18
20
  logger?: {
19
21
  warn(message: string): void;
20
22
  };
@@ -35,6 +37,8 @@ export interface GrillStartResult {
35
37
  task_id?: string;
36
38
  previous_task?: string;
37
39
  hint?: string;
40
+ /** 已经追问过的轮数(达到 maxRounds 后不再追问)。 */
41
+ rounds_used?: number;
38
42
  /** 当前运行的插件版本(取自 package.json):用于确认改动是否已随重启生效。 */
39
43
  version?: string;
40
44
  }
@@ -43,6 +43,7 @@ const OUTPUT_SCHEMA = {
43
43
  task_id: { type: 'string' },
44
44
  previous_task: { type: 'string' },
45
45
  hint: { type: 'string' },
46
+ rounds_used: { type: 'number', description: '已经追问过的轮数' },
46
47
  version: { type: 'string', description: '运行中的插件版本;若缺失说明宿主加载的是旧版代码,需重启 DSH' },
47
48
  },
48
49
  additionalProperties: true,
@@ -77,7 +78,12 @@ export function createGrillStartTool(deps) {
77
78
  version: PLUGIN_VERSION,
78
79
  };
79
80
  }
80
- const judged = judgeTask({ text: task, mode, bypassKeywords: config.bypassKeywords });
81
+ const judged = judgeTask({
82
+ text: task,
83
+ mode,
84
+ bypassKeywords: config.bypassKeywords,
85
+ intensity: config.intensity,
86
+ });
81
87
  const previous = deps.sessions.get(agentId);
82
88
  if (judged.verdict === 'clear') {
83
89
  deps.sessions.end(agentId);
@@ -93,12 +99,29 @@ export function createGrillStartTool(deps) {
93
99
  };
94
100
  }
95
101
  const startedAt = Date.now();
96
- deps.sessions.start({ agentId, task, mode, startedAt });
102
+ // maxRounds 兜底:问够轮数就不再问,直接按当前理解开工(0.1.4 修:以前这个配置项根本没生效)。
103
+ const roundsUsed = previous?.rounds ?? 0;
104
+ if (roundsUsed >= config.maxRounds) {
105
+ deps.sessions.end(agentId);
106
+ deps.gates.open(agentId);
107
+ return {
108
+ verdict: 'clear',
109
+ skip: true,
110
+ reason: 'max-rounds-reached',
111
+ score: judged.score,
112
+ gate_state: 'open',
113
+ mode,
114
+ rounds_used: roundsUsed,
115
+ hint: `已经追问 ${roundsUsed} 轮(上限 ${config.maxRounds}),按当前理解直接开工;有歧义就先做最小可验证版本。`,
116
+ version: PLUGIN_VERSION,
117
+ };
118
+ }
119
+ deps.sessions.start({ agentId, task, mode, startedAt, rounds: roundsUsed });
97
120
  deps.gates.close(agentId);
98
121
  let memoryContext = '';
99
122
  if (config.memoryEnabled) {
100
123
  try {
101
- memoryContext = summarize(await loadMemory(deps.workspaceRoot(exec?.agent)));
124
+ memoryContext = summarize(await loadMemory(deps.workspaceRoot(exec?.agent), (deps.memoryScope?.() ?? 'project')));
102
125
  }
103
126
  catch (error) {
104
127
  deps.logger?.warn(`[grill-first] 读取记忆失败:${error instanceof Error ? error.message : String(error)}`);
@@ -118,6 +141,7 @@ export function createGrillStartTool(deps) {
118
141
  intensity: config.intensity,
119
142
  mode,
120
143
  task_id: `${agentId}:${startedAt}`,
144
+ rounds_used: roundsUsed,
121
145
  version: PLUGIN_VERSION,
122
146
  ...(previous?.task ? { previous_task: previous.task } : {}),
123
147
  };
@@ -9,12 +9,16 @@ export interface GrillSession {
9
9
  task: string;
10
10
  mode: JudgeMode;
11
11
  startedAt: number;
12
+ /** 已经追问过几轮(用户选"要改"算一轮),用于落实 maxRounds 上限。 */
13
+ rounds?: number;
12
14
  }
13
15
  /** agent → 会话 的内存态映射(进程内,不持久化)。 */
14
16
  export declare class SessionRegistry {
15
17
  private readonly sessions;
16
18
  /** 登记/覆盖一个 agent 的当前任务。 */
17
19
  start(session: GrillSession): void;
20
+ /** 记一轮追问(用户选"要改"时调用),返回累计轮数。 */
21
+ bumpRound(agentId: string): number;
18
22
  /** 读取某个 agent 的当前任务。 */
19
23
  get(agentId: string): GrillSession | undefined;
20
24
  /** 结束某个 agent 的澄清会话(确认完成或跳过)。 */
@@ -5,7 +5,15 @@ export class SessionRegistry {
5
5
  start(session) {
6
6
  if (!session.agentId)
7
7
  return;
8
- this.sessions.set(session.agentId, session);
8
+ this.sessions.set(session.agentId, { ...session, rounds: session.rounds ?? 0 });
9
+ }
10
+ /** 记一轮追问(用户选"要改"时调用),返回累计轮数。 */
11
+ bumpRound(agentId) {
12
+ const session = this.sessions.get(agentId);
13
+ if (!session)
14
+ return 0;
15
+ session.rounds = (session.rounds ?? 0) + 1;
16
+ return session.rounds;
9
17
  }
10
18
  /** 读取某个 agent 的当前任务。 */
11
19
  get(agentId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-grill-first",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "DSH 插件:开工前刨根问底——把模糊需求问成明确需求,用户确认后才放行主任务",
5
5
  "icon": "./icon.png",
6
6
  "author": "InoriMutsumi",