@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 +61 -1
- package/dist/commands/collaboration.js +27 -5
- package/dist/commands/local-knowledge.js +13 -1
- package/dist/commands/read-boundary.js +80 -0
- package/dist/commands/resources.js +25 -2
- package/dist/commands/worklogs.js +118 -0
- package/dist/index.js +41 -24
- package/package.json +1 -1
- package/skill/openturtle-cli/SKILL.md +37 -2
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
|
-
.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
.
|
|
157
|
-
.
|
|
158
|
-
.
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
.
|
|
169
|
-
.
|
|
170
|
-
.
|
|
171
|
-
|
|
172
|
-
|
|
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('--
|
|
275
|
-
.option('--
|
|
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.
|
|
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
|
@@ -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 既有会话历史。
|