@openturtle/cli 0.3.7 → 0.4.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.
package/README.md CHANGED
@@ -56,12 +56,17 @@ ot knowledge search "部署"
56
56
  ot knowledge local search "部署"
57
57
  ot meetings list --project <project-id>
58
58
  ot work summary --project <project-id>
59
+ ot worklogs show <worklog-id>
60
+ ot worklogs list --project <project-id> --to <iso-time> --scope team
61
+ ot context get --project <project-id> --query "本周风险"
59
62
  ot worklogs record --summary "完成联调" --type progress --todo <todo-id>
60
63
  ot report draft --today
61
64
  ```
62
65
 
63
66
  `ot install` 只安装 CLI 使用指引。Agent 直接执行 `ot ... --json`,不需要 MCP 或 Hook。
64
67
 
68
+ 普通项目综合问答优先使用一次聚合 `context get`,最多再做一次定向补查。日常回答不要使用 `--trace`、`--include-candidates`、`--help` 或全量对象列表;两次读取后仍缺信息时应明确标记“未召回”并停止。`trace` 只用于召回审计和故障诊断。
69
+
65
70
  ## 常用场景
66
71
 
67
72
  ### 看我的待办
@@ -95,6 +100,30 @@ ot knowledge search "日报" --local-only
95
100
  ot knowledge local search "日报"
96
101
  ```
97
102
 
103
+ 需要可重复的服务端语料边界时,使用带时区的 `--as-of` 或不可变快照:
104
+
105
+ ```bash
106
+ ot knowledge search "日报" --project <project-id> --as-of 2026-07-27T07:28:32Z
107
+ ot context get --project <project-id> --query "上次回读验收还有什么缺口" --snapshot <snapshot-id>
108
+ ```
109
+
110
+ 使用 `--as-of`、`--snapshot` 或任一排除参数时,聚合搜索只返回服务端云端结果,避免无版本边界的本地索引混入;这类参数不能与 `--local-only` 同时使用。
111
+
112
+ ### 导出不可变项目快照
113
+
114
+ 创建快照是写操作,先检查 dry-run:
115
+
116
+ ```bash
117
+ ot projects snapshot export <project-id> \
118
+ --as-of 2026-07-27T07:28:32Z \
119
+ --exclude-tag evaluation-artifact \
120
+ --dry-run
121
+ ot projects snapshot list --project <project-id>
122
+ ot projects snapshot show <snapshot-id>
123
+ ```
124
+
125
+ 快照包含捕获策略、对象版本时间、逐对象内容哈希和 manifest hash。创建后不能更新或删除。
126
+
98
127
  ### 上传录音并处理会议
99
128
 
100
129
  ```bash
@@ -360,9 +389,34 @@ ot roster list --project <project-id>
360
389
  ot work summary --project <project-id>
361
390
  ot digest get --project <project-id> --window this_week
362
391
  ot context get --project <project-id> --query "本周风险" --section risk
392
+ ot context get --project <project-id> --query "上次回读验收还有什么缺口" --snapshot <snapshot-id>
393
+ # 仅在诊断召回时使用:
394
+ ot context get --project <project-id> --query "上次回读验收还有什么缺口" --trace --include-candidates
363
395
  ot context item meeting <meeting-id> --project <project-id>
364
396
  ```
365
397
 
398
+ 可重复读取边界:
399
+
400
+ ```bash
401
+ ot goals list --project <project-id> --as-of 2026-07-27T07:28:32Z
402
+ ot todos list --project <project-id> --exclude-tag evaluation-artifact
403
+ ot worklogs list --project <project-id> --exclude-id <worklog-id> --scope team
404
+ ot meetings list --project <project-id> --snapshot <snapshot-id>
405
+ ot knowledge search "关键词" --project <project-id> --snapshot <snapshot-id>
406
+ ```
407
+
408
+ `--exclude-id`、`--exclude-type` 和 `--exclude-tag` 可重复传入。`--snapshot` 与 `--as-of` 或任一排除参数互斥;非法组合会在 CLI 和服务端分别快速失败。服务端 `as_of` 采用保守语义:对象必须在边界前创建且最后更新时间不晚于边界,WorkLog 的 `occurred_at` 也必须不晚于边界。
409
+
410
+ 项目快照:
411
+
412
+ ```bash
413
+ ot projects snapshot export <project-id> --as-of <iso-time> --exclude-tag evaluation-artifact --dry-run
414
+ ot projects snapshot list --project <project-id> --page 1 --page-size 20
415
+ ot projects snapshot show <snapshot-id>
416
+ ```
417
+
418
+ `snapshot export` 会持久化不可变 corpus,因此属于写操作;Agent 必须先展示 dry-run,并在用户确认后才执行正式创建。`show` 会返回完整 manifest/corpus,`list` 只返回摘要。
419
+
366
420
  目标:
367
421
 
368
422
  ```bash
@@ -387,6 +441,8 @@ ot todos submit-review <todo-id> --body-file review-submission.json --dry-run
387
441
  ot todos review <todo-id> --body-file review-decision.json --dry-run
388
442
  ```
389
443
 
444
+ Todo 响应的顶层 typed 字段是当前 canonical 值。机器消费者应读取 `field_schema_version`、`canonical_fields` 和 `metadata_precedence`;`legacy_metadata` 仅保留历史兼容,`superseded_fields` 指明旧 metadata 键已被哪个顶层字段取代。不得把旧 `v0.4` 或 `J0`-`J9` metadata 当成当前验收标准。
445
+
390
446
  知识对象:
391
447
 
392
448
  ```bash
@@ -419,7 +475,7 @@ ot knowledge local update <asset-id> --body-file local-note.json --dry-run
419
475
  ot knowledge local remove <asset-id> --dry-run
420
476
  ```
421
477
 
422
- `ot knowledge search` 返回 `{ query, cloud, local }` 聚合结果;`--cloud-only` 和 `--local-only` 可限定范围。知识对象是可复用的结论、规范、经验、决策沉淀和操作手册。来源文件是知识背后的原文、附件或仓库文档,本地知识索引可脱离 Desktop 使用。
478
+ `ot knowledge search` 返回 `{ query, cloud, local }` 聚合结果;`--cloud-only` 和 `--local-only` 可限定范围。知识对象是可复用的结论、规范、经验、决策沉淀和操作手册。来源文件是知识背后的原文、附件或仓库文档,本地知识索引可脱离 Desktop 使用。显式服务端边界会自动关闭本地聚合;边界参数与 `--local-only` 冲突时命令直接报错。
423
479
 
424
480
  会议:
425
481
 
@@ -445,12 +501,19 @@ ot meetings updates ignore <meeting-id> <update-id> --dry-run
445
501
  工作日志:
446
502
 
447
503
  ```bash
504
+ ot worklogs show <worklog-id>
448
505
  ot worklogs list --today
506
+ ot worklogs list --project <project-id> --to <iso-time> --scope team --page-size 100
507
+ ot worklogs list --project <project-id> --window this_week --event-type progress
449
508
  ot worklogs record --summary "完成 Qoder CLI 接入验证" --type progress --todo <todo-id>
450
509
  ot worklogs record --summary "Codex CLI 登录需要用户确认" --type blocker
451
510
  ot worklogs create --body-file worklog.json --dry-run
452
511
  ```
453
512
 
513
+ `worklogs list` 支持 `--project`、`--from`、`--to`、`--event-type`、`--scope team|managed|mine`、`--page` 和 `--page-size`。`--from` / `--to` 是包含边界,必须是带 `Z` 或 UTC offset 的 ISO 8601 时间。
514
+
515
+ `--today` 等价于 `--window today`。窗口可选 `today`、`yesterday`、`this_week`、`last_week` 和 `this_month`;不传窗口即保持既有全量读取。日历边界按运行 CLI 的系统本地时区计算,再作为 UTC ISO 时间发送给服务端。`--today` / `--window` 不能和显式 `--from` / `--to` 混用。无效窗口、scope、时间和分页值会在请求前报错。
516
+
454
517
  结构化写操作统一支持 `--body-file <json>`,也可用 `--body-file -` 从 stdin 读取。建议先执行同一条命令的 `--dry-run`,确认 `request.path` 和 `request.body` 后再去掉该参数。
455
518
 
456
519
  只读 API 还提供受限逃生口,只允许 `/api/` 下的 GET:
@@ -1,5 +1,18 @@
1
1
  import { ApiClient, listQuery } from '../core/api-client.js';
2
2
  import { collectOption, parseKeyValueOptions, readJsonBody } from '../core/input.js';
3
+ import { addReadBoundaryOptions, addSnapshotPolicyOptions, buildReadBoundaryQuery, buildSnapshotExportBody, } from './read-boundary.js';
4
+ const PROJECT_ACTIVE_TODO_STATUSES = ['draft', 'pending', 'in_progress', 'pending_review', 'failed'];
5
+ function withProjectTodoSummary(project) {
6
+ const byStatus = project.todo_stats?.by_status;
7
+ const projectActiveTodoCount = byStatus
8
+ ? PROJECT_ACTIVE_TODO_STATUSES.reduce((total, status) => total + Number(byStatus[status] || 0), 0)
9
+ : null;
10
+ return {
11
+ ...project,
12
+ active_todo_count_scope: 'current_user',
13
+ project_active_todo_count: projectActiveTodoCount,
14
+ };
15
+ }
3
16
  export function createCollaborationCommands(client = new ApiClient()) {
4
17
  const mutate = async (method, path, body, dryRun = false) => {
5
18
  if (dryRun)
@@ -19,9 +32,10 @@ export function createCollaborationCommands(client = new ApiClient()) {
19
32
  const response = await client.get('/api/projects', {
20
33
  keyword: query,
21
34
  activated_only: false,
35
+ include_todo_stats: true,
22
36
  page_size: 100,
23
37
  });
24
- const items = Array.isArray(response) ? response : response.items || [];
38
+ const items = (Array.isArray(response) ? response : response.items || []).map((item) => withProjectTodoSummary(item));
25
39
  const exact = items.find((item) => item.id === query || String(item.name || '').toLowerCase() === query.toLowerCase());
26
40
  return {
27
41
  resolved: Boolean(exact),
@@ -29,6 +43,9 @@ export function createCollaborationCommands(client = new ApiClient()) {
29
43
  candidates: exact ? [] : items,
30
44
  };
31
45
  },
46
+ listProjectSnapshots: (projectId, options) => client.get(`/api/projects/${encodeURIComponent(projectId)}/snapshots`, listQuery(options)),
47
+ showProjectSnapshot: (snapshotId) => client.get(`/api/projects/snapshots/${encodeURIComponent(snapshotId)}`),
48
+ exportProjectSnapshot: (projectId, options, dryRun = false) => mutate('POST', `/api/projects/${encodeURIComponent(projectId)}/snapshots`, buildSnapshotExportBody(options), dryRun),
32
49
  async listRoster(teamId, projectId) {
33
50
  const team = teamId ? { id: teamId } : await client.get('/api/teams/me');
34
51
  const resolvedTeamId = String(team.id || teamId || '');
@@ -185,6 +202,20 @@ export function registerCollaborationCommands(program, groups, callbacks) {
185
202
  projects
186
203
  .command('resolve <query>')
187
204
  .action(action(async (query) => print(await commands.resolveProject(query))));
205
+ const snapshots = projects.command('snapshot').description('Immutable Project corpus snapshots');
206
+ addSnapshotPolicyOptions(snapshots.command('export <projectId>').option('--dry-run')).action(action(async (projectId, options) => print(await commands.exportProjectSnapshot(projectId, options, Boolean(options.dryRun)))));
207
+ snapshots
208
+ .command('show <snapshotId>')
209
+ .action(action(async (snapshotId) => print(await commands.showProjectSnapshot(snapshotId))));
210
+ snapshots
211
+ .command('list')
212
+ .requiredOption('--project <id>')
213
+ .option('--page <number>', 'page number', Number)
214
+ .option('--page-size <number>', 'items per page', Number)
215
+ .action(action(async (options) => print(await commands.listProjectSnapshots(options.project, {
216
+ page: options.page,
217
+ page_size: options.pageSize,
218
+ }))));
188
219
  program
189
220
  .command('roster')
190
221
  .description('Current team roster')
@@ -220,7 +251,7 @@ export function registerCollaborationCommands(program, groups, callbacks) {
220
251
  .option('--dry-run')
221
252
  .action(action(async (options) => print(await commands.runOrgSyncSynthesis(options, Boolean(options.dryRun)))));
222
253
  const context = program.command('context').description('Viewer-scoped project context');
223
- context
254
+ addReadBoundaryOptions(context
224
255
  .command('get')
225
256
  .option('--project <id>')
226
257
  .option('--goal <id>')
@@ -230,7 +261,8 @@ export function registerCollaborationCommands(program, groups, callbacks) {
230
261
  .option('--match <mode>', 'any|all|exact', 'any')
231
262
  .option('--mode <mode>', 'brief|search', 'brief')
232
263
  .option('--limit <number>', 'maximum context items', Number)
233
- .action(action(async (options) => print(await commands.resolveContext({
264
+ .option('--trace', 'include retrieval accounting')
265
+ .option('--include-candidates', 'include ranked candidate details')).action(action(async (options) => print(await commands.resolveContext({
234
266
  project_id: options.project,
235
267
  goal_id: options.goal,
236
268
  person_id: options.person,
@@ -239,6 +271,9 @@ export function registerCollaborationCommands(program, groups, callbacks) {
239
271
  match_mode: options.match,
240
272
  mode: options.mode,
241
273
  limit: options.limit,
274
+ trace: options.trace,
275
+ include_candidates: options.includeCandidates,
276
+ ...buildReadBoundaryQuery(options),
242
277
  }))));
243
278
  context
244
279
  .command('item <sourceType> <sourceId>')
@@ -275,17 +310,17 @@ export function registerCollaborationCommands(program, groups, callbacks) {
275
310
  .option('--dry-run')
276
311
  .action(action(async (knowledgeId, attachmentId, options) => print(await commands.downloadKnowledgeAttachment(knowledgeId, attachmentId, options.output, Boolean(options.force), Boolean(options.dryRun)))));
277
312
  const meetings = program.command('meetings').description('Meeting upload, minutes, knowledge, and project updates');
278
- meetings
313
+ addReadBoundaryOptions(meetings
279
314
  .command('list')
280
315
  .option('--project <id>')
281
316
  .option('--status <status>')
282
317
  .option('--page <number>', 'page number', Number)
283
- .option('--page-size <number>', 'items per page', Number)
284
- .action(action(async (options) => print(await commands.listMeetings({
318
+ .option('--page-size <number>', 'items per page', Number)).action(action(async (options) => print(await commands.listMeetings({
285
319
  project_id: options.project,
286
320
  status: options.status,
287
321
  page: options.page,
288
322
  page_size: options.pageSize,
323
+ ...buildReadBoundaryQuery(options),
289
324
  }))));
290
325
  meetings
291
326
  .command('show <meetingId>')
@@ -54,10 +54,19 @@ export function createLocalKnowledgeCommands(root = localKnowledgeStoreRoot()) {
54
54
  export async function searchKnowledgeEverywhere(query, options, fetchCloud, root = localKnowledgeStoreRoot()) {
55
55
  if (options.cloudOnly && options.localOnly)
56
56
  throw new Error('Use --cloud-only or --local-only, not both');
57
+ const hasServerBoundary = Boolean(options.asOf ||
58
+ options.snapshot ||
59
+ asStringArray(options.excludeId).length ||
60
+ asStringArray(options.excludeType).length ||
61
+ asStringArray(options.excludeTag).length);
62
+ if (hasServerBoundary && options.localOnly) {
63
+ throw new Error('Server read-boundary options cannot be combined with --local-only because the local index has no matching corpus boundary');
64
+ }
65
+ const cloudOnly = Boolean(options.cloudOnly || hasServerBoundary);
57
66
  const local = createLocalKnowledgeCommands(root);
58
67
  const [cloudResult, localResult] = await Promise.all([
59
68
  options.localOnly ? Promise.resolve(null) : fetchCloud(),
60
- options.cloudOnly
69
+ cloudOnly
61
70
  ? Promise.resolve(null)
62
71
  : local.search(query, Number(options.limit) || 20, normalizeMatchMode(options.match)),
63
72
  ]);
@@ -67,6 +76,9 @@ export async function searchKnowledgeEverywhere(query, options, fetchCloud, root
67
76
  local: localResult,
68
77
  };
69
78
  }
79
+ function asStringArray(value) {
80
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string' && Boolean(item)) : [];
81
+ }
70
82
  export function registerLocalKnowledgeCommands(knowledge, callbacks) {
71
83
  const commands = createLocalKnowledgeCommands();
72
84
  const local = knowledge.command('local').description('Local indexed knowledge available without Desktop');
@@ -0,0 +1,80 @@
1
+ import { collectOption } from '../core/input.js';
2
+ const READ_OBJECT_TYPES = ['goal', 'todo', 'work_log', 'meeting', 'knowledge'];
3
+ export function addReadBoundaryOptions(command) {
4
+ return addSnapshotPolicyOptions(command).option('--snapshot <id>', 'read exclusively from an immutable project snapshot');
5
+ }
6
+ export function addSnapshotPolicyOptions(command) {
7
+ return command
8
+ .option('--as-of <iso>', 'server-enforced ISO timestamp with timezone')
9
+ .option('--exclude-id <id>', 'repeatable object ID exclusion', collectOption, [])
10
+ .option('--exclude-type <type>', 'repeatable: goal|todo|work_log|meeting|knowledge', collectOption, [])
11
+ .option('--exclude-tag <tag>', 'repeatable tag exclusion', collectOption, []);
12
+ }
13
+ export function buildReadBoundaryQuery(options) {
14
+ const asOf = optionalIsoTimestamp(options.asOf, '--as-of');
15
+ const excludeId = uniqueStrings(options.excludeId);
16
+ const excludeType = uniqueStrings(options.excludeType);
17
+ const excludeTag = uniqueStrings(options.excludeTag);
18
+ for (const value of excludeType) {
19
+ if (!READ_OBJECT_TYPES.includes(value)) {
20
+ throw new Error(`Invalid --exclude-type "${value}". Expected one of: ${READ_OBJECT_TYPES.join(', ')}`);
21
+ }
22
+ }
23
+ if (options.snapshot && (asOf || excludeId.length || excludeType.length || excludeTag.length)) {
24
+ throw new Error('--snapshot cannot be combined with --as-of or exclusion options');
25
+ }
26
+ return compact({
27
+ as_of: asOf,
28
+ exclude_id: excludeId.length ? excludeId : undefined,
29
+ exclude_type: excludeType.length ? excludeType : undefined,
30
+ exclude_tag: excludeTag.length ? excludeTag : undefined,
31
+ snapshot_id: options.snapshot,
32
+ });
33
+ }
34
+ export function buildSnapshotExportBody(options) {
35
+ if (options.snapshot)
36
+ throw new Error('--snapshot is not valid when exporting a new snapshot');
37
+ const query = buildReadBoundaryQuery(options);
38
+ return compact({
39
+ as_of: query.as_of,
40
+ exclude_id: query.exclude_id,
41
+ exclude_type: query.exclude_type,
42
+ exclude_tag: query.exclude_tag,
43
+ });
44
+ }
45
+ function uniqueStrings(values) {
46
+ return [...new Set((values || []).map((value) => value.trim()).filter(Boolean))];
47
+ }
48
+ function optionalIsoTimestamp(value, name) {
49
+ if (value == null)
50
+ return undefined;
51
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2})$/);
52
+ if (!match)
53
+ throw new Error(`${name} must be an ISO 8601 timestamp with timezone`);
54
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText = '0', , offset] = match;
55
+ const year = Number(yearText);
56
+ const month = Number(monthText);
57
+ const day = Number(dayText);
58
+ const hour = Number(hourText);
59
+ const minute = Number(minuteText);
60
+ const second = Number(secondText);
61
+ const maxDay = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
62
+ const offsetParts = offset === 'Z' ? [0, 0] : offset.slice(1).split(':').map(Number);
63
+ if (year < 1 ||
64
+ month < 1 ||
65
+ month > 12 ||
66
+ day < 1 ||
67
+ day > maxDay ||
68
+ hour > 23 ||
69
+ minute > 59 ||
70
+ second > 59 ||
71
+ offsetParts[0] > 23 ||
72
+ offsetParts[1] > 59 ||
73
+ Number.isNaN(Date.parse(value))) {
74
+ throw new Error(`${name} must be a valid ISO 8601 timestamp with timezone`);
75
+ }
76
+ return value;
77
+ }
78
+ function compact(value) {
79
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item != null && item !== ''));
80
+ }
@@ -3,16 +3,34 @@ import path from 'node:path';
3
3
  import { ApiClient, listQuery } from '../core/api-client.js';
4
4
  import { readAuth } from '../core/auth.js';
5
5
  import { initProjectStore, listEvents } from '../core/store.js';
6
+ import { buildReadBoundaryQuery } from './read-boundary.js';
6
7
  export function createResourceCommands(client = new ApiClient()) {
7
8
  return {
8
9
  async listGoals(options) {
9
- return client.get('/api/goals', listQuery(options));
10
+ return client.get('/api/goals', listQuery({
11
+ project_id: options.project_id || options.project,
12
+ mine: options.mine,
13
+ status: options.status,
14
+ scope: options.scope,
15
+ role: options.role,
16
+ page: options.page,
17
+ page_size: options.pageSize || options.page_size,
18
+ ...buildReadBoundaryQuery(options),
19
+ }));
10
20
  },
11
21
  async showGoal(goalId) {
12
22
  return client.get(`/api/goals/${encodeURIComponent(goalId)}`);
13
23
  },
14
24
  async listTodos(options) {
15
- return client.get('/api/todos', listQuery(options));
25
+ return client.get('/api/todos', listQuery({
26
+ project_id: options.project_id || options.project,
27
+ mine: options.mine,
28
+ status: options.status,
29
+ scope: options.scope,
30
+ page: options.page,
31
+ page_size: options.pageSize || options.page_size,
32
+ ...buildReadBoundaryQuery(options),
33
+ }));
16
34
  },
17
35
  async showTodo(todoId) {
18
36
  return client.get(`/api/todos/${encodeURIComponent(todoId)}`);
@@ -48,6 +66,7 @@ export function createResourceCommands(client = new ApiClient()) {
48
66
  person: options.person,
49
67
  scope: options.scope,
50
68
  limit: options.limit,
69
+ ...buildReadBoundaryQuery(options),
51
70
  }));
52
71
  },
53
72
  async listKnowledgeSources(options) {
@@ -59,6 +78,9 @@ export function createResourceCommands(client = new ApiClient()) {
59
78
  async listWorklogs(options) {
60
79
  return client.get('/api/work-logs', listQuery(options));
61
80
  },
81
+ async showWorklog(workLogId) {
82
+ return client.get(`/api/work-logs/${encodeURIComponent(workLogId)}`);
83
+ },
62
84
  async recordWorklog(summary, type = 'note', references = {}) {
63
85
  return client.post('/api/work-logs', {
64
86
  ...(references.project ? { project_id: references.project } : {}),
@@ -99,6 +121,7 @@ function knowledgeQuery(options) {
99
121
  person: options.person,
100
122
  page: options.page,
101
123
  page_size: options.pageSize,
124
+ ...buildReadBoundaryQuery(options),
102
125
  });
103
126
  }
104
127
  export function draftDailyReport(workspace, options = {}) {
@@ -0,0 +1,118 @@
1
+ const WORKLOG_SCOPES = ['team', 'managed', 'mine'];
2
+ const WORKLOG_WINDOWS = ['today', 'yesterday', 'this_week', 'last_week', 'this_month'];
3
+ export function buildWorklogListQuery(options, now = new Date()) {
4
+ const scope = optionalEnum(options.scope, '--scope', WORKLOG_SCOPES);
5
+ const page = optionalInteger(options.page, '--page', 1);
6
+ const pageSize = optionalInteger(options.pageSize, '--page-size', 1, 100);
7
+ const dateFrom = optionalIsoTimestamp(options.from, '--from');
8
+ const dateTo = optionalIsoTimestamp(options.to, '--to');
9
+ if (options.today && options.window) {
10
+ throw new Error('--today cannot be combined with --window');
11
+ }
12
+ const window = options.today ? 'today' : optionalEnum(options.window, '--window', WORKLOG_WINDOWS);
13
+ if (window && (dateFrom || dateTo)) {
14
+ throw new Error('--today/--window cannot be combined with --from or --to');
15
+ }
16
+ if (dateFrom && dateTo && Date.parse(dateFrom) > Date.parse(dateTo)) {
17
+ throw new Error('--from must be earlier than or equal to --to');
18
+ }
19
+ const windowRange = window ? worklogWindowRange(window, now) : {};
20
+ return compactQuery({
21
+ ...buildReadBoundaryQuery(options),
22
+ project_id: options.project,
23
+ date_from: windowRange.date_from ?? dateFrom,
24
+ date_to: windowRange.date_to ?? dateTo,
25
+ event_type: options.eventType,
26
+ scope,
27
+ page,
28
+ page_size: pageSize,
29
+ });
30
+ }
31
+ export function worklogWindowRange(window, now = new Date()) {
32
+ const year = now.getFullYear();
33
+ const month = now.getMonth();
34
+ const day = now.getDate();
35
+ const today = new Date(year, month, day);
36
+ let start;
37
+ let next;
38
+ if (window === 'today') {
39
+ start = today;
40
+ next = new Date(year, month, day + 1);
41
+ }
42
+ else if (window === 'yesterday') {
43
+ start = new Date(year, month, day - 1);
44
+ next = today;
45
+ }
46
+ else if (window === 'this_week') {
47
+ const mondayOffset = (today.getDay() + 6) % 7;
48
+ start = new Date(year, month, day - mondayOffset);
49
+ next = new Date(year, month, day - mondayOffset + 7);
50
+ }
51
+ else if (window === 'last_week') {
52
+ const mondayOffset = (today.getDay() + 6) % 7;
53
+ start = new Date(year, month, day - mondayOffset - 7);
54
+ next = new Date(year, month, day - mondayOffset);
55
+ }
56
+ else {
57
+ start = new Date(year, month, 1);
58
+ next = new Date(year, month + 1, 1);
59
+ }
60
+ return {
61
+ date_from: start.toISOString(),
62
+ date_to: inclusiveMicrosecondBefore(next),
63
+ };
64
+ }
65
+ function optionalIsoTimestamp(value, name) {
66
+ if (value == null)
67
+ return undefined;
68
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2})$/);
69
+ if (!match)
70
+ throw new Error(`${name} must be an ISO 8601 timestamp with timezone`);
71
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText = '0', , offset] = match;
72
+ const year = Number(yearText);
73
+ const month = Number(monthText);
74
+ const day = Number(dayText);
75
+ const hour = Number(hourText);
76
+ const minute = Number(minuteText);
77
+ const second = Number(secondText);
78
+ const maxDay = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
79
+ const offsetParts = offset === 'Z' ? [0, 0] : offset.slice(1).split(':').map(Number);
80
+ if (year < 1 ||
81
+ month < 1 ||
82
+ month > 12 ||
83
+ day < 1 ||
84
+ day > maxDay ||
85
+ hour > 23 ||
86
+ minute > 59 ||
87
+ second > 59 ||
88
+ offsetParts[0] > 23 ||
89
+ offsetParts[1] > 59 ||
90
+ Number.isNaN(Date.parse(value))) {
91
+ throw new Error(`${name} must be a valid ISO 8601 timestamp with timezone`);
92
+ }
93
+ return value;
94
+ }
95
+ function optionalInteger(value, name, minimum, maximum) {
96
+ if (value == null)
97
+ return undefined;
98
+ if (!Number.isInteger(value) || value < minimum || (maximum != null && value > maximum)) {
99
+ const range = maximum == null ? `at least ${minimum}` : `between ${minimum} and ${maximum}`;
100
+ throw new Error(`${name} must be an integer ${range}`);
101
+ }
102
+ return value;
103
+ }
104
+ function optionalEnum(value, name, allowed) {
105
+ if (value == null)
106
+ return undefined;
107
+ if (!allowed.includes(value)) {
108
+ throw new Error(`Invalid ${name} "${value}". Expected one of: ${allowed.join(', ')}`);
109
+ }
110
+ return value;
111
+ }
112
+ function compactQuery(query) {
113
+ return Object.fromEntries(Object.entries(query).filter(([, value]) => value != null && value !== ''));
114
+ }
115
+ function inclusiveMicrosecondBefore(value) {
116
+ return new Date(value.getTime() - 1).toISOString().replace(/\.999Z$/, '.999999Z');
117
+ }
118
+ import { buildReadBoundaryQuery } from './read-boundary.js';
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ import { Command } from 'commander';
4
4
  import { registerCollaborationCommands } from './commands/collaboration.js';
5
5
  import { registerLocalKnowledgeCommands, searchKnowledgeEverywhere } from './commands/local-knowledge.js';
6
6
  import { doctor, draftDailyReport, createResourceCommands } from './commands/resources.js';
7
+ import { addReadBoundaryOptions } from './commands/read-boundary.js';
8
+ import { buildWorklogListQuery } from './commands/worklogs.js';
7
9
  import { ApiClient, ApiError } from './core/api-client.js';
8
10
  import { scheduleAutomaticUpdate, updateCli } from './core/auto-update.js';
9
11
  import { clearAuth, readAuth, redactToken, writeAuth } from './core/auth.js';
@@ -152,25 +154,23 @@ program
152
154
  }));
153
155
  const resources = createResourceCommands();
154
156
  const goals = program.command('goals');
155
- goals
156
- .command('list')
157
- .option('--project <id>')
158
- .option('--mine')
159
- .option('--status <status>')
160
- .option('--json')
161
- .action(action(async (options) => print(await resources.listGoals({ project_id: options.project, mine: options.mine, status: options.status }), options.json)));
157
+ addReadBoundaryOptions(goals.command('list').option('--project <id>').option('--mine').option('--status <status>').option('--json')).action(action(async (options) => print(await resources.listGoals({
158
+ project_id: options.project,
159
+ mine: options.mine,
160
+ status: options.status,
161
+ ...readBoundaryOptions(options),
162
+ }), options.json)));
162
163
  goals
163
164
  .command('show <goalId>')
164
165
  .option('--json')
165
166
  .action(action(async (goalId, options) => print(await resources.showGoal(goalId), options.json)));
166
167
  const todos = program.command('todos');
167
- todos
168
- .command('list')
169
- .option('--project <id>')
170
- .option('--mine')
171
- .option('--status <status>')
172
- .option('--json')
173
- .action(action(async (options) => print(await resources.listTodos({ project_id: options.project, mine: options.mine, status: options.status }), options.json)));
168
+ addReadBoundaryOptions(todos.command('list').option('--project <id>').option('--mine').option('--status <status>').option('--json')).action(action(async (options) => print(await resources.listTodos({
169
+ project_id: options.project,
170
+ mine: options.mine,
171
+ status: options.status,
172
+ ...readBoundaryOptions(options),
173
+ }), options.json)));
174
174
  todos
175
175
  .command('show <todoId>')
176
176
  .option('--json')
@@ -212,7 +212,7 @@ todos
212
212
  print(await resources.updateTodoProgress(todoId, options.message));
213
213
  }));
214
214
  const knowledge = program.command('knowledge');
215
- knowledge
215
+ addReadBoundaryOptions(knowledge
216
216
  .command('list')
217
217
  .option('--project <id>')
218
218
  .option('--q <keyword>')
@@ -222,13 +222,12 @@ knowledge
222
222
  .option('--person <name-or-id>')
223
223
  .option('--page <number>', 'page number', Number)
224
224
  .option('--page-size <number>', 'items per page', Number)
225
- .option('--json')
226
- .action(action(async (options) => print(await resources.listKnowledge(options), options.json)));
225
+ .option('--json')).action(action(async (options) => print(await resources.listKnowledge(options), options.json)));
227
226
  knowledge
228
227
  .command('show <knowledgeId>')
229
228
  .option('--json')
230
229
  .action(action(async (knowledgeId, options) => print(await resources.showKnowledge(knowledgeId), options.json)));
231
- knowledge
230
+ addReadBoundaryOptions(knowledge
232
231
  .command('search <keyword>')
233
232
  .option('--project <id>')
234
233
  .option('--person <name-or-id>')
@@ -237,8 +236,7 @@ knowledge
237
236
  .option('--match <mode>', 'local match mode: any|all|exact', 'any')
238
237
  .option('--cloud-only')
239
238
  .option('--local-only')
240
- .option('--json')
241
- .action(action(async (keyword, options) => print(await searchKnowledgeEverywhere(keyword, options, () => resources.searchKnowledge(keyword, options)), options.json)));
239
+ .option('--json')).action(action(async (keyword, options) => print(await searchKnowledgeEverywhere(keyword, options, () => resources.searchKnowledge(keyword, options)), options.json)));
242
240
  knowledge
243
241
  .command('sources')
244
242
  .option('--path <path>')
@@ -269,12 +267,22 @@ program
269
267
  project: options.project,
270
268
  }), options.json)));
271
269
  const worklogs = program.command('worklogs');
272
- worklogs
270
+ addReadBoundaryOptions(worklogs
273
271
  .command('list')
274
- .option('--today')
275
- .option('--window <window>')
272
+ .option('--project <id>')
273
+ .option('--from <iso>', 'inclusive ISO timestamp with timezone')
274
+ .option('--to <iso>', 'inclusive ISO timestamp with timezone')
275
+ .option('--event-type <type>')
276
+ .option('--scope <scope>', 'team|managed|mine')
277
+ .option('--page <number>', 'page number', Number)
278
+ .option('--page-size <number>', 'items per page (1-100)', Number)
279
+ .option('--today', 'current local calendar day')
280
+ .option('--window <window>', 'today|yesterday|this_week|last_week|this_month')
281
+ .option('--json')).action(action(async (options) => print(await resources.listWorklogs(buildWorklogListQuery(options)), options.json)));
282
+ worklogs
283
+ .command('show <workLogId>')
276
284
  .option('--json')
277
- .action(action(async (options) => print(await resources.listWorklogs(options), options.json)));
285
+ .action(action(async (workLogId, options) => print(await resources.showWorklog(workLogId), options.json)));
278
286
  worklogs
279
287
  .command('record')
280
288
  .option('--summary <text>')
@@ -412,6 +420,15 @@ function requireOption(value, name) {
412
420
  throw new Error(`Missing required option ${name}`);
413
421
  }
414
422
  }
423
+ function readBoundaryOptions(options) {
424
+ return {
425
+ asOf: options.asOf,
426
+ excludeId: options.excludeId,
427
+ excludeType: options.excludeType,
428
+ excludeTag: options.excludeTag,
429
+ snapshot: options.snapshot,
430
+ };
431
+ }
415
432
  function expandTargets(target) {
416
433
  if (target === 'all')
417
434
  return ['claude', 'codex', 'cursor', 'qoder', 'kiro'];
@@ -25,7 +25,8 @@ export function installCursor(options) {
25
25
  '---',
26
26
  '',
27
27
  'Run OpenTurtle operations with the direct `ot ... --json` CLI. Do not use an OpenTurtle MCP server or install OpenTurtle hooks.',
28
- 'Start with `command -v ot` and `ot --json doctor`; if authentication is missing, ask the user to run `ot auth login --web`.',
28
+ 'On first use in a new execution environment, run `command -v ot` and `ot --json doctor`; do not repeat these checks in the same task.',
29
+ 'For ordinary project synthesis, use OpenTurtle API data as the sole authority, start with one aggregated `ot context get` call using natural business-language queries, and allow at most one targeted follow-up. Never prime the request with project-specific tracking codes. Do not inspect repository files, Git state, or local agent memory. Before answering, remove tracking keys from prose and source labels; keep only machine IDs in citation or audit fields. New object titles and summaries must use natural business language, with tracking keys stored separately.',
29
30
  'Before a write, run the same command with `--dry-run` and inspect the request path and body.',
30
31
  'Todo quality: include source context, scope/environment, concrete steps, measurable acceptance criteria, and evidence. Ask one concise question when required information is unclear.',
31
32
  '',
@@ -41,7 +42,8 @@ export function installKiro(options) {
41
42
  '# OpenTurtle CLI',
42
43
  '',
43
44
  'Run OpenTurtle operations with the direct `ot ... --json` CLI. Do not use an OpenTurtle MCP server or install OpenTurtle hooks.',
44
- 'Start with `command -v ot` and `ot --json doctor`; if authentication is missing, ask the user to run `ot auth login --web`.',
45
+ 'On first use in a new execution environment, run `command -v ot` and `ot --json doctor`; do not repeat these checks in the same task.',
46
+ 'For ordinary project synthesis, use OpenTurtle API data as the sole authority, start with one aggregated `ot context get` call using natural business-language queries, and allow at most one targeted follow-up. Never prime the request with project-specific tracking codes. Do not inspect repository files, Git state, or local agent memory. Before answering, remove tracking keys from prose and source labels; keep only machine IDs in citation or audit fields. New object titles and summaries must use natural business language, with tracking keys stored separately.',
45
47
  'Before a write, run the same command with `--dry-run` and inspect the request path and body.',
46
48
  'Todo quality: include source context, scope/environment, concrete steps, measurable acceptance criteria, and evidence. Ask one concise question when required information is unclear.',
47
49
  '',
@@ -135,6 +137,7 @@ function appendAgentsSnippet(workspace) {
135
137
  '',
136
138
  'Use the direct `ot ... --json` CLI for OpenTurtle projects, goals, todos, knowledge, meetings, and worklogs. Do not use an OpenTurtle MCP server or install OpenTurtle hooks.',
137
139
  'Run `ot --json doctor` when authentication or server reachability is uncertain, and use `--dry-run` before writes.',
140
+ 'For ordinary project synthesis, use OpenTurtle API data as the sole authority, use one aggregated `ot context get` call with natural business-language queries, and allow at most one targeted follow-up. Never prime the request with project-specific tracking codes. Do not inspect repository files, Git state, or local agent memory. Before answering, remove tracking keys from prose and source labels; keep only machine IDs in citation or audit fields. New object titles and summaries must use natural business language, with tracking keys stored separately.',
138
141
  'Todo quality: include source context, scope/environment, concrete steps, measurable acceptance criteria, and evidence. Ask one concise question when required information is unclear.',
139
142
  '',
140
143
  '<!-- /OPENTURTLE-CLI -->',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openturtle/cli",
3
- "version": "0.3.7",
3
+ "version": "0.4.1",
4
4
  "description": "OpenTurtle collaboration, knowledge, meeting, and agent CLI",
5
5
  "keywords": [
6
6
  "openturtle",
@@ -11,7 +11,7 @@ Desktop 会在隐藏系统上下文中注入已启用 Connector 的命令、能
11
11
 
12
12
  用户要求新增外部服务时,先准备符合 `schemaVersion: 1` 的 manifest,再调用 `openturtle-desktop` 的 `propose_connector`。该调用只提交待审批提案;用户必须在“扩展 > 连接器”批准,安装和写操作仍需单独确认。不得直接修改 Desktop 配置或 Connector 目录。
13
13
 
14
- 开始前运行:
14
+ 每个新执行环境首次使用前运行;当前任务已经验证过后不要重复:
15
15
 
16
16
  ```bash
17
17
  command -v ot
@@ -20,15 +20,49 @@ ot --json doctor
20
20
 
21
21
  缺少登录态时让用户运行 `ot auth login --web`,不要索取或输出 token。Desktop 内置 Agent 会自动复用 Desktop 当前登录态、团队和项目上下文;独立终端使用 CLI 自己的登录配置。
22
22
 
23
+ 只读 OpenTurtle 业务问题以 OpenTurtle API 返回的 Project Context 和业务对象为唯一权威来源,不读取仓库源码、`CLAUDE.md`、Git 状态、其他本地项目文件或 Codex/Claude 的历史 Memory。只有用户明确要求结合本地历史,或询问代码并要求修改时,才进入对应本地资料流程。Skill 已给出可用命令时不要预先调用 `--help`,只在命令真实报参数错误后查询一次对应帮助。
24
+
25
+ ## 聚合优先与读取预算
26
+
27
+ 普通用户询问项目现状、决策、工作包、会议结论、风险或下一步时,默认测试的是服务端 Project Context 聚合能力:
28
+
29
+ 1. 已知项目和语料边界时,直接执行一次 `context get`。把用户问题拆成 2-6 个自然业务短句,用可重复的 `--query` 一次提交;默认 `--limit 20`,不要先用更大 limit 再缩小重跑。不得把内部实验编号、工作包代号或机器 ID 当成用户必须知道的检索口令,也不要在系统提示或示例中枚举当前项目的实际代号。内部追踪键只放在来源或审计字段;标题包含追踪键时,正文略去该前缀并使用自然业务名称。
30
+ 2. 普通综合问答最多执行 2 次 OpenTurtle 业务读取。第二次只能是一次定向 `context get` 或一个已知 ID 的 `show`,不能再展开全量 `goals/todos/worklogs/knowledge/meetings list` 查询链。
31
+ 3. 两次读取后仍缺信息时,明确写“未召回”及缺失项并停止。不要为了让答案看起来完整而继续搜索。
32
+ 4. 普通回答禁止使用 `--trace`、`--include-candidates`、全量对象列表和全量 WorkLog。`trace` 只用于用户明确要求审计召回或诊断缺失;一次诊断只运行一个 trace 查询。
33
+ 5. 机器 ID 在命令和来源中必须完整复制,不得截断、缩写或凭记忆重写。聚合结果已经覆盖用户问题时立即作答。
34
+ 6. 普通项目综合问答以 60 秒内完成为目标。项目解析完成后优先只做一次 Context 读取;只有缺失事实会实质改变答案时才做第二次定向读取,不为扩写背景或增加篇幅补查。
35
+ 7. 作答前做一次展示层检查:除来源中的机器 ID 外,标题、段落、小标题、列表和来源标签都不得出现仅用于追踪的编号或代号。原始标题或摘要带追踪前缀时,按对象类型和业务内容改述;无法确定自然名称时使用对象类型加内容摘要,不得原样复制追踪键。
36
+
37
+ ```bash
38
+ ot context get --project <id> --snapshot <snapshot-id> \
39
+ --query "当前目标和成功标准" \
40
+ --query "正在推进哪些工作,目前分别是什么状态" \
41
+ --query "最近一次会议讨论了什么,哪些问题尚未决定" \
42
+ --query "当前正式决策和验证路径" \
43
+ --query "最近验收结果和遗留缺口" \
44
+ --limit 20
45
+ ```
46
+
23
47
  ## 读取流程
24
48
 
25
- 1. `ot projects list` 或 `ot projects resolve <query>` 定位项目。
26
- 2. `ot roster list --project <id>` 解析成员;先匹配 canonical `display_name`,再参考 aliases 和项目角色,重名或无法唯一匹配时询问用户。
27
- 3. `ot context get --project <id> --query <term>` 获取项目上下文;复杂问题拆成少量明确查询词。
28
- 4. 按对象读取:`ot work summary`、`ot goals list/show`、`ot todos list/show`、`ot knowledge list/show/search`、`ot knowledge local list/search/read`、`ot meetings list/show/transcript/minutes/participants`、`ot worklogs list`。
49
+ 1. 项目未知时优先用 `ot projects resolve <query>` 定位;已有项目 ID 或唯一的当前项目时不要再 list/resolve。
50
+ `active_todo_count` 是当前用户相关的活跃待办,项目整体进度使用 `project_active_todo_count` `todo_stats`,不得把个人数字表述为项目总数。
51
+ 2. 只有问题涉及具体人员时才用 `ot roster list --project <id>` 解析成员;先匹配 canonical `display_name`,再参考 aliases 和项目角色,重名或无法唯一匹配时询问用户。
52
+ 3. 用一次 `ot context get --project <id> --query <term>` 获取项目上下文;复杂问题用同一命令的多个 `--query`,遵守上面的读取预算。
53
+ 4. 聚合结果缺少一个关键事实时,第二次读取可按已知对象使用 `ot goals/todos/knowledge/meetings/worklogs show`,或执行一次更精确的 Context 查询。对象 list/search 只用于用户明确要求浏览集合,不用于普通综合问答补全。
29
54
  5. 记录 Todo 执行进展时优先使用 `ot worklogs record --summary "..." --type progress --todo <todo-id>`,可同时传 `--project` / `--goal`,不要只把关联 ID 塞进 metadata。
30
55
  6. 高层命令缺失时才使用只读逃生口 `ot request get /api/...`。
31
56
 
57
+ 需要可重复语料边界时,优先让服务端执行,不要先全量读取再用 `jq` 猜边界:
58
+
59
+ ```bash
60
+ ot context get --project <id> --query <term> --as-of <iso> --exclude-tag evaluation-artifact
61
+ ot worklogs list --project <id> --snapshot <snapshot-id> --scope team
62
+ ```
63
+
64
+ `--exclude-id`、`--exclude-type`、`--exclude-tag` 可重复传入。`--snapshot` 与 `--as-of` 或任一排除参数互斥,非法组合必须报错。显式边界下 `knowledge search` 只使用云端语料;边界参数不能与 `--local-only` 混用。
65
+
32
66
  ## 写入规则
33
67
 
34
68
  - 所有结构化写入使用 `--body-file <json>` 或 `--body-file -`,避免 shell 转义损坏内容。
@@ -36,6 +70,7 @@ ot --json doctor
36
70
  - 创建目标、Todo、正式决策/风险、提交审核、审核通过/驳回、确认会议项目更新前,必须展示明确草稿和影响。
37
71
  - 项目归属只能来自用户明确选择,或受管 Goal/Meeting 唯一解析出的项目;无法唯一确定时停止写入并询问。
38
72
  - Knowledge 保存可长期复用的结论和完整会议纪要;短进展、决策、风险和产物引用写入 WorkLog。原始文件是 Knowledge 的来源材料,不是第二套知识系统。
73
+ - 新建或更新 Goal、Todo、WorkLog、Knowledge 和 Meeting Update 时,面向用户的 `title`、`summary`、正文和验收描述必须使用自然业务语言。需要内部追踪键时放入 metadata 或关系字段,不得把它作为标题前缀或要求用户在后续提问中使用。
39
74
 
40
75
  ## Todo 与 Goal
41
76
 
@@ -43,6 +78,8 @@ Todo 草稿至少包含来源、范围/环境、2-5 个行动步骤、可判断
43
78
 
44
79
  `ot goals` 管理 OpenTurtle 团队/项目目标。当前 Agent 会话的 Goal 使用 Provider 官方 `/goal` 命令;不要将会话 Goal 写入 OpenTurtle 项目目标,也不要尝试同步两者状态。
45
80
 
81
+ 读取 Todo 时以顶层 typed 字段为 canonical。`field_schema_version`、`canonical_fields` 和 `metadata_precedence` 是机器契约;`legacy_metadata` 只用于审计历史,`superseded_fields` 标明旧键的替代字段。不得把旧 `v0.4` 或 `J0`-`J9` metadata 当成当前验收标准。
82
+
46
83
  ```bash
47
84
  ot goals create --body-file goal.json --dry-run
48
85
  ot todos create --body-file todo.json --dry-run
@@ -51,6 +88,30 @@ ot todos submit-review <todo-id> --body-file review.json --dry-run
51
88
  ot todos review <todo-id> --body-file decision.json --dry-run
52
89
  ```
53
90
 
91
+ ## WorkLog
92
+
93
+ 优先使用结构化读命令,不要用全量 `worklogs list` 后在客户端猜项目或时间范围。读取单条记录用 `ot worklogs show <worklog-id>`;项目历史或快照前数据使用服务端过滤:
94
+
95
+ ```bash
96
+ ot worklogs list --project <project-id> --to <iso-time> --scope team --page-size 100
97
+ ot worklogs list --project <project-id> --window this_week --event-type progress
98
+ ```
99
+
100
+ `--today` 等价于 `--window today`。日历窗口按运行 CLI 的系统本地时区计算;精确截止时间必须使用带时区的 `--from` / `--to`。窗口参数不能与显式时间边界混用,无效窗口、scope、时间和分页值会直接报错。
101
+
102
+ ## Project Snapshot 与 Context Trace
103
+
104
+ 项目快照是持久化写操作,必须先 dry-run 并经用户确认;列表和读取是只读:
105
+
106
+ ```bash
107
+ ot projects snapshot export <project-id> --as-of <iso> --exclude-tag evaluation-artifact --dry-run
108
+ ot projects snapshot list --project <project-id>
109
+ ot projects snapshot show <snapshot-id>
110
+ ot context get --project <project-id> --query <term> --snapshot <snapshot-id>
111
+ ```
112
+
113
+ 快照建立后不可更新或删除。普通业务问答直接使用给定 snapshot ID,不要先 `show/list` 快照。只有服务端明确返回 snapshot 无效时才核对一次完整 ID 和快照列表。诊断召回时可给单个明确查询加 `--trace --include-candidates`;trace 返回候选数、选中/遗漏数、rank、score、匹配原因和截断信息,不能把一次召回当成稳定修复。
114
+
54
115
  ## Knowledge
55
116
 
56
117
  保存前展示标题、摘要、正文范围、Project/Goal、来源 URL、附件和上下文引用。只有用户明确选择并确认的结论才能沉淀;不得自动上传或持久化 Desktop 既有会话历史。