@openturtle/cli 0.3.7 → 0.4.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.
package/README.md CHANGED
@@ -56,6 +56,9 @@ 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 "本周风险" --trace --include-candidates
59
62
  ot worklogs record --summary "完成联调" --type progress --todo <todo-id>
60
63
  ot report draft --today
61
64
  ```
@@ -95,6 +98,30 @@ ot knowledge search "日报" --local-only
95
98
  ot knowledge local search "日报"
96
99
  ```
97
100
 
101
+ 需要可重复的服务端语料边界时,使用带时区的 `--as-of` 或不可变快照:
102
+
103
+ ```bash
104
+ ot knowledge search "日报" --project <project-id> --as-of 2026-07-27T07:28:32Z
105
+ ot context get --project <project-id> --query "E1-X1" --snapshot <snapshot-id> --trace --include-candidates
106
+ ```
107
+
108
+ 使用 `--as-of`、`--snapshot` 或任一排除参数时,聚合搜索只返回服务端云端结果,避免无版本边界的本地索引混入;这类参数不能与 `--local-only` 同时使用。
109
+
110
+ ### 导出不可变项目快照
111
+
112
+ 创建快照是写操作,先检查 dry-run:
113
+
114
+ ```bash
115
+ ot projects snapshot export <project-id> \
116
+ --as-of 2026-07-27T07:28:32Z \
117
+ --exclude-tag evaluation-artifact \
118
+ --dry-run
119
+ ot projects snapshot list --project <project-id>
120
+ ot projects snapshot show <snapshot-id>
121
+ ```
122
+
123
+ 快照包含捕获策略、对象版本时间、逐对象内容哈希和 manifest hash。创建后不能更新或删除。
124
+
98
125
  ### 上传录音并处理会议
99
126
 
100
127
  ```bash
@@ -360,9 +387,33 @@ ot roster list --project <project-id>
360
387
  ot work summary --project <project-id>
361
388
  ot digest get --project <project-id> --window this_week
362
389
  ot context get --project <project-id> --query "本周风险" --section risk
390
+ ot context get --project <project-id> --query "E1-X1" --trace --include-candidates
391
+ ot context get --project <project-id> --query "E1-X1" --snapshot <snapshot-id> --trace
363
392
  ot context item meeting <meeting-id> --project <project-id>
364
393
  ```
365
394
 
395
+ 可重复读取边界:
396
+
397
+ ```bash
398
+ ot goals list --project <project-id> --as-of 2026-07-27T07:28:32Z
399
+ ot todos list --project <project-id> --exclude-tag evaluation-artifact
400
+ ot worklogs list --project <project-id> --exclude-id <worklog-id> --scope team
401
+ ot meetings list --project <project-id> --snapshot <snapshot-id>
402
+ ot knowledge search "关键词" --project <project-id> --snapshot <snapshot-id>
403
+ ```
404
+
405
+ `--exclude-id`、`--exclude-type` 和 `--exclude-tag` 可重复传入。`--snapshot` 与 `--as-of` 或任一排除参数互斥;非法组合会在 CLI 和服务端分别快速失败。服务端 `as_of` 采用保守语义:对象必须在边界前创建且最后更新时间不晚于边界,WorkLog 的 `occurred_at` 也必须不晚于边界。
406
+
407
+ 项目快照:
408
+
409
+ ```bash
410
+ ot projects snapshot export <project-id> --as-of <iso-time> --exclude-tag evaluation-artifact --dry-run
411
+ ot projects snapshot list --project <project-id> --page 1 --page-size 20
412
+ ot projects snapshot show <snapshot-id>
413
+ ```
414
+
415
+ `snapshot export` 会持久化不可变 corpus,因此属于写操作;Agent 必须先展示 dry-run,并在用户确认后才执行正式创建。`show` 会返回完整 manifest/corpus,`list` 只返回摘要。
416
+
366
417
  目标:
367
418
 
368
419
  ```bash
@@ -387,6 +438,8 @@ ot todos submit-review <todo-id> --body-file review-submission.json --dry-run
387
438
  ot todos review <todo-id> --body-file review-decision.json --dry-run
388
439
  ```
389
440
 
441
+ Todo 响应的顶层 typed 字段是当前 canonical 值。机器消费者应读取 `field_schema_version`、`canonical_fields` 和 `metadata_precedence`;`legacy_metadata` 仅保留历史兼容,`superseded_fields` 指明旧 metadata 键已被哪个顶层字段取代。不得把旧 `v0.4` 或 `J0`-`J9` metadata 当成当前验收标准。
442
+
390
443
  知识对象:
391
444
 
392
445
  ```bash
@@ -419,7 +472,7 @@ ot knowledge local update <asset-id> --body-file local-note.json --dry-run
419
472
  ot knowledge local remove <asset-id> --dry-run
420
473
  ```
421
474
 
422
- `ot knowledge search` 返回 `{ query, cloud, local }` 聚合结果;`--cloud-only` 和 `--local-only` 可限定范围。知识对象是可复用的结论、规范、经验、决策沉淀和操作手册。来源文件是知识背后的原文、附件或仓库文档,本地知识索引可脱离 Desktop 使用。
475
+ `ot knowledge search` 返回 `{ query, cloud, local }` 聚合结果;`--cloud-only` 和 `--local-only` 可限定范围。知识对象是可复用的结论、规范、经验、决策沉淀和操作手册。来源文件是知识背后的原文、附件或仓库文档,本地知识索引可脱离 Desktop 使用。显式服务端边界会自动关闭本地聚合;边界参数与 `--local-only` 冲突时命令直接报错。
423
476
 
424
477
  会议:
425
478
 
@@ -445,12 +498,19 @@ ot meetings updates ignore <meeting-id> <update-id> --dry-run
445
498
  工作日志:
446
499
 
447
500
  ```bash
501
+ ot worklogs show <worklog-id>
448
502
  ot worklogs list --today
503
+ ot worklogs list --project <project-id> --to <iso-time> --scope team --page-size 100
504
+ ot worklogs list --project <project-id> --window this_week --event-type progress
449
505
  ot worklogs record --summary "完成 Qoder CLI 接入验证" --type progress --todo <todo-id>
450
506
  ot worklogs record --summary "Codex CLI 登录需要用户确认" --type blocker
451
507
  ot worklogs create --body-file worklog.json --dry-run
452
508
  ```
453
509
 
510
+ `worklogs list` 支持 `--project`、`--from`、`--to`、`--event-type`、`--scope team|managed|mine`、`--page` 和 `--page-size`。`--from` / `--to` 是包含边界,必须是带 `Z` 或 UTC offset 的 ISO 8601 时间。
511
+
512
+ `--today` 等价于 `--window today`。窗口可选 `today`、`yesterday`、`this_week`、`last_week` 和 `this_month`;不传窗口即保持既有全量读取。日历边界按运行 CLI 的系统本地时区计算,再作为 UTC ISO 时间发送给服务端。`--today` / `--window` 不能和显式 `--from` / `--to` 混用。无效窗口、scope、时间和分页值会在请求前报错。
513
+
454
514
  结构化写操作统一支持 `--body-file <json>`,也可用 `--body-file -` 从 stdin 读取。建议先执行同一条命令的 `--dry-run`,确认 `request.path` 和 `request.body` 后再去掉该参数。
455
515
 
456
516
  只读 API 还提供受限逃生口,只允许 `/api/` 下的 GET:
@@ -1,5 +1,6 @@
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';
3
4
  export function createCollaborationCommands(client = new ApiClient()) {
4
5
  const mutate = async (method, path, body, dryRun = false) => {
5
6
  if (dryRun)
@@ -29,6 +30,9 @@ export function createCollaborationCommands(client = new ApiClient()) {
29
30
  candidates: exact ? [] : items,
30
31
  };
31
32
  },
33
+ listProjectSnapshots: (projectId, options) => client.get(`/api/projects/${encodeURIComponent(projectId)}/snapshots`, listQuery(options)),
34
+ showProjectSnapshot: (snapshotId) => client.get(`/api/projects/snapshots/${encodeURIComponent(snapshotId)}`),
35
+ exportProjectSnapshot: (projectId, options, dryRun = false) => mutate('POST', `/api/projects/${encodeURIComponent(projectId)}/snapshots`, buildSnapshotExportBody(options), dryRun),
32
36
  async listRoster(teamId, projectId) {
33
37
  const team = teamId ? { id: teamId } : await client.get('/api/teams/me');
34
38
  const resolvedTeamId = String(team.id || teamId || '');
@@ -185,6 +189,20 @@ export function registerCollaborationCommands(program, groups, callbacks) {
185
189
  projects
186
190
  .command('resolve <query>')
187
191
  .action(action(async (query) => print(await commands.resolveProject(query))));
192
+ const snapshots = projects.command('snapshot').description('Immutable Project corpus snapshots');
193
+ addSnapshotPolicyOptions(snapshots.command('export <projectId>').option('--dry-run')).action(action(async (projectId, options) => print(await commands.exportProjectSnapshot(projectId, options, Boolean(options.dryRun)))));
194
+ snapshots
195
+ .command('show <snapshotId>')
196
+ .action(action(async (snapshotId) => print(await commands.showProjectSnapshot(snapshotId))));
197
+ snapshots
198
+ .command('list')
199
+ .requiredOption('--project <id>')
200
+ .option('--page <number>', 'page number', Number)
201
+ .option('--page-size <number>', 'items per page', Number)
202
+ .action(action(async (options) => print(await commands.listProjectSnapshots(options.project, {
203
+ page: options.page,
204
+ page_size: options.pageSize,
205
+ }))));
188
206
  program
189
207
  .command('roster')
190
208
  .description('Current team roster')
@@ -220,7 +238,7 @@ export function registerCollaborationCommands(program, groups, callbacks) {
220
238
  .option('--dry-run')
221
239
  .action(action(async (options) => print(await commands.runOrgSyncSynthesis(options, Boolean(options.dryRun)))));
222
240
  const context = program.command('context').description('Viewer-scoped project context');
223
- context
241
+ addReadBoundaryOptions(context
224
242
  .command('get')
225
243
  .option('--project <id>')
226
244
  .option('--goal <id>')
@@ -230,7 +248,8 @@ export function registerCollaborationCommands(program, groups, callbacks) {
230
248
  .option('--match <mode>', 'any|all|exact', 'any')
231
249
  .option('--mode <mode>', 'brief|search', 'brief')
232
250
  .option('--limit <number>', 'maximum context items', Number)
233
- .action(action(async (options) => print(await commands.resolveContext({
251
+ .option('--trace', 'include retrieval accounting')
252
+ .option('--include-candidates', 'include ranked candidate details')).action(action(async (options) => print(await commands.resolveContext({
234
253
  project_id: options.project,
235
254
  goal_id: options.goal,
236
255
  person_id: options.person,
@@ -239,6 +258,9 @@ export function registerCollaborationCommands(program, groups, callbacks) {
239
258
  match_mode: options.match,
240
259
  mode: options.mode,
241
260
  limit: options.limit,
261
+ trace: options.trace,
262
+ include_candidates: options.includeCandidates,
263
+ ...buildReadBoundaryQuery(options),
242
264
  }))));
243
265
  context
244
266
  .command('item <sourceType> <sourceId>')
@@ -275,17 +297,17 @@ export function registerCollaborationCommands(program, groups, callbacks) {
275
297
  .option('--dry-run')
276
298
  .action(action(async (knowledgeId, attachmentId, options) => print(await commands.downloadKnowledgeAttachment(knowledgeId, attachmentId, options.output, Boolean(options.force), Boolean(options.dryRun)))));
277
299
  const meetings = program.command('meetings').description('Meeting upload, minutes, knowledge, and project updates');
278
- meetings
300
+ addReadBoundaryOptions(meetings
279
301
  .command('list')
280
302
  .option('--project <id>')
281
303
  .option('--status <status>')
282
304
  .option('--page <number>', 'page number', Number)
283
- .option('--page-size <number>', 'items per page', Number)
284
- .action(action(async (options) => print(await commands.listMeetings({
305
+ .option('--page-size <number>', 'items per page', Number)).action(action(async (options) => print(await commands.listMeetings({
285
306
  project_id: options.project,
286
307
  status: options.status,
287
308
  page: options.page,
288
309
  page_size: options.pageSize,
310
+ ...buildReadBoundaryQuery(options),
289
311
  }))));
290
312
  meetings
291
313
  .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'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openturtle/cli",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "OpenTurtle collaboration, knowledge, meeting, and agent CLI",
5
5
  "keywords": [
6
6
  "openturtle",
@@ -24,11 +24,20 @@ ot --json doctor
24
24
 
25
25
  1. 用 `ot projects list` 或 `ot projects resolve <query>` 定位项目。
26
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`。
27
+ 3. 用 `ot context get --project <id> --query <term>` 获取项目上下文;复杂问题拆成少量明确查询词。需要审计召回时加 `--trace --include-candidates`。
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/show`。
29
29
  5. 记录 Todo 执行进展时优先使用 `ot worklogs record --summary "..." --type progress --todo <todo-id>`,可同时传 `--project` / `--goal`,不要只把关联 ID 塞进 metadata。
30
30
  6. 高层命令缺失时才使用只读逃生口 `ot request get /api/...`。
31
31
 
32
+ 需要可重复语料边界时,优先让服务端执行,不要先全量读取再用 `jq` 猜边界:
33
+
34
+ ```bash
35
+ ot context get --project <id> --query <term> --as-of <iso> --exclude-tag evaluation-artifact --trace
36
+ ot worklogs list --project <id> --snapshot <snapshot-id> --scope team
37
+ ```
38
+
39
+ `--exclude-id`、`--exclude-type`、`--exclude-tag` 可重复传入。`--snapshot` 与 `--as-of` 或任一排除参数互斥,非法组合必须报错。显式边界下 `knowledge search` 只使用云端语料;边界参数不能与 `--local-only` 混用。
40
+
32
41
  ## 写入规则
33
42
 
34
43
  - 所有结构化写入使用 `--body-file <json>` 或 `--body-file -`,避免 shell 转义损坏内容。
@@ -43,6 +52,8 @@ Todo 草稿至少包含来源、范围/环境、2-5 个行动步骤、可判断
43
52
 
44
53
  `ot goals` 管理 OpenTurtle 团队/项目目标。当前 Agent 会话的 Goal 使用 Provider 官方 `/goal` 命令;不要将会话 Goal 写入 OpenTurtle 项目目标,也不要尝试同步两者状态。
45
54
 
55
+ 读取 Todo 时以顶层 typed 字段为 canonical。`field_schema_version`、`canonical_fields` 和 `metadata_precedence` 是机器契约;`legacy_metadata` 只用于审计历史,`superseded_fields` 标明旧键的替代字段。不得把旧 `v0.4` 或 `J0`-`J9` metadata 当成当前验收标准。
56
+
46
57
  ```bash
47
58
  ot goals create --body-file goal.json --dry-run
48
59
  ot todos create --body-file todo.json --dry-run
@@ -51,6 +62,30 @@ ot todos submit-review <todo-id> --body-file review.json --dry-run
51
62
  ot todos review <todo-id> --body-file decision.json --dry-run
52
63
  ```
53
64
 
65
+ ## WorkLog
66
+
67
+ 优先使用结构化读命令,不要用全量 `worklogs list` 后在客户端猜项目或时间范围。读取单条记录用 `ot worklogs show <worklog-id>`;项目历史或快照前数据使用服务端过滤:
68
+
69
+ ```bash
70
+ ot worklogs list --project <project-id> --to <iso-time> --scope team --page-size 100
71
+ ot worklogs list --project <project-id> --window this_week --event-type progress
72
+ ```
73
+
74
+ `--today` 等价于 `--window today`。日历窗口按运行 CLI 的系统本地时区计算;精确截止时间必须使用带时区的 `--from` / `--to`。窗口参数不能与显式时间边界混用,无效窗口、scope、时间和分页值会直接报错。
75
+
76
+ ## Project Snapshot 与 Context Trace
77
+
78
+ 项目快照是持久化写操作,必须先 dry-run 并经用户确认;列表和读取是只读:
79
+
80
+ ```bash
81
+ ot projects snapshot export <project-id> --as-of <iso> --exclude-tag evaluation-artifact --dry-run
82
+ ot projects snapshot list --project <project-id>
83
+ ot projects snapshot show <snapshot-id>
84
+ ot context get --project <project-id> --query <term> --snapshot <snapshot-id> --trace --include-candidates
85
+ ```
86
+
87
+ 快照建立后不可更新或删除。`trace` 返回候选数、选中/遗漏数、rank、score、匹配原因和截断信息;需要判断关系链稳定性时,应使用明确查询词并检查结构化关联理由,不能把一次召回当成稳定修复。
88
+
54
89
  ## Knowledge
55
90
 
56
91
  保存前展示标题、摘要、正文范围、Project/Goal、来源 URL、附件和上下文引用。只有用户明确选择并确认的结论才能沉淀;不得自动上传或持久化 Desktop 既有会话历史。