autosnippet 3.2.2 → 3.2.3

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.
Files changed (32) hide show
  1. package/README.md +23 -0
  2. package/config/default.json +7 -0
  3. package/dashboard/dist/assets/{icons-18VxiaCT.js → icons-pSac4wYO.js} +101 -96
  4. package/dashboard/dist/assets/{index-CRH5Umim.js → index-6itPuGFl.js} +45 -45
  5. package/dashboard/dist/assets/index-DNOHYBhy.css +1 -0
  6. package/dashboard/dist/index.html +3 -3
  7. package/lib/cli/SetupService.js +93 -25
  8. package/lib/domain/knowledge/KnowledgeEntry.js +11 -0
  9. package/lib/domain/task/Task.js +32 -2
  10. package/lib/domain/task/TaskDependency.js +1 -0
  11. package/lib/external/mcp/McpServer.js +180 -6
  12. package/lib/external/mcp/handlers/bootstrap/shared/bootstrap-phases.js +2 -1
  13. package/lib/external/mcp/handlers/decide.js +109 -0
  14. package/lib/external/mcp/handlers/ready.js +42 -0
  15. package/lib/external/mcp/handlers/system.js +12 -0
  16. package/lib/external/mcp/handlers/task.js +7 -19
  17. package/lib/external/mcp/tools.js +83 -42
  18. package/lib/http/routes/knowledge.js +10 -10
  19. package/lib/http/routes/task.js +81 -1
  20. package/lib/http/utils/routeHelpers.js +30 -0
  21. package/lib/repository/task/TaskRepository.impl.js +3 -1
  22. package/lib/service/cursor/AgentInstructionsGenerator.js +6 -4
  23. package/lib/service/knowledge/KnowledgeService.js +12 -1
  24. package/lib/service/task/TaskGraphService.js +243 -3
  25. package/package.json +1 -1
  26. package/skills/autosnippet-intent/SKILL.md +3 -1
  27. package/skills/autosnippet-recipes/SKILL.md +3 -1
  28. package/templates/copilot-instructions.md +47 -14
  29. package/templates/cursor-rules/autosnippet-conventions.mdc +11 -0
  30. package/templates/cursor-rules/autosnippet-workflow.mdc +16 -7
  31. package/templates/guard-ci.yml +21 -0
  32. package/dashboard/dist/assets/index-BJiuaVPD.css +0 -1
@@ -9,7 +9,7 @@ import Logger from '../../infrastructure/logging/Logger.js';
9
9
  import { getServiceContainer } from '../../injection/ServiceContainer.js';
10
10
  import { ValidationError } from '../../shared/errors/index.js';
11
11
  import { asyncHandler } from '../middleware/errorHandler.js';
12
- import { getContext, safeInt } from '../utils/routeHelpers.js';
12
+ import { getContext, safeInt, sanitizeForAPI, sanitizePaginatedForAPI } from '../utils/routeHelpers.js';
13
13
 
14
14
  const _logger = Logger.getInstance();
15
15
  const router = express.Router();
@@ -35,7 +35,7 @@ router.get(
35
35
 
36
36
  if (keyword) {
37
37
  const result = await knowledgeService.search(keyword, { page, pageSize });
38
- return res.json({ success: true, data: result });
38
+ return res.json({ success: true, data: sanitizePaginatedForAPI(result) });
39
39
  }
40
40
 
41
41
  const filters = {};
@@ -65,7 +65,7 @@ router.get(
65
65
  }
66
66
 
67
67
  const result = await knowledgeService.list(filters, { page, pageSize });
68
- res.json({ success: true, data: result });
68
+ res.json({ success: true, data: sanitizePaginatedForAPI(result) });
69
69
  })
70
70
  );
71
71
 
@@ -94,7 +94,7 @@ router.get(
94
94
  const container = getServiceContainer();
95
95
  const knowledgeService = container.get('knowledgeService');
96
96
  const entry = await knowledgeService.get(id);
97
- res.json({ success: true, data: entry.toJSON() });
97
+ res.json({ success: true, data: sanitizeForAPI(entry) });
98
98
  })
99
99
  );
100
100
 
@@ -120,7 +120,7 @@ router.post(
120
120
  const entry = await knowledgeService.create(data, context);
121
121
  res.status(201).json({
122
122
  success: true,
123
- data: entry.toJSON(),
123
+ data: sanitizeForAPI(entry),
124
124
  });
125
125
  })
126
126
  );
@@ -138,7 +138,7 @@ router.patch(
138
138
  const context = getContext(req);
139
139
 
140
140
  const entry = await knowledgeService.update(id, req.body, context);
141
- res.json({ success: true, data: entry.toJSON() });
141
+ res.json({ success: true, data: sanitizeForAPI(entry) });
142
142
  })
143
143
  );
144
144
 
@@ -174,7 +174,7 @@ router.patch(
174
174
  const context = getContext(req);
175
175
 
176
176
  const entry = await knowledgeService.publish(id, context);
177
- res.json({ success: true, data: entry.toJSON() });
177
+ res.json({ success: true, data: sanitizeForAPI(entry) });
178
178
  })
179
179
  );
180
180
 
@@ -197,7 +197,7 @@ router.patch(
197
197
  const context = getContext(req);
198
198
 
199
199
  const entry = await knowledgeService.deprecate(id, reason, context);
200
- res.json({ success: true, data: entry.toJSON() });
200
+ res.json({ success: true, data: sanitizeForAPI(entry) });
201
201
  })
202
202
  );
203
203
 
@@ -214,7 +214,7 @@ router.patch(
214
214
  const context = getContext(req);
215
215
 
216
216
  const entry = await knowledgeService.reactivate(id, context);
217
- res.json({ success: true, data: entry.toJSON() });
217
+ res.json({ success: true, data: sanitizeForAPI(entry) });
218
218
  })
219
219
  );
220
220
 
@@ -245,7 +245,7 @@ router.post(
245
245
  ids.map((id) => knowledgeService.publish(id, context))
246
246
  );
247
247
 
248
- const published = results.filter((r) => r.status === 'fulfilled').map((r) => r.value.toJSON());
248
+ const published = results.filter((r) => r.status === 'fulfilled').map((r) => sanitizeForAPI(r.value));
249
249
  const failed = results
250
250
  .map((r, i) => (r.status === 'rejected' ? { id: ids[i], error: r.reason?.message } : null))
251
251
  .filter(Boolean);
@@ -105,6 +105,12 @@ async function _dispatch(svc, operation, params) {
105
105
  return _depTree(svc, params);
106
106
  case 'stats':
107
107
  return _stats(svc);
108
+ case 'record_decision':
109
+ return _recordDecision(svc, params);
110
+ case 'revise_decision':
111
+ return _reviseDecision(svc, params);
112
+ case 'unpin_decision':
113
+ return _unpinDecision(svc, params);
108
114
  default:
109
115
  return { success: false, message: `Unknown operation: ${operation}` };
110
116
  }
@@ -197,7 +203,24 @@ async function _progress(svc, args) {
197
203
 
198
204
  async function _prime(svc) {
199
205
  const result = await svc.prime({ withKnowledge: true });
200
- return { success: true, data: result };
206
+ const decisionCount = (result.decisions || []).length;
207
+ const staleCount = (result.staleDecisions || []).length;
208
+ const decisionTitles = (result.decisions || []).map((d) => d.title).join('; ');
209
+ const statsLine = `${result.inProgress.length} in-progress, ${result.ready.length} ready, ${result.stats.total} total`;
210
+
211
+ let message;
212
+ if (decisionCount > 0) {
213
+ const stalePart = staleCount > 0 ? ` ${staleCount} stale.` : '';
214
+ message = `⚠️ ${decisionCount} ACTIVE DECISION(S): [${decisionTitles}].${stalePart} ${statsLine}.`;
215
+ } else {
216
+ message = `${statsLine}.`;
217
+ }
218
+
219
+ return {
220
+ success: true,
221
+ data: result,
222
+ message,
223
+ };
201
224
  }
202
225
 
203
226
  // ── decompose ──
@@ -279,4 +302,61 @@ async function _stats(svc) {
279
302
  return { success: true, data: stats };
280
303
  }
281
304
 
305
+ // ── record_decision ──
306
+
307
+ async function _recordDecision(svc, args) {
308
+ if (!args.title) return { success: false, message: 'title is required' };
309
+ if (!args.description) return { success: false, message: 'description is required' };
310
+ const { task, isDuplicate } = await svc.recordDecision({
311
+ title: args.title,
312
+ description: args.description,
313
+ rationale: args.rationale || '',
314
+ tags: args.tags || [],
315
+ relatedTaskId: args.relatedTaskId || null,
316
+ });
317
+ return {
318
+ success: true,
319
+ data: task.toJSON(),
320
+ message: isDuplicate
321
+ ? `Decision already recorded: ${task.id}`
322
+ : `Decision pinned: ${task.id} — "${args.title}"`,
323
+ };
324
+ }
325
+
326
+ // ── revise_decision ──
327
+
328
+ async function _reviseDecision(svc, args) {
329
+ if (!args.id) return { success: false, message: 'id of old decision is required' };
330
+ if (!args.title) return { success: false, message: 'title of new decision is required' };
331
+ if (!args.description)
332
+ return { success: false, message: 'description of new decision is required' };
333
+ const result = await svc.reviseDecision({
334
+ oldDecisionId: args.id,
335
+ title: args.title,
336
+ description: args.description,
337
+ rationale: args.rationale || '',
338
+ reason: args.reason || '',
339
+ });
340
+ return {
341
+ success: true,
342
+ data: {
343
+ newDecision: result.newDecision.toJSON(),
344
+ superseded: result.oldDecisionId,
345
+ },
346
+ message: `Decision revised: ${result.oldDecisionId} → ${result.newDecision.id}`,
347
+ };
348
+ }
349
+
350
+ // ── unpin_decision ──
351
+
352
+ async function _unpinDecision(svc, args) {
353
+ if (!args.id) return { success: false, message: 'id is required' };
354
+ const task = await svc.unpinDecision(args.id, args.reason || '');
355
+ return {
356
+ success: true,
357
+ data: task.toJSON(),
358
+ message: `Decision ${args.id} unpinned and closed`,
359
+ };
360
+ }
361
+
282
362
  export default router;
@@ -3,6 +3,8 @@
3
3
  * 提取自各路由文件中的重复实现
4
4
  */
5
5
 
6
+ import { KnowledgeEntry } from '../../domain/knowledge/KnowledgeEntry.js';
7
+
6
8
  /**
7
9
  * 从请求中提取操作上下文(用户身份、IP、UA)
8
10
  * @param {import('express').Request} req
@@ -31,3 +33,31 @@ export function safeInt(value, defaultValue, min = 1, max = 1000) {
31
33
  }
32
34
  return Math.max(min, Math.min(max, parsed));
33
35
  }
36
+
37
+ /**
38
+ * 将 KnowledgeEntry(或其 toJSON 输出)转换为对外 API 安全的格式
39
+ * - 过滤系统内部标签(dimension:* / bootstrap:* 等)
40
+ *
41
+ * @param {KnowledgeEntry|Object} entryOrJson 实体或 toJSON 输出
42
+ * @returns {Object} 过滤后的 JSON
43
+ */
44
+ export function sanitizeForAPI(entryOrJson) {
45
+ const json = typeof entryOrJson?.toJSON === 'function' ? entryOrJson.toJSON() : { ...entryOrJson };
46
+ if (Array.isArray(json.tags)) {
47
+ json.tags = json.tags.filter((t) => !KnowledgeEntry.isSystemTag(t));
48
+ }
49
+ return json;
50
+ }
51
+
52
+ /**
53
+ * 将分页结果中的 data 数组批量过滤系统标签
54
+ * @param {{ data: Array, pagination: Object }} result
55
+ * @returns {{ data: Array, pagination: Object }}
56
+ */
57
+ export function sanitizePaginatedForAPI(result) {
58
+ if (!result?.data) return result;
59
+ return {
60
+ ...result,
61
+ data: result.data.map(sanitizeForAPI),
62
+ };
63
+ }
@@ -108,7 +108,8 @@ export class TaskRepositoryImpl {
108
108
  SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open,
109
109
  SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
110
110
  SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
111
- SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END) as deferred
111
+ SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END) as deferred,
112
+ SUM(CASE WHEN status = 'pinned' THEN 1 ELSE 0 END) as pinned
112
113
  FROM tasks
113
114
  `);
114
115
  }
@@ -318,6 +319,7 @@ export class TaskRepositoryImpl {
318
319
  in_progress: row.in_progress || 0,
319
320
  closed: row.closed || 0,
320
321
  deferred: row.deferred || 0,
322
+ pinned: row.pinned || 0,
321
323
  };
322
324
  }
323
325
 
@@ -55,7 +55,9 @@ const MCP_TOOLS_SUMMARY = [
55
55
  { name: 'autosnippet_skill', desc: 'Skill management (list/load/create/update/delete/suggest)' },
56
56
  { name: 'autosnippet_save_document', desc: 'Save development document (auto-publish)' },
57
57
  { name: 'autosnippet_bootstrap', desc: 'Project cold-start & scan (knowledge/refine/scan)' },
58
- { name: 'autosnippet_task', desc: 'TaskGraph lifecycle (prime/claim/close/ready/create/fail/defer)' },
58
+ { name: 'autosnippet_ready', desc: 'Session entry point — loads decisions + ready tasks (call FIRST)' },
59
+ { name: 'autosnippet_decide', desc: 'Decision management (record/revise/unpin/list)' },
60
+ { name: 'autosnippet_task', desc: 'Task CRUD (create/claim/close/fail/defer/progress/decompose)' },
59
61
  { name: 'autosnippet_health', desc: 'Service health & KB statistics' },
60
62
  { name: 'autosnippet_capabilities', desc: 'List all available MCP tools (self-discovery)' },
61
63
  ];
@@ -279,7 +281,7 @@ export class AgentInstructionsGenerator {
279
281
  // Key tools highlight for Claude
280
282
  lines.push('### Recommended Workflow', '');
281
283
  lines.push(
282
- '1. **Session start**: `autosnippet_task({ operation: "prime" })` — Restore task context'
284
+ '1. **Session start**: `autosnippet_ready()` — Restore task context and decisions'
283
285
  );
284
286
  lines.push(
285
287
  '2. **Before writing code**: `autosnippet_search({ query: "<topic>" })` to find relevant patterns'
@@ -405,7 +407,7 @@ export class AgentInstructionsGenerator {
405
407
  '## Recommended Workflow',
406
408
  '',
407
409
  '### Session Start (ALWAYS)',
408
- '1. `autosnippet_task({ operation: "prime" })` — Restore task context',
410
+ '1. `autosnippet_ready()` — Restore task context and decisions',
409
411
  '',
410
412
  '### Task Lifecycle',
411
413
  '2. `autosnippet_task({ operation: "claim", id: "asd-xxx" })` — Start working',
@@ -423,7 +425,7 @@ export class AgentInstructionsGenerator {
423
425
  '',
424
426
  '### Context Pressure',
425
427
  '- `_contextHint: CONTEXT_PRESSURE:WARNING` → Summarize completed work, then continue',
426
- '- `_contextHint: CONTEXT_PRESSURE:CRITICAL` → Call `autosnippet_task({ operation: "prime" })` immediately',
428
+ '- `_contextHint: CONTEXT_PRESSURE:CRITICAL` → Call `autosnippet_ready()` immediately',
427
429
  '',
428
430
  ];
429
431
  }
@@ -225,11 +225,22 @@ export class KnowledgeService {
225
225
  case 'relations':
226
226
  case 'constraints':
227
227
  case 'reasoning':
228
- case 'tags':
229
228
  case 'headers':
230
229
  case 'headerPaths':
231
230
  dbUpdates[key] = data[key];
232
231
  break;
232
+
233
+ // tags 需要特殊处理:API 返回时已过滤系统标签,保存时需要合并回来
234
+ case 'tags': {
235
+ const existingSystemTags = (_entry.tags || []).filter((t) =>
236
+ KnowledgeEntry.isSystemTag(t)
237
+ );
238
+ const incomingUserTags = (data.tags || []).filter(
239
+ (t) => !KnowledgeEntry.isSystemTag(t)
240
+ );
241
+ dbUpdates.tags = [...incomingUserTags, ...existingSystemTags];
242
+ break;
243
+ }
233
244
  }
234
245
  }
235
246
 
@@ -1,5 +1,5 @@
1
1
  import { Task } from '../../domain/task/Task.js';
2
- import { DepType, affectsReadyWork, isValidDepType } from '../../domain/task/TaskDependency.js';
2
+ import { affectsReadyWork, DepType, isValidDepType } from '../../domain/task/TaskDependency.js';
3
3
  import Logger from '../../infrastructure/logging/Logger.js';
4
4
 
5
5
  /**
@@ -88,6 +88,13 @@ export class TaskGraphService {
88
88
  async decompose(epicId, subtasks) {
89
89
  const epic = this.repo.findById(epicId);
90
90
  if (!epic) throw new Error(`Epic not found: ${epicId}`);
91
+ // 决策不可拆解(C6)
92
+ if (epic.taskType === 'decision') {
93
+ throw new Error('Cannot decompose a decision. Decisions are atomic records.');
94
+ }
95
+ if (epic.status === 'pinned') {
96
+ throw new Error('Cannot decompose a pinned task.');
97
+ }
91
98
 
92
99
  const results = this.repo.inTransaction(() => {
93
100
  const created = [];
@@ -359,16 +366,249 @@ export class TaskGraphService {
359
366
  withKnowledge: options.withKnowledge !== false,
360
367
  });
361
368
  const statistics = await this.stats();
362
-
363
- return {
369
+ // 按创建时间降序,确保超出 limit 时保留最新决策(C5)
370
+ // 双重过滤 status+taskType,避免 pinned 语义过载(D1)
371
+ const pinnedDecisions = this.repo.findAll(
372
+ { status: 'pinned', taskType: 'decision' },
373
+ { limit: 50, orderBy: 'created_at DESC' }
374
+ );
375
+
376
+ const result = {
364
377
  inProgress: inProgress.map((t) => t.toJSON()),
365
378
  ready: readyTasks.map((t) => (t.toJSON ? t.toJSON() : t)),
366
379
  stats: statistics,
367
380
  };
381
+
382
+ if (pinnedDecisions.length > 0) {
383
+ // P2: Stale detection — 超过阈值的决策标记为 stale
384
+ const staleThresholdSec = this._getDecisionStaleThreshold();
385
+ const nowSec = Math.floor(Date.now() / 1000);
386
+ const activeDecisions = [];
387
+ const staleDecisions = [];
388
+
389
+ for (const t of pinnedDecisions) {
390
+ const isStale =
391
+ t.metadata?.staleSince ||
392
+ (staleThresholdSec > 0 && t.createdAt && nowSec - t.createdAt > staleThresholdSec);
393
+ if (isStale) {
394
+ staleDecisions.push(t);
395
+ } else {
396
+ activeDecisions.push(t);
397
+ }
398
+ }
399
+
400
+ // P1 Result Compaction: compact 模式 — description 截断到 120 chars
401
+ // Agent 需要完整内容时调用 autosnippet_decide({ operation: 'list' })
402
+ result.decisions = activeDecisions.map((t) => ({
403
+ id: t.id,
404
+ title: t.title,
405
+ summary:
406
+ typeof t.description === 'string'
407
+ ? t.description
408
+ .replace(/^## Decision\n/, '')
409
+ .split(/\n\n## /)[0]
410
+ .slice(0, 120)
411
+ : '',
412
+ createdAt: t.createdAt,
413
+ }));
414
+
415
+ // P2: stale decisions 单独返回(轻量格式,仅供提示)
416
+ if (staleDecisions.length > 0) {
417
+ result.staleDecisions = staleDecisions.map((t) => ({
418
+ id: t.id,
419
+ title: t.title,
420
+ createdAt: t.createdAt,
421
+ ageDays: Math.floor((nowSec - (t.createdAt || nowSec)) / 86400),
422
+ }));
423
+ }
424
+
425
+ result._decisionHint =
426
+ 'These are team-agreed decisions (compact view). Respect them in your response. ' +
427
+ 'Call autosnippet_decide({ operation: "list" }) for full details. ' +
428
+ "If the user's request conflicts with a decision, point out the conflict and ask whether to revise_decision.";
429
+
430
+ if (staleDecisions.length > 0) {
431
+ result._staleHint =
432
+ `${staleDecisions.length} decision(s) are stale (>${Math.floor(staleThresholdSec / 86400)} days). ` +
433
+ 'Consider reviewing with autosnippet_decide({ operation: "list" }) and unpin outdated ones.';
434
+ }
435
+ }
436
+
437
+ // ── 完整协议指令(从 copilot-instructions.md 移到 prime 返回值)──
438
+ result._protocol = [
439
+ 'Respect decisions above. Record new decisions when user agrees/disagrees on something.',
440
+ 'Use create/close to track multi-step work.',
441
+ ].join('\n');
442
+
443
+ return result;
444
+ }
445
+
446
+ // ═══ 决策管理 ═══════════════════════════════════════
447
+
448
+ /**
449
+ * 记录决策 — 直接以 pinned 状态创建,避免 open→pinned 的幽灵窗口(C1)
450
+ * @param {object} params — { title, description, rationale, tags, relatedTaskId }
451
+ * @returns {{ task: Task, isDuplicate: boolean }}
452
+ */
453
+ async recordDecision({ title, description, rationale, tags, relatedTaskId }) {
454
+ if (!title) throw new Error('Decision title is required');
455
+ if (!description) throw new Error('Decision description is required');
456
+
457
+ let formattedDesc = `## Decision\n${description}`;
458
+ if (rationale) {
459
+ formattedDesc += `\n\n## Rationale\n${rationale}`;
460
+ }
461
+
462
+ // 直接以 pinned 状态构建 Task,避免 open→pinned 的幽灵窗口
463
+ const task = new Task({
464
+ title,
465
+ description: formattedDesc,
466
+ taskType: 'decision',
467
+ status: 'pinned',
468
+ priority: 0,
469
+ // C8: 传递普通对象,_entityToRow() 负责 JSON.stringify
470
+ metadata: {
471
+ tags: tags || [],
472
+ source: 'agent-user-agreement',
473
+ rationale: rationale || '',
474
+ recordedAt: new Date().toISOString(),
475
+ },
476
+ });
477
+ task.computeContentHash();
478
+ task.validate();
479
+
480
+ // 去重检测(复用 create 的逻辑)
481
+ const duplicate = this.repo.findByContentHash(task.contentHash);
482
+ if (duplicate) {
483
+ return { task: duplicate, isDuplicate: true };
484
+ }
485
+
486
+ task.id = this.idGen.generate();
487
+
488
+ // 单事务:创建 + 关联依赖
489
+ const saved = this.repo.inTransaction(() => {
490
+ const created = this.repo.create(task);
491
+ if (relatedTaskId) {
492
+ try {
493
+ this.repo.addDependency(created.id, relatedTaskId, 'related');
494
+ } catch (err) {
495
+ this.logger.debug('recordDecision: dependency add failed', { error: err.message });
496
+ }
497
+ }
498
+ return created;
499
+ });
500
+
501
+ this._logEvent(saved.id, 'decision_recorded', null, title);
502
+ return { task: saved, isDuplicate: false };
503
+ }
504
+
505
+ /**
506
+ * 修订决策 — 原子事务:创建新决策 + 关闭旧决策 + 建立 supersedes 链(C2+C7)
507
+ * @param {object} params — { oldDecisionId, title, description, rationale, reason }
508
+ * @returns {{ newDecision: Task, oldDecisionId: string }}
509
+ */
510
+ async reviseDecision({ oldDecisionId, title, description, rationale, reason }) {
511
+ // 事务前验证
512
+ const oldDecision = this.repo.findById(oldDecisionId);
513
+ if (!oldDecision) throw new Error(`Decision not found: ${oldDecisionId}`);
514
+ if (oldDecision.status !== 'pinned') {
515
+ throw new Error(`Can only revise pinned decisions (current: ${oldDecision.status})`);
516
+ }
517
+
518
+ let formattedDesc = `## Decision\n${description}`;
519
+ if (rationale) {
520
+ formattedDesc += `\n\n## Rationale\n${rationale}`;
521
+ }
522
+
523
+ // 构建新决策 Task
524
+ const newTask = new Task({
525
+ title,
526
+ description: formattedDesc,
527
+ taskType: 'decision',
528
+ status: 'pinned',
529
+ priority: 0,
530
+ metadata: {
531
+ tags: [],
532
+ source: 'agent-user-agreement',
533
+ rationale: rationale || '',
534
+ recordedAt: new Date().toISOString(),
535
+ supersedes: oldDecisionId,
536
+ },
537
+ });
538
+ newTask.computeContentHash();
539
+ newTask.validate();
540
+ newTask.id = this.idGen.generate();
541
+
542
+ // 原子事务:创建新决策 + 关闭旧决策 + 建立 supersedes 链
543
+ const newDecision = this.repo.inTransaction(() => {
544
+ const created = this.repo.create(newTask);
545
+
546
+ this.repo.update(oldDecisionId, {
547
+ status: 'closed',
548
+ closeReason: `Superseded by ${created.id}: ${reason || 'Revised'}`,
549
+ closedAt: Math.floor(Date.now() / 1000),
550
+ updatedAt: Math.floor(Date.now() / 1000),
551
+ });
552
+
553
+ this.repo.addDependency(created.id, oldDecisionId, 'supersedes');
554
+ return created;
555
+ });
556
+
557
+ this._logEvent(oldDecisionId, 'superseded', 'pinned', `by ${newDecision.id}`);
558
+ this._logEvent(newDecision.id, 'supersedes', null, oldDecisionId);
559
+
560
+ return { newDecision, oldDecisionId };
561
+ }
562
+
563
+ /**
564
+ * 取消固定决策
565
+ * @param {string} id
566
+ * @param {string} [reason='']
567
+ * @returns {Task}
568
+ */
569
+ async unpinDecision(id, reason = '') {
570
+ const task = this.repo.findById(id);
571
+ if (!task) throw new Error(`Decision not found: ${id}`);
572
+ if (task.status !== 'pinned') {
573
+ throw new Error(`Can only unpin pinned decisions (current: ${task.status})`);
574
+ }
575
+
576
+ const saved = this.repo.update(id, {
577
+ status: 'closed',
578
+ closeReason: reason || 'Unpinned',
579
+ closedAt: Math.floor(Date.now() / 1000),
580
+ updatedAt: Math.floor(Date.now() / 1000),
581
+ });
582
+
583
+ this._logEvent(id, 'unpinned', 'pinned', reason);
584
+ return saved;
368
585
  }
369
586
 
370
587
  // ═══ 私有方法 ═══════════════════════════════════════
371
588
 
589
+ /**
590
+ * P2: 获取决策过期阈值(秒)
591
+ * 默认 30 天 = 2592000 秒。可通过容器内 'config' 服务配置。
592
+ * 返回 0 表示禁用过期检测。
593
+ * @private
594
+ */
595
+ _getDecisionStaleThreshold() {
596
+ try {
597
+ const config = this.repo?.db
598
+ ? null // 通过构造函数传入的 config 优先
599
+ : null;
600
+ // 暂无 config 注入路径,使用环境变量或硬编码默认值
601
+ const envDays = process.env.ASD_DECISION_STALE_DAYS;
602
+ if (envDays !== undefined) {
603
+ const days = Number.parseInt(envDays, 10);
604
+ if (Number.isFinite(days) && days >= 0) return days * 86400;
605
+ }
606
+ return 30 * 86400; // 默认 30 天
607
+ } catch {
608
+ return 30 * 86400;
609
+ }
610
+ }
611
+
372
612
  /**
373
613
  * 查找因 closedTaskId 完成而新解除阻塞的任务
374
614
  * @private
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autosnippet",
3
- "version": "3.2.2",
3
+ "version": "3.2.3",
4
4
  "description": "Extract code patterns into a knowledge base for AI coding assistants",
5
5
  "type": "module",
6
6
  "main": "lib/bootstrap.js",
@@ -46,7 +46,9 @@ Use this skill when the user's intent is unclear or overlaps multiple capabiliti
46
46
  - Guard: `autosnippet_guard` (code 单文件 / files[] 批量)
47
47
  - Bootstrap: `autosnippet_bootstrap` (no params — Mission Briefing) + `autosnippet_dimension_complete`
48
48
  - Wiki: `autosnippet_wiki_plan` (topic planning) + `autosnippet_wiki_finalize` (meta.json + dedup)
49
- - Task: `autosnippet_task` (operation: create / ready / claim / close / fail / defer / progress / prime / stats / decompose)
49
+ - Ready: `autosnippet_ready` (session entry loads decisions + tasks)
50
+ - Decide: `autosnippet_decide` (operation: record / revise / unpin / list)
51
+ - Task: `autosnippet_task` (operation: create / ready / claim / close / fail / defer / progress / stats / decompose)
50
52
  - Skills: `autosnippet_skill` (operation: list / load / create / update / delete / suggest)
51
53
  - Admin: `autosnippet_enrich_candidates`, `autosnippet_knowledge_lifecycle`, `autosnippet_validate_candidate`, `autosnippet_check_duplicate`
52
54
 
@@ -118,7 +118,9 @@ Recipe is the core knowledge unit. V3 uses a unified structured model:
118
118
 
119
119
  | Tool | Description |
120
120
  |------|-------------|
121
- | `autosnippet_task` | TaskGraph lifecycle (`operation`: create/ready/claim/close/fail/defer/progress/prime/stats/decompose) |
121
+ | `autosnippet_ready` | Session entry point — loads decisions + ready tasks (call FIRST) |
122
+ | `autosnippet_decide` | Decision management (`operation`: record/revise/unpin/list) |
123
+ | `autosnippet_task` | Task CRUD (`operation`: create/ready/claim/close/fail/defer/progress/stats/decompose) |
122
124
 
123
125
  ### Skills Management
124
126