@openturtle/cli 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { ApiClient, listQuery } from '../core/api-client.js';
2
2
  import { collectOption, parseKeyValueOptions, readJsonBody } from '../core/input.js';
3
3
  import { addReadBoundaryOptions, addSnapshotPolicyOptions, buildReadBoundaryQuery, buildSnapshotExportBody, } from './read-boundary.js';
4
+ import { DECISION_CREATE_SCHEMA, DECISION_UPDATE_SCHEMA, GOAL_CREATE_SCHEMA, GOAL_UPDATE_SCHEMA, KNOWLEDGE_SAVE_SCHEMA, TODO_CREATE_SCHEMA, TODO_UPDATE_SCHEMA, WORKLOG_CREATE_SCHEMA, formatSchema, validateBody, } from './schemas.js';
4
5
  const PROJECT_ACTIVE_TODO_STATUSES = ['draft', 'pending', 'in_progress', 'pending_review', 'failed'];
5
6
  function withProjectTodoSummary(project) {
6
7
  const byStatus = project.todo_stats?.by_status;
@@ -125,6 +126,17 @@ export function createCollaborationCommands(client = new ApiClient()) {
125
126
  getContextItem: (sourceType, sourceId, options) => client.get(`/api/project-context/items/${encodeURIComponent(sourceType)}/${encodeURIComponent(sourceId)}`, listQuery(options)),
126
127
  relations: (knowledgeId) => client.get(`/api/memory/objects/${encodeURIComponent(knowledgeId)}/relations`),
127
128
  saveKnowledge: (knowledgeId, body, dryRun = false) => mutate(knowledgeId ? 'PATCH' : 'POST', knowledgeId ? `/api/memory/objects/${encodeURIComponent(knowledgeId)}` : '/api/memory/objects', body, dryRun),
129
+ listDecisions: (options) => client.get('/api/memory/objects', listQuery({
130
+ type: 'knowledge',
131
+ kind: 'decision',
132
+ project_id: options.project_id || options.project,
133
+ q: options.q,
134
+ space: options.space,
135
+ page: options.page,
136
+ page_size: options.pageSize || options.page_size,
137
+ ...buildReadBoundaryQuery(options),
138
+ })),
139
+ showDecision: (decisionId) => client.get(`/api/memory/objects/${encodeURIComponent(decisionId)}`),
128
140
  attachKnowledge: async (knowledgeId, files, dryRun = false) => {
129
141
  const path = `/api/memory/objects/${encodeURIComponent(knowledgeId)}/attachments`;
130
142
  if (dryRun)
@@ -292,9 +304,46 @@ export function registerCollaborationCommands(program, groups, callbacks) {
292
304
  .action(action(async (knowledgeId) => print(await commands.relations(knowledgeId))));
293
305
  groups.knowledge
294
306
  .command('save [knowledgeId]')
295
- .requiredOption('--body-file <path>', 'JSON body file, or - for stdin')
307
+ .option('--body-file <path>', 'JSON body file, or - for stdin')
296
308
  .option('--dry-run')
297
- .action(action(async (knowledgeId, options) => print(await commands.saveKnowledge(knowledgeId, await readJsonBody(options.bodyFile), Boolean(options.dryRun)))));
309
+ .option('--show-schema', 'print the request body schema and exit')
310
+ .description(`保存知识 (type=knowledge)。无 id = POST 新建; 带 id = PATCH 更新 (可改 content/metadata_/status/kind, merge 语义)。\n注意 body.content 是对象 (如 {title, body, summary}), 不是字符串——title/body 必须放 content 里, 放顶层会丢。\nFields: ${KNOWLEDGE_SAVE_SCHEMA.fields.map((f) => f.name).join(', ')}\nRequired (create only): kind, content\n推进 status 用 review 子命令, 或 save <id> --body-file '{"status":"validated"}'\n(use --show-schema for full detail)`)
311
+ .action(action(async (knowledgeId, options) => {
312
+ if (options.showSchema) {
313
+ print(formatSchema(KNOWLEDGE_SAVE_SCHEMA));
314
+ return;
315
+ }
316
+ const body = await readJsonBody(options.bodyFile);
317
+ const dryRun = Boolean(options.dryRun);
318
+ if (dryRun) {
319
+ const echoed = await commands.saveKnowledge(knowledgeId, body, true);
320
+ const report = validateBody(KNOWLEDGE_SAVE_SCHEMA, body, !knowledgeId);
321
+ print({
322
+ ...(echoed && typeof echoed === 'object' ? echoed : { result: echoed }),
323
+ validation: report.errors.length
324
+ ? { valid: false, errors: report.errors, warnings: report.warnings }
325
+ : { valid: true, warnings: report.warnings },
326
+ preview: previewMutation(KNOWLEDGE_SAVE_SCHEMA, body, knowledgeId ? { knowledgeId } : {}),
327
+ });
328
+ if (report.errors.length)
329
+ process.exit(1);
330
+ return;
331
+ }
332
+ print(await commands.saveKnowledge(knowledgeId, body, dryRun));
333
+ }));
334
+ groups.knowledge
335
+ .command('review <knowledgeId>')
336
+ .description('推进知识状态 (draft→validated); 等价 save <id> --body-file {"status":...}; knowledge 无独立审核流')
337
+ .option('--status <status>', '目标状态 (draft/validated/...)')
338
+ .option('--approve', '便捷: 等价 --status validated')
339
+ .option('--dry-run')
340
+ .action(action(async (knowledgeId, options) => {
341
+ const status = options.approve ? 'validated' : options.status;
342
+ if (!status) {
343
+ throw new Error('Use --status <status> (e.g. validated) or --approve');
344
+ }
345
+ print(await commands.saveKnowledge(knowledgeId, { status }, Boolean(options.dryRun)));
346
+ }));
298
347
  groups.knowledge
299
348
  .command('attach <knowledgeId> <files...>')
300
349
  .option('--dry-run')
@@ -309,6 +358,30 @@ export function registerCollaborationCommands(program, groups, callbacks) {
309
358
  .option('--force')
310
359
  .option('--dry-run')
311
360
  .action(action(async (knowledgeId, attachmentId, options) => print(await commands.downloadKnowledgeAttachment(knowledgeId, attachmentId, options.output, Boolean(options.force), Boolean(options.dryRun)))));
361
+ const decisions = program
362
+ .command('decisions')
363
+ .description('可检索的团队决策 (knowledge kind=decision; 会议 decision/risk 候选物化为 WorkLog)');
364
+ addReadBoundaryOptions(decisions
365
+ .command('list')
366
+ .option('--project <id>')
367
+ .option('--q <keyword>')
368
+ .option('--space <space>', 'personal|team')
369
+ .option('--page <number>', 'page number', Number)
370
+ .option('--page-size <number>', 'items per page', Number)
371
+ .option('--json')).action(action(async (options) => print(await commands.listDecisions({
372
+ project: options.project,
373
+ q: options.q,
374
+ space: options.space,
375
+ page: options.page,
376
+ pageSize: options.pageSize,
377
+ ...buildReadBoundaryQuery(options),
378
+ }), options.json)));
379
+ decisions
380
+ .command('show <decisionId>')
381
+ .option('--json')
382
+ .action(action(async (decisionId, options) => print(await commands.showDecision(decisionId), options.json)));
383
+ registerJsonMutation(decisions, 'create', [], (args, body, dryRun) => commands.saveKnowledge(undefined, withDecisionKind(body), dryRun), action, print, DECISION_CREATE_SCHEMA);
384
+ registerJsonMutation(decisions, 'update <decisionId>', ['decisionId'], (args, body, dryRun) => commands.saveKnowledge(args.decisionId, withDecisionKind(body), dryRun), action, print, DECISION_UPDATE_SCHEMA);
312
385
  const meetings = program.command('meetings').description('Meeting upload, minutes, knowledge, and project updates');
313
386
  addReadBoundaryOptions(meetings
314
387
  .command('list')
@@ -383,12 +456,12 @@ export function registerCollaborationCommands(program, groups, callbacks) {
383
456
  .command('ignore <meetingId> <updateId>')
384
457
  .option('--dry-run')
385
458
  .action(action(async (meetingId, updateId, options) => print(await commands.ignoreProjectUpdate(meetingId, updateId, Boolean(options.dryRun)))));
386
- registerJsonMutation(groups.goals, 'create', [], (args, body, dryRun) => commands.createGoal(body, dryRun), action, print);
387
- registerJsonMutation(groups.goals, 'update <goalId>', ['goalId'], (args, body, dryRun) => commands.updateGoal(args.goalId, body, dryRun), action, print);
459
+ registerJsonMutation(groups.goals, 'create', [], (args, body, dryRun) => commands.createGoal(body, dryRun), action, print, GOAL_CREATE_SCHEMA);
460
+ registerJsonMutation(groups.goals, 'update <goalId>', ['goalId'], (args, body, dryRun) => commands.updateGoal(args.goalId, body, dryRun), action, print, GOAL_UPDATE_SCHEMA);
388
461
  registerJsonMutation(groups.goals, 'submit-review <goalId>', ['goalId'], (args, body, dryRun) => commands.submitGoalReview(args.goalId, body, dryRun), action, print);
389
462
  registerJsonMutation(groups.goals, 'review <goalId>', ['goalId'], (args, body, dryRun) => commands.reviewGoal(args.goalId, body, dryRun), action, print);
390
- registerJsonMutation(groups.todos, 'create', [], (args, body, dryRun) => commands.createTodo(body, dryRun), action, print);
391
- registerJsonMutation(groups.todos, 'update <todoId>', ['todoId'], (args, body, dryRun) => commands.updateTodo(args.todoId, body, dryRun), action, print);
463
+ registerJsonMutation(groups.todos, 'create', [], (args, body, dryRun) => commands.createTodo(body, dryRun), action, print, TODO_CREATE_SCHEMA);
464
+ registerJsonMutation(groups.todos, 'update <todoId>', ['todoId'], (args, body, dryRun) => commands.updateTodo(args.todoId, body, dryRun), action, print, TODO_UPDATE_SCHEMA);
392
465
  registerJsonMutation(groups.todos, 'submit-review <todoId>', ['todoId'], (args, body, dryRun) => commands.submitTodoReview(args.todoId, body, dryRun), action, print);
393
466
  registerJsonMutation(groups.todos, 'review <todoId>', ['todoId'], (args, body, dryRun) => commands.reviewTodo(args.todoId, body, dryRun), action, print);
394
467
  groups.todos
@@ -399,7 +472,7 @@ export function registerCollaborationCommands(program, groups, callbacks) {
399
472
  .command('assign <todoId> <assigneeId>')
400
473
  .option('--dry-run')
401
474
  .action(action(async (todoId, assigneeId, options) => print(await commands.assignTodo(todoId, assigneeId, Boolean(options.dryRun)))));
402
- registerJsonMutation(groups.worklogs, 'create', [], (args, body, dryRun) => commands.createWorklog(body, dryRun), action, print);
475
+ registerJsonMutation(groups.worklogs, 'create', [], (args, body, dryRun) => commands.createWorklog(body, dryRun), action, print, WORKLOG_CREATE_SCHEMA);
403
476
  program
404
477
  .command('request')
405
478
  .description('Read-only raw API escape hatch')
@@ -411,17 +484,54 @@ export function registerCollaborationCommands(program, groups, callbacks) {
411
484
  print(await commands.requestGet(path, parseKeyValueOptions(options.query)));
412
485
  }));
413
486
  }
414
- function registerJsonMutation(parent, name, argumentNames, execute, action, print) {
487
+ function registerJsonMutation(parent, name, argumentNames, execute, action, print, schema) {
415
488
  const command = parent
416
489
  .command(name)
417
- .requiredOption('--body-file <path>', 'JSON body file, or - for stdin')
418
- .option('--dry-run');
490
+ .option('--body-file <path>', 'JSON body file, or - for stdin')
491
+ .option('--dry-run')
492
+ .option('--show-schema', 'print the request body schema and exit');
493
+ if (schema) {
494
+ const required = schema.fields.filter((f) => f.required).map((f) => f.name).join(', ');
495
+ command.description(`${schema.description}\n\nFields: ${schema.fields.map((f) => f.name).join(', ')}\nRequired: ${required || '(none)'}\n(use --show-schema for full detail and example)`);
496
+ }
419
497
  command.action(action(async (...values) => {
420
498
  const options = values[argumentNames.length];
421
499
  const args = Object.fromEntries(argumentNames.map((argument, index) => [argument, String(values[index])]));
422
- print(await execute(args, await readJsonBody(options.bodyFile), Boolean(options.dryRun)));
500
+ if (options.showSchema && schema) {
501
+ print(formatSchema(schema));
502
+ return;
503
+ }
504
+ const body = await readJsonBody(options.bodyFile);
505
+ const dryRun = Boolean(options.dryRun);
506
+ if (schema && dryRun) {
507
+ const echoed = await execute(args, body, true);
508
+ const report = validateBody(schema, body);
509
+ print({
510
+ ...(echoed && typeof echoed === 'object' ? echoed : { result: echoed }),
511
+ validation: report.errors.length
512
+ ? { valid: false, errors: report.errors, warnings: report.warnings }
513
+ : { valid: true, warnings: report.warnings },
514
+ preview: previewMutation(schema, body, args),
515
+ });
516
+ if (report.errors.length)
517
+ process.exit(1);
518
+ return;
519
+ }
520
+ print(await execute(args, body, dryRun));
423
521
  }));
424
522
  }
523
+ /** Best-effort, human-readable preview of what a mutation will create/update. */
524
+ function previewMutation(schema, body, args) {
525
+ const label = body.title ?? body.summary ?? body.what ?? body.name;
526
+ const summary = `${schema.method === 'POST' ? 'create' : 'update'} ${schema.description.split(' — ')[1] || ''}`.trim();
527
+ return {
528
+ will: summary,
529
+ ...(typeof label === 'string' && label ? { label } : {}),
530
+ ...(body.kind ? { kind: body.kind } : {}),
531
+ ...(Object.keys(args).length ? { target: args } : {}),
532
+ provided_fields: Object.keys(body),
533
+ };
534
+ }
425
535
  function dryRunRequest(method, path, body) {
426
536
  return {
427
537
  dry_run: true,
@@ -439,3 +549,7 @@ function dryRunDownload(path, output, force) {
439
549
  output: { path: output || null, force },
440
550
  };
441
551
  }
552
+ /** Pin a knowledge body to the decision contract so `ot decisions create/update` always stores kind=decision. */
553
+ export function withDecisionKind(body) {
554
+ return { ...body, type: 'knowledge', kind: 'decision' };
555
+ }
@@ -0,0 +1,224 @@
1
+ /** Body schemas for ot CLI write commands, aligned with backend pydantic models.
2
+ *
3
+ * These are intentionally hand-mirrored from the backend request schemas so the CLI
4
+ * can surface required fields and validate dry-run bodies without a network round-trip.
5
+ * Keep in sync with:
6
+ * - packages/backend/.../common/schemas/goal.py (GoalCreate / GoalUpdate)
7
+ * - packages/backend/.../common/schemas/todo.py (TodoCreate / TodoUpdate)
8
+ * - packages/backend/.../common/schemas/work_log.py (WorkLogCreate)
9
+ * - packages/backend/.../common/schemas/memory.py (MemoryObjectCreate / RememberRequest)
10
+ */
11
+ const GOAL_STATUS_ENUM = ['active', 'paused', 'completed', 'cancelled'];
12
+ export const GOAL_CREATE_SCHEMA = {
13
+ description: 'POST /api/goals — 创建目标',
14
+ method: 'POST',
15
+ fields: [
16
+ { name: 'title', type: 'string', required: true, description: '目标标题 (1-256)' },
17
+ { name: 'success_criteria', type: 'string', required: true, description: '成功标准 (1-4096)' },
18
+ { name: 'owner_ids', type: 'string[]', required: true, description: '负责人用户 id 列表 (至少 1 个, ≤20)' },
19
+ { name: 'description', type: 'string', default: '', description: '目标描述' },
20
+ { name: 'manager_ids', type: 'string[]', description: '管理者 id (≤20)' },
21
+ { name: 'collaborator_ids', type: 'string[]', description: '协作者 id (≤100)' },
22
+ { name: 'linked_project_id', type: 'string', description: '关联项目 id' },
23
+ { name: 'status', type: 'enum', enum: GOAL_STATUS_ENUM, default: 'active', description: '初始状态' },
24
+ ],
25
+ example: {
26
+ title: 'Q3 交付 XX 能力',
27
+ success_criteria: '灰度上线并达成 N 个团队采用',
28
+ owner_ids: ['<user-id>'],
29
+ linked_project_id: '<project-id>',
30
+ },
31
+ };
32
+ export const GOAL_UPDATE_SCHEMA = {
33
+ description: 'PATCH /api/goals/{id} — 更新目标 (未提供字段不改)',
34
+ method: 'PATCH',
35
+ fields: [
36
+ { name: 'title', type: 'string', description: '新标题 (1-256)' },
37
+ { name: 'description', type: 'string', description: '新描述' },
38
+ { name: 'success_criteria', type: 'string', description: '新成功标准 (1-4096)' },
39
+ { name: 'owner_ids', type: 'string[]', description: '负责人 id (≥1, ≤20)' },
40
+ { name: 'manager_ids', type: 'string[]', description: '管理者 id (≤20)' },
41
+ { name: 'collaborator_ids', type: 'string[]', description: '协作者 id (≤100)' },
42
+ { name: 'linked_project_id', type: 'string', description: '关联项目 id' },
43
+ { name: 'status', type: 'enum', enum: GOAL_STATUS_ENUM, description: '状态' },
44
+ { name: 'progress_description', type: 'string', description: '进度描述' },
45
+ { name: 'risk_level', type: 'enum', enum: ['low', 'medium', 'high'], description: '风险等级' },
46
+ ],
47
+ example: { status: 'paused', progress_description: '等待依赖到位' },
48
+ };
49
+ const TODO_ASSIGNEE_TYPE_ENUM = ['human', 'agent', 'agent_with_skill'];
50
+ export const TODO_CREATE_SCHEMA = {
51
+ description: 'POST /api/todos — 创建待办',
52
+ method: 'POST',
53
+ fields: [
54
+ { name: 'title', type: 'string', required: true, description: '待办标题 (1-256)' },
55
+ { name: 'project_id', type: 'string', description: '关联项目 id' },
56
+ { name: 'goal_id', type: 'string', description: '关联目标 id' },
57
+ { name: 'task_id', type: 'string', description: '关联 Task id' },
58
+ { name: 'assignee_id', type: 'string', description: '主负责人 id (兼容旧客户端)' },
59
+ { name: 'assignee_ids', type: 'string[]', description: '全部负责人 id' },
60
+ { name: 'description', type: 'string', default: '', description: '详细描述' },
61
+ { name: 'due_date', type: 'datetime', description: '截止时间 (YYYY-MM-DD 或 ISO)' },
62
+ { name: 'assignee_type', type: 'enum', enum: TODO_ASSIGNEE_TYPE_ENUM, description: '指派类型' },
63
+ { name: 'skill_ids', type: 'string[]', description: 'Skill id 列表' },
64
+ { name: 'depends_on', type: 'string[]', description: '前置依赖 Todo id' },
65
+ { name: 'reviewer_ids', type: 'string[]', description: '评审人 id (提供则 review_required 自动 true)' },
66
+ { name: 'acceptance_criteria', type: 'string', description: '验收标准' },
67
+ { name: 'evidence_required', type: 'string', description: '需要的证据说明' },
68
+ { name: 'metadata', type: 'object', description: '扩展元数据' },
69
+ ],
70
+ example: {
71
+ title: '完成 XX 接口联调',
72
+ project_id: '<project-id>',
73
+ assignee_ids: ['<user-id>'],
74
+ due_date: '2026-08-15',
75
+ },
76
+ };
77
+ export const TODO_UPDATE_SCHEMA = {
78
+ description: 'PATCH /api/todos/{id} — 更新待办 (未提供字段不改; 显式 null 清除 due_date)',
79
+ method: 'PATCH',
80
+ fields: [
81
+ { name: 'title', type: 'string', description: '新标题 (1-256)' },
82
+ { name: 'description', type: 'string', description: '新描述 (空串清空)' },
83
+ { name: 'project_id', type: 'string', description: '重新归属项目 (null 清除)' },
84
+ { name: 'goal_id', type: 'string', description: '重新归属目标 (null 清除)' },
85
+ { name: 'due_date', type: 'datetime', description: '截止时间 (null 清除)' },
86
+ { name: 'assignee_id', type: 'string', description: '重新指派' },
87
+ { name: 'assignee_ids', type: 'string[]', description: '全部负责人 id' },
88
+ { name: 'assignee_type', type: 'enum', enum: TODO_ASSIGNEE_TYPE_ENUM, description: '指派类型' },
89
+ { name: 'execution_result', type: 'object', description: '执行结果 (agent 产出 / human 提交)' },
90
+ { name: 'reviewer_ids', type: 'string[]', description: '评审人 id' },
91
+ { name: 'review_required', type: 'boolean', description: '是否需要评审' },
92
+ { name: 'acceptance_criteria', type: 'string', description: '验收标准' },
93
+ { name: 'evidence_required', type: 'string', description: '需要的证据说明' },
94
+ { name: 'metadata', type: 'object', description: '扩展元数据' },
95
+ ],
96
+ example: { due_date: '2026-08-20', description: '联调完成, 待验收' },
97
+ };
98
+ const WORKLOG_EVENT_TYPE_ENUM = ['progress', 'blocker', 'decision', 'commit', 'note', 'meeting'];
99
+ const WORKLOG_SOURCE_ENUM = ['manual', 'agent', 'system', 'git'];
100
+ export const WORKLOG_CREATE_SCHEMA = {
101
+ description: 'POST /api/work-logs — 录入工作日志 (便捷写法见 ot worklogs record)',
102
+ method: 'POST',
103
+ fields: [
104
+ { name: 'summary', type: 'string', required: true, description: '摘要 (1-4096)' },
105
+ { name: 'event_type', type: 'enum', enum: WORKLOG_EVENT_TYPE_ENUM, required: true, description: '事件类型' },
106
+ { name: 'source', type: 'enum', enum: WORKLOG_SOURCE_ENUM, required: true, description: '来源' },
107
+ { name: 'project_id', type: 'string', description: '关联项目 id' },
108
+ { name: 'goal_id', type: 'string', description: '关联目标 id' },
109
+ { name: 'todo_id', type: 'string', description: '关联待办 id' },
110
+ { name: 'task_id', type: 'string', description: '关联 Task id' },
111
+ { name: 'occurred_at', type: 'datetime', description: '发生时间 (默认现在)' },
112
+ { name: 'changed_files', type: 'string[]', description: '变更文件列表' },
113
+ { name: 'diff_stat', type: 'string', description: 'diff 摘要' },
114
+ { name: 'metadata', type: 'object', description: '扩展元数据' },
115
+ ],
116
+ example: {
117
+ summary: '完成 XX 模块联调',
118
+ event_type: 'progress',
119
+ source: 'manual',
120
+ project_id: '<project-id>',
121
+ },
122
+ };
123
+ const KNOWLEDGE_KIND_ENUM = ['note', 'decision', 'fact', 'meeting'];
124
+ export const KNOWLEDGE_SAVE_SCHEMA = {
125
+ description: 'POST/PATCH /api/memory/objects — 创建/更新知识 (type=knowledge)',
126
+ method: 'POST',
127
+ fields: [
128
+ { name: 'kind', type: 'enum', enum: KNOWLEDGE_KIND_ENUM, required: true, description: '知识细分类' },
129
+ { name: 'content', type: 'object', required: true, description: '主体内容, 建议结构 {title, body, summary?, ...}' },
130
+ { name: 'scope', type: 'string', default: 'org', description: '作用域 (org/project/case/...)' },
131
+ { name: 'status', type: 'string', default: 'draft', description: '状态 (draft/validated/...)' },
132
+ { name: 'project_id', type: 'string', description: '关联项目 id' },
133
+ { name: 'space', type: 'enum', enum: ['team', 'personal'], default: 'team', description: '可见空间' },
134
+ { name: 'metadata_', type: 'object', description: '元数据' },
135
+ { name: 'refs', type: 'object', description: '关联实体 [{target_type, target_id}]' },
136
+ ],
137
+ example: {
138
+ kind: 'note',
139
+ content: { title: '溯源接口在测试环境会 404', body: '根因是 nginx 未代理该路径' },
140
+ },
141
+ };
142
+ /** Decision-oriented knowledge body (kind=decision). Used by `ot decisions create`. */
143
+ export const DECISION_CREATE_SCHEMA = {
144
+ description: 'POST /api/memory/objects (kind=decision) — 沉淀一条可检索的决策',
145
+ method: 'POST',
146
+ fields: [
147
+ { name: 'kind', type: 'enum', enum: ['decision'], required: true, default: 'decision', description: '固定 decision' },
148
+ {
149
+ name: 'content',
150
+ type: 'object',
151
+ required: true,
152
+ description: '决策内容: {title, body, why?, how_to_apply?, due_date?}',
153
+ },
154
+ { name: 'scope', type: 'string', default: 'org', description: '作用域' },
155
+ { name: 'project_id', type: 'string', description: '关联项目 id' },
156
+ { name: 'space', type: 'enum', enum: ['team', 'personal'], default: 'team', description: '可见空间' },
157
+ { name: 'refs', type: 'object', description: '关联实体 [{target_type, target_id}]' },
158
+ ],
159
+ example: {
160
+ kind: 'decision',
161
+ content: {
162
+ title: '统一用 ot decisions 沉淀团队决策',
163
+ body: '会议 decision/risk 候选 confirm 后物化为 WorkLog; 正式决策另存 kind=decision',
164
+ why: '避免决策散落在聊天记录里无法检索',
165
+ how_to_apply: '会后用 ot decisions create 写入, 带项目归属',
166
+ },
167
+ },
168
+ };
169
+ export const DECISION_UPDATE_SCHEMA = {
170
+ description: 'PATCH /api/memory/objects/{id} (kind=decision) — 更新决策 (content merge 语义)',
171
+ method: 'PATCH',
172
+ fields: [
173
+ { name: 'content', type: 'object', description: '合并到 content 的字段 {title?, body?, why?, how_to_apply?, due_date?}' },
174
+ { name: 'metadata_', type: 'object', description: '合并到 metadata 的字段' },
175
+ { name: 'status', type: 'string', description: '状态 (draft/validated/...)' },
176
+ ],
177
+ example: { content: { title: '修订后的决策标题', how_to_apply: '更新后的执行方式' } },
178
+ };
179
+ export const SCHEMAS = {
180
+ 'goals create': GOAL_CREATE_SCHEMA,
181
+ 'goals update': GOAL_UPDATE_SCHEMA,
182
+ 'todos create': TODO_CREATE_SCHEMA,
183
+ 'todos update': TODO_UPDATE_SCHEMA,
184
+ 'worklogs create': WORKLOG_CREATE_SCHEMA,
185
+ 'knowledge save': KNOWLEDGE_SAVE_SCHEMA,
186
+ 'decisions create': DECISION_CREATE_SCHEMA,
187
+ 'decisions update': DECISION_UPDATE_SCHEMA,
188
+ };
189
+ /** Validate a request body against a schema (required fields + unknown-field hints).
190
+ *
191
+ * Set `strict=false` for endpoints that double as create+update (e.g. knowledge save):
192
+ * required-field checks are skipped so a partial update body isn't flagged.
193
+ */
194
+ export function validateBody(schema, body, strict = true) {
195
+ const errors = [];
196
+ const warnings = [];
197
+ const known = new Set(schema.fields.map((f) => f.name));
198
+ if (strict) {
199
+ for (const field of schema.fields) {
200
+ if (field.required && !(field.name in body)) {
201
+ errors.push(`missing required field: ${field.name} (${field.type})`);
202
+ }
203
+ }
204
+ }
205
+ for (const key of Object.keys(body)) {
206
+ if (!known.has(key)) {
207
+ warnings.push(`unknown field "${key}" — not in schema (may still be accepted by backend)`);
208
+ }
209
+ }
210
+ return { errors, warnings };
211
+ }
212
+ /** Render a schema as a human-readable block (for --show-schema / --help). */
213
+ export function formatSchema(schema) {
214
+ const lines = [schema.description, ''];
215
+ lines.push('Fields:');
216
+ for (const f of schema.fields) {
217
+ const req = f.required ? ' (required)' : '';
218
+ const def = f.default !== undefined ? ` [default: ${f.default}]` : '';
219
+ const en = f.enum ? ` {${f.enum.join('|')}}` : '';
220
+ lines.push(` ${f.name}: ${f.type}${en}${req}${def} — ${f.description}`);
221
+ }
222
+ lines.push('', 'Example:', JSON.stringify(schema.example, null, 2));
223
+ return lines.join('\n');
224
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import nodePath from 'node:path';
3
- import { readAuth, writeAuth } from './auth.js';
3
+ import { jwtExpiresAt, readAuth, writeAuth } from './auth.js';
4
4
  import { DEFAULT_SERVER_URL } from './defaults.js';
5
5
  export class ApiError extends Error {
6
6
  method;
@@ -17,20 +17,21 @@ export class ApiError extends Error {
17
17
  this.name = 'ApiError';
18
18
  }
19
19
  }
20
+ const REFRESH_LEEWAY_MS = 60_000;
20
21
  export class ApiClient {
21
22
  options;
22
23
  constructor(options = {}) {
23
24
  this.options = options;
24
25
  }
25
26
  async request(method, path, body, query) {
26
- const { url, token } = this.requestContext(path, query);
27
- const response = await this.fetchResponse(method, url, {
27
+ const buildInit = (token) => ({
28
28
  headers: {
29
29
  ...(body == null ? {} : { 'content-type': 'application/json' }),
30
30
  ...(token ? { authorization: `Bearer ${token}` } : {}),
31
31
  },
32
32
  ...(body == null ? {} : { body: JSON.stringify(body) }),
33
33
  });
34
+ const { url, response } = await this.authenticatedFetch(method, path, query, buildInit);
34
35
  return this.parseResponse(method, url, response);
35
36
  }
36
37
  get(path, query) {
@@ -62,18 +63,18 @@ export class ApiClient {
62
63
  const bytes = fs.readFileSync(file.filePath);
63
64
  form.append(file.field, new Blob([bytes], { type: contentTypeFor(file.filePath) }), nodePath.basename(file.filePath));
64
65
  }
65
- const { url, token } = this.requestContext(pathname);
66
- const response = await this.fetchResponse(method, url, {
66
+ const buildInit = (token) => ({
67
67
  headers: token ? { authorization: `Bearer ${token}` } : {},
68
68
  body: form,
69
69
  });
70
+ const { url, response } = await this.authenticatedFetch(method, pathname, undefined, buildInit);
70
71
  return this.parseResponse(method, url, response);
71
72
  }
72
73
  async download(pathname, outputPath, force = false) {
73
- const { url, token } = this.requestContext(pathname);
74
- const response = await this.fetchResponse('GET', url, {
74
+ const buildInit = (token) => ({
75
75
  headers: token ? { authorization: `Bearer ${token}` } : {},
76
76
  });
77
+ const { url, response } = await this.authenticatedFetch('GET', pathname, undefined, buildInit);
77
78
  if (!response.ok)
78
79
  return this.parseResponse('GET', url, response);
79
80
  const filename = responseFilename(response) || 'download';
@@ -135,9 +136,86 @@ export class ApiClient {
135
136
  }
136
137
  return { url, token, server };
137
138
  }
139
+ async authenticatedFetch(method, pathname, query, buildInit) {
140
+ const { url, server } = this.requestContext(pathname, query);
141
+ let auth = readAuth(this.options.homeDir);
142
+ let token = this.options.token || auth.token;
143
+ // An explicit token (option or env override) is never refreshed: there is
144
+ // no stored credential to rotate and nothing safe to persist.
145
+ const canRefresh = !this.options.token && !this.tokenFromEnv() && Boolean(auth.refreshToken);
146
+ // Proactive refresh: avoid starting a request with a token that is about
147
+ // to expire (long-running commands would otherwise hit 401 mid-run).
148
+ if (canRefresh && this.tokenExpiringSoon(token)) {
149
+ if ((await this.tryRefreshToken(server)) === 'refreshed') {
150
+ auth = readAuth(this.options.homeDir);
151
+ token = auth.token;
152
+ }
153
+ }
154
+ let response = await this.fetchResponse(method, url, buildInit(token));
155
+ // Reactive refresh: on 401, rotate the stored token once and retry.
156
+ if (response.status === 401 && canRefresh) {
157
+ const outcome = await this.tryRefreshToken(server);
158
+ if (outcome === 'refreshed') {
159
+ const retried = readAuth(this.options.homeDir);
160
+ response = await this.fetchResponse(method, url, buildInit(retried.token));
161
+ }
162
+ else if (outcome === 'invalid') {
163
+ throw new ApiError(`${method} ${url.pathname} failed: 401 access token expired and the stored refresh token is no longer valid. Run \`ot auth login --web\` to sign in again.`, method, url.pathname, 401);
164
+ }
165
+ }
166
+ return { url, response };
167
+ }
168
+ async tryRefreshToken(server) {
169
+ const auth = readAuth(this.options.homeDir);
170
+ if (!auth.refreshToken)
171
+ return 'error';
172
+ const url = resolveApiUrl(server, '/api/auth/refresh');
173
+ let response;
174
+ try {
175
+ response = await this.fetchImpl()(url.toString(), {
176
+ method: 'POST',
177
+ headers: { 'content-type': 'application/json' },
178
+ body: JSON.stringify({ refresh_token: auth.refreshToken }),
179
+ });
180
+ }
181
+ catch {
182
+ // Network-level failure: keep the stored tokens, surface the original error.
183
+ return 'error';
184
+ }
185
+ let payload = {};
186
+ if (response.ok) {
187
+ payload = (await response.json().catch(() => ({})));
188
+ }
189
+ if (!response.ok || !payload.access_token) {
190
+ // The refresh token is dead too: drop the dead tokens so subsequent
191
+ // commands fail fast with a clear "please log in" state.
192
+ const { token: _token, refreshToken: _refreshToken, ...rest } = auth;
193
+ writeAuth(rest, this.options.homeDir);
194
+ return 'invalid';
195
+ }
196
+ writeAuth({
197
+ ...auth,
198
+ token: payload.access_token,
199
+ refreshToken: payload.refresh_token || auth.refreshToken,
200
+ }, this.options.homeDir);
201
+ return 'refreshed';
202
+ }
203
+ tokenExpiringSoon(token) {
204
+ const expiresAt = jwtExpiresAt(token);
205
+ if (expiresAt == null)
206
+ return false;
207
+ const now = (this.options.now || Date.now)();
208
+ return expiresAt * 1000 - now < REFRESH_LEEWAY_MS;
209
+ }
210
+ tokenFromEnv() {
211
+ return Boolean(process.env.OPENTURTLE_API_TOKEN || process.env.OPENTURTLE_TOKEN);
212
+ }
213
+ fetchImpl() {
214
+ return this.options.fetchImpl || fetch;
215
+ }
138
216
  async fetchResponse(method, url, init) {
139
217
  try {
140
- return await fetch(url, { ...init, method });
218
+ return await this.fetchImpl()(url, { ...init, method });
141
219
  }
142
220
  catch (error) {
143
221
  const detail = error instanceof Error ? error.message : String(error);
package/dist/core/auth.js CHANGED
@@ -28,6 +28,20 @@ export function clearAuth(homeDir = os.homedir()) {
28
28
  if (fs.existsSync(filePath))
29
29
  fs.unlinkSync(filePath);
30
30
  }
31
+ export function jwtExpiresAt(token) {
32
+ if (!token)
33
+ return undefined;
34
+ const parts = token.split('.');
35
+ if (parts.length !== 3)
36
+ return undefined;
37
+ try {
38
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
39
+ return typeof payload.exp === 'number' ? payload.exp : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
31
45
  export function redactToken(token) {
32
46
  if (!token)
33
47
  return undefined;
@@ -0,0 +1,46 @@
1
+ /** Local-timezone helpers for CLI output.
2
+ *
3
+ * The backend stores all timestamps as UTC ISO strings (DateTime(timezone=True)).
4
+ * These helpers add a human-readable, local-tz companion field without mutating the
5
+ * canonical ISO value, so JSON consumers keep a stable contract while humans get a
6
+ * readable time. Uses Node's built-in Intl — no third-party dependency.
7
+ */
8
+ const DEFAULT_TZ = 'Asia/Shanghai';
9
+ /** Format a UTC ISO timestamp as a local-tz string, e.g. "2026-08-15 00:00:00 (Asia/Shanghai)".
10
+ *
11
+ * The 'sv-SE' locale is used because it yields ISO-like "YYYY-MM-DD HH:MM:SS" output,
12
+ * which is far more readable than the default en-US formatting.
13
+ */
14
+ export function formatLocalDateTime(iso, tz = DEFAULT_TZ) {
15
+ const date = new Date(iso);
16
+ if (Number.isNaN(date.getTime()))
17
+ return iso;
18
+ const local = date.toLocaleString('sv-SE', { timeZone: tz, hour12: false });
19
+ return `${local} (${tz})`;
20
+ }
21
+ /** True for full ISO datetime strings (with a `T` and time component), e.g. "2026-08-14T16:00:00Z".
22
+ * Plain dates ("2026-08-15") are intentionally excluded — they are user-supplied calendar
23
+ * dates, not UTC timestamps, and should not be shifted.
24
+ */
25
+ export function isIsoDateTime(value) {
26
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
27
+ }
28
+ /** Recursively walk a value and, for every ISO-datetime field, append a `<field>_local`
29
+ * companion with the local-tz rendering. The original ISO value is preserved.
30
+ */
31
+ export function withLocalTimes(value, tz = DEFAULT_TZ) {
32
+ if (Array.isArray(value)) {
33
+ return value.map((item) => withLocalTimes(item, tz));
34
+ }
35
+ if (value && typeof value === 'object') {
36
+ const out = {};
37
+ for (const [key, raw] of Object.entries(value)) {
38
+ out[key] = withLocalTimes(raw, tz);
39
+ if (isIsoDateTime(raw)) {
40
+ out[`${key}_local`] = formatLocalDateTime(raw, tz);
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+ return value;
46
+ }
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { DEFAULT_SERVER_URL } from './core/defaults.js';
13
13
  import { forgetInstallation, readInstallations, recordInstallation, replaceInstallations, } from './core/installations.js';
14
14
  import { findWorkspaceRoot } from './core/paths.js';
15
15
  import { initProjectStore } from './core/store.js';
16
+ import { withLocalTimes } from './core/time.js';
16
17
  import { cliVersion } from './core/version.js';
17
18
  import { loginWithWeb } from './core/web-auth.js';
18
19
  import { installClaude, installCodex, installCursor, installKiro, installQoder, uninstallClaude, uninstallCodex, uninstallCursor, uninstallKiro, uninstallQoder, } from './installers/agents.js';
@@ -24,6 +25,7 @@ program
24
25
  .description('OpenTurtle collaboration, knowledge, meeting, and local agent CLI')
25
26
  .version(cliVersion());
26
27
  program.option('-j, --json', 'emit machine-readable JSON, including errors');
28
+ program.option('-l, --local-time', '为日期时间字段追加 *_local 本地时区渲染 (默认输出 UTC ISO 不变)');
27
29
  program
28
30
  .command('update')
29
31
  .description('check npm for a newer CLI and install it')
@@ -435,6 +437,9 @@ function expandTargets(target) {
435
437
  return [target];
436
438
  }
437
439
  function print(value, asJson = true) {
440
+ if (program.opts().localTime) {
441
+ value = withLocalTimes(value);
442
+ }
438
443
  if (asJson) {
439
444
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
440
445
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openturtle/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "OpenTurtle collaboration, knowledge, meeting, and agent CLI",
5
5
  "keywords": [
6
6
  "openturtle",
@@ -29,10 +29,11 @@ ot --json doctor
29
29
  1. 已知项目和语料边界时,直接执行一次 `context get`。把用户问题拆成 2-6 个自然业务短句,用可重复的 `--query` 一次提交;默认 `--limit 20`,不要先用更大 limit 再缩小重跑。不得把内部实验编号、工作包代号或机器 ID 当成用户必须知道的检索口令,也不要在系统提示或示例中枚举当前项目的实际代号。内部追踪键只放在来源或审计字段;标题包含追踪键时,正文略去该前缀并使用自然业务名称。
30
30
  2. 普通综合问答最多执行 2 次 OpenTurtle 业务读取。第二次只能是一次定向 `context get` 或一个已知 ID 的 `show`,不能再展开全量 `goals/todos/worklogs/knowledge/meetings list` 查询链。
31
31
  3. 两次读取后仍缺信息时,明确写“未召回”及缺失项并停止。不要为了让答案看起来完整而继续搜索。
32
- 4. 普通回答禁止使用 `--trace`、`--include-candidates`、全量对象列表和全量 WorkLog。`trace` 只用于用户明确要求审计召回或诊断缺失;一次诊断只运行一个 trace 查询。
33
- 5. 机器 ID 在命令和来源中必须完整复制,不得截断、缩写或凭记忆重写。聚合结果已经覆盖用户问题时立即作答。
34
- 6. 普通项目综合问答以 60 秒内完成为目标。项目解析完成后优先只做一次 Context 读取;只有缺失事实会实质改变答案时才做第二次定向读取,不为扩写背景或增加篇幅补查。
35
- 7. 作答前做一次展示层检查:除来源中的机器 ID 外,标题、段落、小标题、列表和来源标签都不得出现仅用于追踪的编号或代号。原始标题或摘要带追踪前缀时,按对象类型和业务内容改述;无法确定自然名称时使用对象类型加内容摘要,不得原样复制追踪键。
32
+ 4. 聚合响应带 `evidence_confidence` 字段,按语义使用,不要把它当成拒答开关:`strong` 正常作答;`weak` 可以作答,但标注证据偏弱;`none` 表示没有强直接证据——先按第 2 条做一次定向补查,仍无强证据时,区分两种情形:问题所问事物明显不存在(语料中没有任何相关记录)时,明确回答“未找到/未召回”并停止;语料中存在弱相关记录时,基于这些记录作答,同时显式列出证据缺口和不确定性,不得编造弱证据不支持的内容。
33
+ 5. 普通回答禁止使用 `--trace`、`--include-candidates`、全量对象列表和全量 WorkLog。`trace` 只用于用户明确要求审计召回或诊断缺失;一次诊断只运行一个 trace 查询。
34
+ 6. 机器 ID 在命令和来源中必须完整复制,不得截断、缩写或凭记忆重写。聚合结果已经覆盖用户问题时立即作答。
35
+ 7. 普通项目综合问答以 60 秒内完成为目标。项目解析完成后优先只做一次 Context 读取;只有缺失事实会实质改变答案时才做第二次定向读取,不为扩写背景或增加篇幅补查。
36
+ 8. 作答前做一次展示层检查:除来源中的机器 ID 外,标题、段落、小标题、列表和来源标签都不得出现仅用于追踪的编号或代号。原始标题或摘要带追踪前缀时,按对象类型和业务内容改述;无法确定自然名称时使用对象类型加内容摘要,不得原样复制追踪键。
36
37
 
37
38
  ```bash
38
39
  ot context get --project <id> --snapshot <snapshot-id> \
@@ -66,7 +67,7 @@ ot worklogs list --project <id> --snapshot <snapshot-id> --scope team
66
67
  ## 写入规则
67
68
 
68
69
  - 所有结构化写入使用 `--body-file <json>` 或 `--body-file -`,避免 shell 转义损坏内容。
69
- - 先执行同一命令的 `--dry-run`,检查 `request.path` `request.body`;用户确认后再正式写入。
70
+ - 先执行同一命令的 `--dry-run`,检查 `request.path`、`request.body`、`validation`(body schema 校验)和 `preview`(将创建/改成什么);用户确认后再正式写入。用 `--show-schema` 查看写命令的 body 字段、必填项和示例。
70
71
  - 创建目标、Todo、正式决策/风险、提交审核、审核通过/驳回、确认会议项目更新前,必须展示明确草稿和影响。
71
72
  - 项目归属只能来自用户明确选择,或受管 Goal/Meeting 唯一解析出的项目;无法唯一确定时停止写入并询问。
72
73
  - Knowledge 保存可长期复用的结论和完整会议纪要;短进展、决策、风险和产物引用写入 WorkLog。原始文件是 Knowledge 的来源材料,不是第二套知识系统。
@@ -127,14 +128,19 @@ ot knowledge attach <knowledge-id> ./artifact.pdf --dry-run
127
128
 
128
129
  `ot knowledge search` 默认聚合云端知识和本地索引;仅查一侧时显式使用 `--cloud-only` 或 `--local-only`。本地知识由 CLI 维护,Desktop 知识库界面复用同一实现和原有数据目录。
129
130
 
131
+ - **content 是对象不是字符串**:`ot knowledge save` 的 body 里 `content` 是对象(如 `{"title":"...","body":"...","summary":"..."}`);`title`/`body` 必须放在 `content` 内,放在 body 顶层会被忽略。无 id = POST 新建;`save <id>` = PATCH 更新(可改 `content`/`metadata_`/`status`/`kind`,merge 语义)。
132
+ - `ot knowledge show <id>` 顶层 `title`/`summary` 已从 content 提升,正文在 `content.body`;团队记忆索引的短 id(前缀)可直接喂给 `show`,多义时后端返回 409。
133
+ - 推进 draft→validated:`ot knowledge review <id> --approve`(等价 `save <id> --body-file '{"status":"validated"}'`;knowledge 无独立审核流,仅状态推进)。
134
+
130
135
  ## Meeting
131
136
 
132
137
  1. 先解析 Project、Roster 和 Project Context。
133
138
  2. 上传录音或读取已有 Meeting;区分事实、讨论、建议、已确认决策和不确定负责人。
134
- 3. 用 `ot meetings publish <meeting-id> --dry-run` 将完整纪要发布为 `kind=meeting` Knowledge
139
+ 3. 用 `ot meetings publish <meeting-id> --dry-run` 将完整纪要发布为 `kind=meeting` Knowledge。**publish 会自动按 kind 物化**项目更新候选(decision/risk→WorkLog,action/research→Todo,负责人自动指派),无需手动创建产物。
135
140
  4. 用 `ot meetings updates list <meeting-id>` 展示 action、research、decision、risk 草稿,允许逐条编辑或忽略。
136
- 5. 只有用户确认具体条目后,才用 `ot meetings updates confirm <meeting-id> <update-id...> --knowledge <knowledge-id> --dry-run` 物化;没有已保存的 meeting Knowledge ID 时不得确认。
141
+ 5. `ot meetings updates confirm <meeting-id> <update-id...> --knowledge <knowledge-id> --dry-run` **幂等确认**:已物化的候选返回 `already_materialized`(不重复创建),仅草稿态首次物化;`--all` 确认全部剩余草稿。没有已保存的 meeting Knowledge ID 时不得确认。
137
142
  6. 同一候选只走一次物化路径,禁止重复创建 Todo 或 WorkLog。
143
+ 7. 会议 decision/risk 候选物化为 WorkLog(进入时间线);如需可检索的正式决策,另用 `ot decisions create` 沉淀为 `kind=decision` Knowledge,目标用 `ot goals`。`ot knowledge show <短id>` 支持团队记忆索引里的短 id 前缀。
138
144
 
139
145
  ## Desktop 边界
140
146