@oxmumu3/dsh-project-companion 0.22.1 → 0.23.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.
@@ -1,3 +1,16 @@
1
+ # 0.23.0 验证增量
2
+
3
+ 2026-11-04,把项目工具的 30 个动作收敛为 `look` / `manage` 两个首选入口(旧名保留兼容)。
4
+
5
+ - `node --max-old-space-size=256 --test --test-concurrency=1 test/*.test.mjs`:2,343 项 / 2,306 通过 / 0 失败 / 37 跳过(跳过项均需 Family 源码树)。
6
+ - `node --test test/contract-guidance.test.mjs test/guidance.test.mjs test/action-surface.test.mjs test/routing-surface.test.mjs`:15/15。
7
+ - `npm run report:routing`:动作 32(入口 2 + 兼容旧名 30)、look 视角 6、manage 动作 12、策略提示词 3,112 字符、`project_companion` 描述 722 字符、四个工具描述合计 1,367 字符、40 句语料 40 句可解析到入口。
8
+ - 契约测试固定三件事:入口只能翻译成已声明的动作;语料覆盖每个 view/op;描述写明全部取值。
9
+
10
+ **本版没有验证的**:没有用真实模型跑这 40 句,所以"入口收敛是否让模型选得更准"仍未证实——本版只证明入口集合更小、映射正确、语料可解析。旧动作名仍在 enum 中,模型理论上仍可直接调用它们。界面上的首屏简报与诊断码可视化未做。
11
+
12
+ ---
13
+
1
14
  # 0.22.1 验证增量
2
15
 
3
16
  2026-11-04,把 0.21 私有线独有的 3 个诊断模块移植到发布线并接回 9 个引用点(另补上私有线清单里漏掉的 `news-model.js` 与 `review-runtime-health.js`)。
package/index.js CHANGED
@@ -7,6 +7,7 @@ import {defineDomain,domainTable} from '@deepseek-ai/dsh-storage-domain';
7
7
  import {createUserMessage} from '@deepseek-ai/dsh-llm';
8
8
  import {createService,parseCommand} from './src/service.js';
9
9
  import {registerGuidance} from './src/guidance.js';
10
+ import {normalizeSurfaceAction,LOOK_VIEWS,MANAGE_OPS,SURFACE_COPY} from './src/action-surface.js';
10
11
  import {createWechatRecoveryGuard} from './src/wechat-recovery.js';
11
12
  import {createCoordinator,effectiveCwd} from './src/coordination.js';
12
13
  import {createFamilyProjectAdmission} from './src/family-project-admission.js';
@@ -1025,9 +1026,11 @@ export async function installCompanion(ctx,options={}){
1025
1026
  }));
1026
1027
  ctx.tools.register(defineTool({
1027
1028
  name:'project_companion',
1028
- description:'项目与交办。list/watch/unwatch 按友好项目名称查询或关注;digest 看进展与卡点(detail:analysis 读有界材料);project_update 只回答“相比上次有什么变化”;memory/remember/correct_memory/forget_memory 管项目记忆;context_brief/context_status/context_handoff/context_check/context_forget 走个人资料简报闭环;check_once/once_status/cancel_once 是一次检查,configure_followup/cancel_followup/followup_status 是周期跟进,configure_daily/cancel_daily 是每日汇报;remind/cancel_reminder 是提醒;coordinate/task_status 是交办与结果查询;pause/resume 管自动跟进。查询不派工。',
1029
+ description:'项目与交办。两类入口优先用:【看】action:look + view:progress=进展与卡点|change=相比上次的变化(本人明确问了才传 userRequested:true)|status=总状态|task=交办到哪了|once=一次检查|followup=周期跟进。【管】action:manage + op:watch/unwatch=关注或取消|memory/remember/correct_memory/forget_memory=项目记忆|pause/resume=暂停或恢复自动跟进|daily/cancel_daily=每日汇报|followup/cancel_followup=周期跟进配置。【做】保持独立动作:coordinate=明确要求执行或继续;remind=提醒;personal_context 读外部资料;personal_review 做回顾;ai_digest 做资讯。【兼容】旧动作名仍可用,不删是为了不破坏已安装:list、digest、project_update、status、task_status、once_status、cancel_once、followup_status、check_once、configure_daily、cancel_daily、configure_followup、cancel_followup、link_github、context_brief、context_status、context_handoff、context_check、context_forget,提醒取消用 cancel_reminder。新调用优先用 look/manage。',
1029
1030
  parameters:{
1030
- action:{type:'string',required:true,enum:['watch','unwatch','list','digest','project_update','context_brief','context_status','context_handoff','context_check','context_forget','memory','remember','correct_memory','forget_memory','check_once','once_status','cancel_once','configure_followup','cancel_followup','followup_status','configure_daily','cancel_daily','pause','resume','remind','cancel_reminder','link_github','status','coordinate','task_status']},
1031
+ action:{type:'string',required:true,enum:['look','manage','watch','unwatch','list','digest','project_update','context_brief','context_status','context_handoff','context_check','context_forget','memory','remember','correct_memory','forget_memory','check_once','once_status','cancel_once','configure_followup','cancel_followup','followup_status','configure_daily','cancel_daily','pause','resume','remind','cancel_reminder','link_github','status','coordinate','task_status']},
1032
+ view:{type:'string',enum:LOOK_VIEWS,description:'look 的视角。'+SURFACE_COPY.look},
1033
+ op:{type:'string',enum:MANAGE_OPS,description:'manage 的动作。'+SURFACE_COPY.manage},
1031
1034
  reviewItemId:{type:'string',description:'coordinate可选:仅当本人任务全文明确引用个人回顾「事项原题」时,从prepare结果映射稳定itemId。目标须在该事项原回顾项目范围内;不自行添加源正文或宣称已处理。'},
1032
1035
  objective:{type:'string',description:'context_brief:用户明确想推进的目标,最多160字;沿用同一目标时可省略。'},
1033
1036
  queries:{type:'array',items:{type:'object',additionalProperties:false,properties:{source:{type:'string',enum:CONTEXT_SOURCES,required:true},operation:{type:'string',required:true},query:{type:'string'},id:{type:'string'},ids:{type:'array',items:{type:'string'}},projectId:{type:'string'},range:{type:'string'},from:{type:'string'},to:{type:'string'},calendarId:{type:'string'},limit:{type:'number'}}},description:'context_brief:用户明确限定的最多5项来源查询(每个来源一次),每项最多3条。每源独立参数,沿用当前简报范围时省略;不因已连接而扫描全部账号。'},
@@ -1091,6 +1094,7 @@ export async function installCompanion(ctx,options={}){
1091
1094
  }},
1092
1095
  async execute(args,execution){
1093
1096
  if(disposed)return{status:'blocked',accepted:false,text:'项目助理正在关闭,暂不接收新的交办。'};
1097
+ args=normalizeSurfaceAction(args);
1094
1098
  try{return await withFamily(execution,args,async admittedArgs=>{args=admittedArgs;try{
1095
1099
  if(args.action.startsWith('context_')){
1096
1100
  try{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxmumu3/dsh-project-companion",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "DSH Dot: WeChat personal agent, project memory and proactive follow-up for DeepSeek Harness",
5
5
  "author": "牛村木木山",
6
6
  "license": "MIT",
@@ -25,7 +25,9 @@
25
25
  "docs/comparison.md",
26
26
  "scripts/demo.mjs",
27
27
  "scripts/prompt-report.mjs",
28
+ "scripts/routing-report.mjs",
28
29
  "test/",
30
+ "test/routing-corpus.mjs",
29
31
  "docs/developer-commands.md",
30
32
  "vendor/",
31
33
  "THIRD_PARTY_NOTICES.md",
@@ -58,7 +60,8 @@
58
60
  "test": "node --max-old-space-size=128 --test --test-concurrency=1 test/*.test.mjs",
59
61
  "demo": "node --max-old-space-size=128 scripts/demo.mjs",
60
62
  "report:prompt": "node scripts/prompt-report.mjs",
61
- "verify": "node --max-old-space-size=256 --test --test-concurrency=1 test/*.test.mjs && node scripts/prompt-report.mjs"
63
+ "verify": "node --max-old-space-size=256 --test --test-concurrency=1 test/*.test.mjs && node scripts/prompt-report.mjs",
64
+ "report:routing": "node scripts/routing-report.mjs"
62
65
  },
63
66
  "dependencies": {
64
67
  "zod": "4.6.7"
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /** Report the model-facing routing surface and the phrasing corpus coverage.
3
+ * Run after changing the tool surface: `npm run report:routing`. */
4
+ import {readFile} from 'node:fs/promises';
5
+ import {registerGuidance} from '../src/guidance.js';
6
+ import {LOOK_VIEWS,MANAGE_OPS,normalizeSurfaceAction} from '../src/action-surface.js';
7
+ import {CORPUS} from '../test/routing-corpus.mjs';
8
+
9
+ const source=await readFile(new URL('../index.js',import.meta.url),'utf8');
10
+ function surface(tool){
11
+ const at=source.indexOf(`name:'${tool}',\n description:'`);
12
+ const description=/description:'([^']*)'/.exec(source.slice(at,at+4000))[1];
13
+ const list=/action:\{type:'string',required:true,enum:\[([^\]]*)\]/.exec(source.slice(at,at+8000))[1]
14
+ .split(',').map(value=>value.trim().replace(/^'|'$/g,'')).filter(Boolean);
15
+ return {description,list};
16
+ }
17
+ let section;
18
+ registerGuidance({systemPrompt:{section:value=>{section=value;}},tools:{get:()=>({})}},{clock:()=>new Date(),hostTimeZone:()=>Intl.DateTimeFormat().resolvedOptions().timeZone});
19
+ const promptText=section.text({scope:{id:'synthetic-report'}});
20
+ const project=surface('project_companion');
21
+ const composites=project.list.filter(name=>name==='look'||name==='manage');
22
+ const legacy=project.list.filter(name=>!composites.includes(name));
23
+
24
+ const routed=CORPUS.filter(([,entry,key])=>{
25
+ const args=entry==='look'?{action:'look',view:key,project:'p'}:{action:'manage',op:key,project:'p'};
26
+ try{return normalizeSurfaceAction(args).action!==entry;}catch{return false;}
27
+ });
28
+
29
+ console.log('模型可见路由面报告');
30
+ console.log(`工具动作数 ${project.list.length}(入口 ${composites.length} 个:${composites.join('、')};兼容旧名 ${legacy.length} 个)`);
31
+ console.log(`look 视角 ${LOOK_VIEWS.length} 个:${LOOK_VIEWS.join('、')}`);
32
+ console.log(`manage 动作 ${MANAGE_OPS.length} 个:${MANAGE_OPS.join('、')}`);
33
+ console.log(`策略提示词 ${promptText.length} 字符`);
34
+ console.log(`工具描述 ${project.description.length} 字符`);
35
+ console.log(`评测语料 ${CORPUS.length} 句,可解析到入口 ${routed.length} 句`);
36
+ console.log('');
37
+ console.log('说明:入口数决定模型一次要在几个名字里选;兼容旧名不删是为了不破坏已安装。');
@@ -0,0 +1,56 @@
1
+ /** Two composite entries over the project tool's long legacy action list.
2
+ *
3
+ * The model picks between `look` and `manage` (plus the safety-critical
4
+ * standalone actions it already had); this module translates the chosen
5
+ * view/op back into the exact legacy action so every existing handler,
6
+ * guard and test keeps working unchanged. Old action names stay valid.
7
+ */
8
+ export const LOOK_VIEWS = ['progress', 'change', 'status', 'task', 'once', 'followup'];
9
+ export const MANAGE_OPS = ['watch', 'unwatch', 'memory', 'remember', 'correct_memory', 'forget_memory', 'pause', 'resume', 'daily', 'cancel_daily', 'followup', 'cancel_followup'];
10
+
11
+ const LOOK = {
12
+ progress: a => ({ action: 'digest', project: a.project, ...(a.detail ? { detail: a.detail } : {}) }),
13
+ change: a => ({ action: 'project_update', project: a.project, userRequested: a.userRequested === true }),
14
+ status: () => ({ action: 'status' }),
15
+ task: a => ({ action: 'task_status', ...(a.id ? { id: a.id } : {}) }),
16
+ once: a => ({ action: 'once_status', ...(a.project ? { project: a.project } : {}) }),
17
+ followup: a => ({ action: 'followup_status', ...(a.project ? { project: a.project } : {}) }),
18
+ };
19
+
20
+ const MANAGE = {
21
+ watch: a => ({ action: 'watch', ...(a.project ? { project: a.project } : {}) }),
22
+ unwatch: a => ({ action: 'unwatch', ...(a.project ? { project: a.project } : {}) }),
23
+ memory: a => ({ action: 'memory', project: a.project }),
24
+ remember: a => ({ action: 'remember', project: a.project, memoryKind: a.memoryKind, memoryText: a.memoryText, sourceKind: a.sourceKind, ...(a.sourceReference ? { sourceReference: a.sourceReference } : {}), userRequested: a.userRequested === true }),
25
+ correct_memory: a => ({ action: 'correct_memory', project: a.project, memoryId: a.memoryId, memoryText: a.memoryText, userRequested: a.userRequested === true }),
26
+ forget_memory: a => ({ action: 'forget_memory', project: a.project, memoryId: a.memoryId, userRequested: a.userRequested === true }),
27
+ pause: () => ({ action: 'pause' }),
28
+ resume: () => ({ action: 'resume' }),
29
+ daily: a => ({ action: 'configure_daily', time: a.time, ...(a.timeZone ? { timeZone: a.timeZone } : {}), ...(a.project ? { project: a.project } : {}) }),
30
+ cancel_daily: () => ({ action: 'cancel_daily' }),
31
+ followup: a => ({ action: 'configure_followup', everyMinutes: a.everyMinutes, ...(a.project ? { project: a.project } : {}) }),
32
+ cancel_followup: a => ({ action: 'cancel_followup', ...(a.project ? { project: a.project } : {}) }),
33
+ };
34
+
35
+ /** Pure translation. Unknown view/op or a missing discriminator throws, so a
36
+ * malformed call fails loudly instead of silently doing something else. */
37
+ export function normalizeSurfaceAction(args) {
38
+ if (!args || typeof args !== 'object') return args;
39
+ if (args.action === 'look') {
40
+ const view = args.view;
41
+ if (!Object.hasOwn(LOOK, view)) throw Error(`look 需要 view,可选:${LOOK_VIEWS.join('、')}`);
42
+ return LOOK[view](args);
43
+ }
44
+ if (args.action === 'manage') {
45
+ const op = args.op;
46
+ if (!Object.hasOwn(MANAGE, op)) throw Error(`manage 需要 op,可选:${MANAGE_OPS.join('、')}`);
47
+ return MANAGE[op](args);
48
+ }
49
+ return args;
50
+ }
51
+
52
+ /** What each composite view/op means, for the tool description and reports. */
53
+ export const SURFACE_COPY = {
54
+ look: 'progress=进展与卡点|change=相比上次的变化(需本人明确问了才传 userRequested:true)|status=助理总状态|task=交办到哪了|once=一次检查的状态|followup=周期跟进的状态',
55
+ manage: 'watch=关注项目|unwatch=取消关注|memory=读项目记忆|remember=记一条|correct_memory=改一条|forget_memory=忘一条|pause=暂停自动跟进|resume=恢复|daily=每日汇报时间|cancel_daily=取消日报|followup=设置周期跟进(要分钟数)|cancel_followup=取消周期跟进',
56
+ };
package/src/guidance.js CHANGED
@@ -7,7 +7,7 @@ const RULES = [
7
7
  '能读能做的直接做,不先给用户递菜单;只有真实缺项(名称、时间、来源范围、明确授权)才问一个必要问题。',
8
8
  '回复用简洁Markdown,一句话先给结果;必要时短标题、少量加粗、短列表,段间留空行。不用宽表格、emoji或整段加粗,不把工具原文、内部ID、绝对路径、ISO时间、英文状态码堆给用户。',
9
9
 
10
- '【项目】找项目或关注用 project_companion 的 list|watch|unwatch,名称唯一就直接处理。进展与卡点用 digest,要细读传 detail:analysis,短摘要不等于全部资料。只有本人明确问“相比上次有什么变化”才用 project_update;本人明确给出公开 GitHub 仓库时才用 link_github。',
10
+ '【项目】优先用两个入口:action=look(view=progress|change|status|task|once|followup)和 action=manage(op=watch|unwatch|memory|remember|correct_memory|forget_memory|pause|resume|daily|cancel_daily|followup|cancel_followup),旧的细分动作名仍可用。名称唯一就直接处理(按名字检索仍可用 list)。进展用 progress;只有本人明确问“相比上次有什么变化”才用 change 并传 userRequested:true;本人给出公开 GitHub 仓库时才用 link_github。',
11
11
  '【记忆】读用 memory,记用 remember;sourceKind 分清 user/assistant/file,只有本人明确表达才用 user 并传 userRequested:true,模型推断标 assistant。本人说“记住这点”就保存,并用一句话回执说清存了什么。',
12
12
  '【纠错】本人明确纠正或要求忘记时,先用 memory 在同一项目准确选定已有记录,内部填 memoryId,再用 correct_memory 或 forget_memory 并传 userRequested:true;不要求用户提供 ID,内容有歧义才用简短描述澄清,不跨项目猜测或批量删除。保留原始来源并标明本轮用户纠正,不把文件或助手记录改称用户原话;回执只说纠正或移除的具体记忆,不声称已验证或业务验收。',
13
13
  '【资料简报】结合个人资料排下一步用 context_brief(给 objective 和逐来源 queries,最多5源、每源最多3条);查状态用 context_status;把简报第几项交给项目用 context_handoff(当次 revision+itemId)。事项按普通编号说,不展示内部ID。',
@@ -0,0 +1,69 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile} from 'node:fs/promises';
4
+ import {normalizeSurfaceAction,LOOK_VIEWS,MANAGE_OPS,SURFACE_COPY} from '../src/action-surface.js';
5
+
6
+ const source=await readFile(new URL('../index.js',import.meta.url),'utf8');
7
+ function enumOf(tool){
8
+ const at=source.indexOf(`name:'${tool}',\n description:'`);
9
+ assert.ok(at>0,tool);
10
+ const match=/action:\{type:'string',required:true,enum:\[([^\]]*)\]/.exec(source.slice(at,at+8000));
11
+ assert.ok(match,`${tool} action enum`);
12
+ return match[1].split(',').map(value=>value.trim().replace(/^'|'$/g,'')).filter(Boolean);
13
+ }
14
+ const TOOLS=enumOf('project_companion');
15
+
16
+ // The composite entries must only ever translate into actions that the tool
17
+ // actually declares; a typo here would ship a call that the schema rejects.
18
+ test('every composite view and op translates into a declared legacy action',()=>{
19
+ for(const view of LOOK_VIEWS){
20
+ const target=normalizeSurfaceAction({action:'look',view,project:'/synthetic/p'});
21
+ assert.ok(TOOLS.includes(target.action),`look.${view} -> ${target.action} must be declared`);
22
+ }
23
+ for(const op of MANAGE_OPS){
24
+ const target=normalizeSurfaceAction({action:'manage',op,project:'/synthetic/p',time:'09:00',everyMinutes:60,memoryKind:'goal',memoryText:'x',sourceKind:'assistant'});
25
+ assert.ok(TOOLS.includes(target.action),`manage.${op} -> ${target.action} must be declared`);
26
+ }
27
+ assert.ok(TOOLS.includes('look')&&TOOLS.includes('manage'),'both entries stay declared');
28
+ });
29
+
30
+ test('look views keep the parameters the legacy handlers already rely on',()=>{
31
+ assert.deepEqual(normalizeSurfaceAction({action:'look',view:'progress',project:'p',detail:'analysis'}),
32
+ {action:'digest',project:'p',detail:'analysis'});
33
+ assert.equal(normalizeSurfaceAction({action:'look',view:'progress',project:'p'}).detail,undefined);
34
+ assert.deepEqual(normalizeSurfaceAction({action:'look',view:'task',id:'job-1'}),{action:'task_status',id:'job-1'});
35
+ assert.deepEqual(normalizeSurfaceAction({action:'look',view:'status'}),{action:'status'});
36
+ });
37
+
38
+ // project_update is gated on the user asking; the composite must not widen it.
39
+ test('look.change never invents the user request flag',()=>{
40
+ assert.equal(normalizeSurfaceAction({action:'look',view:'change',project:'p'}).userRequested,false);
41
+ assert.equal(normalizeSurfaceAction({action:'look',view:'change',project:'p',userRequested:true}).userRequested,true);
42
+ });
43
+
44
+ test('manage ops keep authorization flags exactly as the caller passed them',()=>{
45
+ const remember=normalizeSurfaceAction({action:'manage',op:'remember',project:'p',memoryKind:'decision',memoryText:'t',sourceKind:'user'});
46
+ assert.deepEqual(remember,{action:'remember',project:'p',memoryKind:'decision',memoryText:'t',sourceKind:'user',userRequested:false});
47
+ const correct=normalizeSurfaceAction({action:'manage',op:'correct_memory',project:'p',memoryId:'m',memoryText:'t',userRequested:true});
48
+ assert.equal(correct.action,'correct_memory');
49
+ assert.equal(correct.userRequested,true);
50
+ const daily=normalizeSurfaceAction({action:'manage',op:'daily',time:'09:10',timeZone:'America/Mexico_City'});
51
+ assert.deepEqual(daily,{action:'configure_daily',time:'09:10',timeZone:'America/Mexico_City'});
52
+ });
53
+
54
+ test('a malformed composite fails loudly instead of guessing',()=>{
55
+ assert.throws(()=>normalizeSurfaceAction({action:'look'}),/look 需要 view/);
56
+ assert.throws(()=>normalizeSurfaceAction({action:'look',view:'nope'}),/look 需要 view/);
57
+ assert.throws(()=>normalizeSurfaceAction({action:'manage',op:'nope'}),/manage 需要 op/);
58
+ assert.equal(normalizeSurfaceAction({action:'digest',project:'p'}).action,'digest','legacy names pass through untouched');
59
+ });
60
+
61
+ test('the tool description teaches both entries and every discriminator',()=>{
62
+ const at=source.indexOf("name:'project_companion',\n description:'");
63
+ const description=/description:'([^']*)'/.exec(source.slice(at,at+4000))[1];
64
+ assert.match(description,/look/);
65
+ assert.match(description,/manage/);
66
+ for(const view of LOOK_VIEWS)assert.ok(description.includes(view),`description must name view ${view}`);
67
+ for(const op of MANAGE_OPS)assert.ok(description.includes(op),`description must name op ${op}`);
68
+ assert.equal(Object.keys(SURFACE_COPY).sort().join(','),'look,manage');
69
+ });
@@ -11,7 +11,8 @@ function guidance(){
11
11
 
12
12
  test('memory guidance offers natural correction and forgetting with exact internal selection and an honest receipt',()=>{
13
13
  const prompt=guidance();
14
- const memory=prompt.split('\n').find(line=>line.includes('correct_memory'));
14
+ const memory=prompt.split('\n').find(line=>line.includes('memoryId'));
15
+ assert.ok(memory&&memory.includes('correct_memory'),'the memory-editing policy line must name the tools');
15
16
  assert.ok(memory,'natural-language memory editing must be discoverable');
16
17
  for(const term of ['forget_memory','userRequested:true','memory','memoryId'])assert.ok(memory.includes(term),term);
17
18
  assert.match(memory,/同一项目|当前项目/);assert.match(memory,/准确|精确/);assert.match(memory,/歧义/);
@@ -0,0 +1,44 @@
1
+ /** Natural phrasings a user actually types, and the entry each should land on.
2
+ * Kept as data so both the test and scripts/routing-report.mjs read one list. */
3
+ export const CORPUS = [
4
+ ['这个项目最近推进到哪了', 'look', 'progress'],
5
+ ['看看有什么卡住的', 'look', 'progress'],
6
+ ['把资料再读细一点说', 'look', 'progress'],
7
+ ['跟上次比有什么变化', 'look', 'change'],
8
+ ['这两天有什么新动静', 'look', 'change'],
9
+ ['助理现在什么状态', 'look', 'status'],
10
+ ['上次那个任务成了吗', 'look', 'task'],
11
+ ['交办的事有结果了吗', 'look', 'task'],
12
+ ['那次检查跑完了吗', 'look', 'once'],
13
+ ['周期跟进的还开着吗', 'look', 'followup'],
14
+ ['把 DSH 关注一下', 'manage', 'watch'],
15
+ ['别再盯这个项目了', 'manage', 'unwatch'],
16
+ ['之前记过什么', 'manage', 'memory'],
17
+ ['记住这个决定', 'manage', 'remember'],
18
+ ['这条记错了,改一下', 'manage', 'correct_memory'],
19
+ ['这条别再记着了', 'manage', 'forget_memory'],
20
+ ['先别自动跑了', 'manage', 'pause'],
21
+ ['恢复自动跟进吧', 'manage', 'resume'],
22
+ ['以后每天早上九点汇报', 'manage', 'daily'],
23
+ ['日报先停掉', 'manage', 'cancel_daily'],
24
+ ['每小时看一次这个项目', 'manage', 'followup'],
25
+ ['这个跟进取消掉', 'manage', 'cancel_followup'],
26
+ ['帮我梳理一下这周的进展', 'look', 'progress'],
27
+ ['现在有哪些风险', 'look', 'progress'],
28
+ ['上周到现在变了什么', 'look', 'change'],
29
+ ['上次看到哪儿了', 'look', 'change'],
30
+ ['你还记得我上次说的目标吗', 'manage', 'memory'],
31
+ ['这个约束记一下', 'manage', 'remember'],
32
+ ['把那条阻塞删掉', 'manage', 'forget_memory'],
33
+ ['我记得你记错了', 'manage', 'correct_memory'],
34
+ ['这个项目不用再跟了', 'manage', 'unwatch'],
35
+ ['把工作区加进来', 'manage', 'watch'],
36
+ ['每天九点半发一次', 'manage', 'daily'],
37
+ ['每天早上那封先别发了', 'manage', 'cancel_daily'],
38
+ ['二十分钟看一次就行', 'manage', 'followup'],
39
+ ['别再看这个项目了', 'manage', 'unwatch'],
40
+ ['交办那个跑完了没', 'look', 'task'],
41
+ ['检查有结果了吗', 'look', 'once'],
42
+ ['跟进还在跑吗', 'look', 'followup'],
43
+ ['现在整体状态怎么样', 'look', 'status'],
44
+ ];
@@ -0,0 +1,17 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {LOOK_VIEWS,MANAGE_OPS,normalizeSurfaceAction} from '../src/action-surface.js';
4
+ import {CORPUS} from './routing-corpus.mjs';
5
+
6
+ test('every natural phrasing in the corpus resolves to a real composite entry',()=>{
7
+ assert.ok(CORPUS.length>=40,'corpus must stay at least 40 phrasings');
8
+ const seen=new Set();
9
+ for(const [utterance,entry,key] of CORPUS){
10
+ const args=entry==='look'?{action:'look',view:key,project:'p'}:{action:'manage',op:key,project:'p',time:'09:00',everyMinutes:60,memoryKind:'goal',memoryText:'x',sourceKind:'assistant'};
11
+ const target=normalizeSurfaceAction(args);
12
+ assert.ok(target.action&&target.action!==entry,`${utterance} must translate`);
13
+ seen.add(`${entry}.${key}`);
14
+ }
15
+ for(const view of LOOK_VIEWS)assert.ok(seen.has(`look.${view}`),`corpus misses look.${view}`);
16
+ for(const op of MANAGE_OPS)assert.ok(seen.has(`manage.${op}`),`corpus misses manage.${op}`);
17
+ });