ai-growth 1.1.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/.env.example +19 -0
- package/AGENTS.md +232 -0
- package/IMPLEMENTATION_PLAN.md +207 -0
- package/LICENSE +21 -0
- package/PRODUCT_SPEC.md +1776 -0
- package/README.md +384 -0
- package/SKILL.md +121 -0
- package/START-HERE.md +156 -0
- package/dist/api/app.js +206 -0
- package/dist/api/server.js +42 -0
- package/dist/cli/main.js +799 -0
- package/dist/core/actions.js +264 -0
- package/dist/core/context.js +7 -0
- package/dist/core/directions.js +26 -0
- package/dist/core/evidence.js +155 -0
- package/dist/core/goals.js +188 -0
- package/dist/core/lifeContext.js +41 -0
- package/dist/core/memory.js +35 -0
- package/dist/core/metrics.js +74 -0
- package/dist/core/profile.js +95 -0
- package/dist/core/progress.js +244 -0
- package/dist/core/prompts/index.js +40 -0
- package/dist/core/replan.js +100 -0
- package/dist/core/rollover.js +68 -0
- package/dist/core/rolloverTypes.js +1 -0
- package/dist/core/stateMachine.js +61 -0
- package/dist/core/status.js +57 -0
- package/dist/core/types.js +9 -0
- package/dist/daemon/main.js +64 -0
- package/dist/db/cli.js +19 -0
- package/dist/db/client.js +36 -0
- package/dist/db/migrate.js +34 -0
- package/dist/db/migrations/V1__init.sql +249 -0
- package/dist/device/card.js +257 -0
- package/dist/device/deck.js +157 -0
- package/dist/device/fontCoverage.js +162 -0
- package/dist/device/http.js +170 -0
- package/dist/device/png.js +111 -0
- package/dist/device/provision.js +185 -0
- package/dist/device/render.js +194 -0
- package/dist/device/renderCard.js +48 -0
- package/dist/device/report.js +146 -0
- package/dist/device/serial.js +201 -0
- package/dist/device/token.js +78 -0
- package/dist/device/types.js +2 -0
- package/dist/mcp/ctx.js +48 -0
- package/dist/mcp/server.js +15 -0
- package/dist/mcp/tools.js +586 -0
- package/dist/passport/builder.js +83 -0
- package/dist/passport/state.js +81 -0
- package/dist/passport/sync.js +61 -0
- package/dist/reminder/notify.js +30 -0
- package/dist/reminder/scheduler.js +87 -0
- package/dist/reminder/service.js +102 -0
- package/dist/shared/config.js +50 -0
- package/dist/shared/result.js +27 -0
- package/dist/shared/time.js +67 -0
- package/dist/shared/util.js +21 -0
- package/dist/ui/app.js +500 -0
- package/dist/ui/index.html +38 -0
- package/dist/ui/style.css +367 -0
- package/docs/images/cards.png +0 -0
- package/docs/images/cover.png +0 -0
- package/launchd/com.lairey.ai-growth.daemon.plist +45 -0
- package/llms.txt +52 -0
- package/package.json +79 -0
- package/scripts/check-device-cards.mjs +177 -0
- package/scripts/doctor.sh +194 -0
- package/scripts/gen-device-assets.mjs +188 -0
- package/scripts/install.sh +291 -0
- package/scripts/preview-cards.mjs +120 -0
- package/scripts/reset-dev-data.sh +92 -0
- package/scripts/smoke-device.mjs +401 -0
- package/scripts/uninstall.sh +63 -0
- package/server.json +56 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { writeAudit } from './context.js';
|
|
2
|
+
import { newId, ts } from '../shared/util.js';
|
|
3
|
+
import { assertActionTransition, isTerminalActionStatus } from './stateMachine.js';
|
|
4
|
+
import { DomainError, ErrorCodes } from '../shared/result.js';
|
|
5
|
+
import { todayIn, resolveTimezone, getProfile } from './profile.js';
|
|
6
|
+
import { localDateKey } from '../shared/time.js';
|
|
7
|
+
import { cancelRemindersByAction } from '../reminder/service.js';
|
|
8
|
+
function rowToAction(r) {
|
|
9
|
+
return {
|
|
10
|
+
id: r.id, kind: r.kind, domain: r.domain, title: r.title,
|
|
11
|
+
description: r.description ?? undefined, whyToday: r.why_today ?? undefined,
|
|
12
|
+
estimatedMinutes: r.estimated_minutes ?? undefined,
|
|
13
|
+
completionCriteria: r.completion_criteria ?? undefined,
|
|
14
|
+
evidenceRequirements: r.evidence_requirements ?? undefined,
|
|
15
|
+
verificationStrategy: r.verification_strategy ?? undefined, basis: r.basis ?? undefined,
|
|
16
|
+
basisGoalId: r.basis_goal_id ?? undefined, basisDirectionId: r.basis_direction_id ?? undefined,
|
|
17
|
+
basisStageId: r.basis_stage_id ?? undefined, planId: r.plan_id ?? undefined,
|
|
18
|
+
dateKey: r.date_key, status: r.status,
|
|
19
|
+
skippedReason: r.skipped_reason ?? undefined, completedAt: r.completed_at ?? undefined,
|
|
20
|
+
createdAt: r.created_at, updatedAt: r.updated_at,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// ---------- daily plans ----------
|
|
24
|
+
export function getDailyPlan(ctx, dateKey) {
|
|
25
|
+
const row = ctx.db
|
|
26
|
+
.prepare('SELECT * FROM daily_plans WHERE date_key=?')
|
|
27
|
+
.get(dateKey);
|
|
28
|
+
if (!row)
|
|
29
|
+
return null;
|
|
30
|
+
return {
|
|
31
|
+
id: row.id, dateKey: row.date_key, rationale: row.rationale ?? undefined,
|
|
32
|
+
status: row.status, createdAt: row.created_at, confirmedAt: row.confirmed_at ?? undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function getTodayPlan(ctx) {
|
|
36
|
+
const today = todayIn(ctx);
|
|
37
|
+
const plan = getDailyPlan(ctx, today);
|
|
38
|
+
return { plan, actions: plan ? listActionsForDate(ctx, today) : [] };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 创建今日计划(Core 保证结构性约束,V1.1 §23):
|
|
42
|
+
* - 必须先完成 Onboarding(核心原则 2:不允许直接生成任务)
|
|
43
|
+
* - 有未处理的 Pending Replan 时不得创建新 Main Quest(先解决 Replan)
|
|
44
|
+
* - Main Quest <= 1,Daily 0~2
|
|
45
|
+
* - Main 必须有完成标准、预计耗时、whyToday
|
|
46
|
+
* - 同一天已存在计划时返回错误(改用 confirm/update)
|
|
47
|
+
*/
|
|
48
|
+
export function createTodayPlan(ctx, input) {
|
|
49
|
+
// 结构性强制核心原则 2:首次使用必须先完成访谈,Agent 无法用"忘记"绕过
|
|
50
|
+
const profile = getProfile(ctx);
|
|
51
|
+
if (!profile || profile.onboardingStatus !== 'COMPLETED') {
|
|
52
|
+
throw new DomainError(ErrorCodes.ONBOARDING_REQUIRED, 'Onboarding must be COMPLETED before daily planning. Interview the user first, then call plan_today.');
|
|
53
|
+
}
|
|
54
|
+
const today = todayIn(ctx);
|
|
55
|
+
if (getDailyPlan(ctx, today)) {
|
|
56
|
+
throw new DomainError(ErrorCodes.ALREADY_EXISTS, `Plan for ${today} already exists`);
|
|
57
|
+
}
|
|
58
|
+
// 结构性强制:有 Pending Replan 时先解决它再开新主线,避免旧主线被静默丢弃
|
|
59
|
+
if (input.mainQuest) {
|
|
60
|
+
const pending = ctx.db
|
|
61
|
+
.prepare(`SELECT COUNT(*) AS n FROM actions WHERE status='PENDING_REPLAN' AND deleted_at IS NULL`)
|
|
62
|
+
.get();
|
|
63
|
+
if (pending.n > 0) {
|
|
64
|
+
throw new DomainError(ErrorCodes.REPLAN_REQUIRED, 'A previous Main Quest is pending replan. Resolve it with replan() before creating a new Main Quest.');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const dailies = input.dailyActions ?? [];
|
|
68
|
+
if (dailies.length > 2)
|
|
69
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Daily Actions must be 0~2 by default (V1.1 §23.1)');
|
|
70
|
+
const main = input.mainQuest;
|
|
71
|
+
if (main) {
|
|
72
|
+
if (!main.completionCriteria?.trim())
|
|
73
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Main Quest requires completion criteria');
|
|
74
|
+
if (!main.estimatedMinutes)
|
|
75
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Main Quest requires estimated time');
|
|
76
|
+
if (!main.whyToday?.trim())
|
|
77
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Main Quest requires whyToday');
|
|
78
|
+
}
|
|
79
|
+
const planId = newId();
|
|
80
|
+
const now = ts();
|
|
81
|
+
const tx = ctx.db.transaction(() => {
|
|
82
|
+
ctx.db
|
|
83
|
+
.prepare('INSERT INTO daily_plans (id, date_key, rationale, status, created_at) VALUES (?,?,?,?,?)')
|
|
84
|
+
.run(planId, today, input.rationale ?? null, 'DRAFT', now);
|
|
85
|
+
if (main)
|
|
86
|
+
insertAction(ctx, { ...main, kind: 'MAIN_QUEST', planId, dateKey: today });
|
|
87
|
+
for (const d of dailies)
|
|
88
|
+
insertAction(ctx, { ...d, kind: 'DAILY', planId, dateKey: today });
|
|
89
|
+
});
|
|
90
|
+
tx();
|
|
91
|
+
writeAudit(ctx, {
|
|
92
|
+
entityType: 'daily_plan', entityId: planId,
|
|
93
|
+
after: { date: today, mainQuest: !!main, dailyCount: dailies.length },
|
|
94
|
+
reason: 'create today plan',
|
|
95
|
+
});
|
|
96
|
+
const plan = getDailyPlan(ctx, today);
|
|
97
|
+
return { plan, actions: listActionsForDate(ctx, today) };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 确认当日计划。确认后该计划下的 DRAFT action 进入 PLANNED 可执行态。
|
|
101
|
+
* 幂等:已确认的计划再次调用直接返回,不重复写审计。
|
|
102
|
+
*/
|
|
103
|
+
export function confirmTodayPlan(ctx, planId) {
|
|
104
|
+
let dateKey = todayIn(ctx);
|
|
105
|
+
if (planId) {
|
|
106
|
+
const target = ctx.db.prepare('SELECT date_key FROM daily_plans WHERE id=?').get(planId);
|
|
107
|
+
if (!target)
|
|
108
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Plan not found: ${planId}`);
|
|
109
|
+
dateKey = target.date_key;
|
|
110
|
+
}
|
|
111
|
+
const plan = getDailyPlan(ctx, dateKey);
|
|
112
|
+
if (!plan)
|
|
113
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `No plan for ${dateKey}`);
|
|
114
|
+
if (plan.status === 'CONFIRMED')
|
|
115
|
+
return plan;
|
|
116
|
+
ctx.db
|
|
117
|
+
.prepare('UPDATE daily_plans SET status=?, confirmed_at=? WHERE id=?')
|
|
118
|
+
.run('CONFIRMED', ts(), plan.id);
|
|
119
|
+
ctx.db
|
|
120
|
+
.prepare(`UPDATE actions SET status='PLANNED', updated_at=? WHERE plan_id=? AND status='DRAFT'`)
|
|
121
|
+
.run(ts(), plan.id);
|
|
122
|
+
const after = getDailyPlan(ctx, dateKey);
|
|
123
|
+
writeAudit(ctx, { entityType: 'daily_plan', entityId: plan.id, after, reason: 'user confirmed plan' });
|
|
124
|
+
return after;
|
|
125
|
+
}
|
|
126
|
+
function insertAction(ctx, input) {
|
|
127
|
+
const id = newId();
|
|
128
|
+
const now = ts();
|
|
129
|
+
ctx.db
|
|
130
|
+
.prepare(`INSERT INTO actions (id, kind, domain, title, description, why_today, estimated_minutes,
|
|
131
|
+
completion_criteria, evidence_requirements, verification_strategy, basis, basis_goal_id,
|
|
132
|
+
basis_direction_id, basis_stage_id, plan_id, date_key, status, created_at, updated_at)
|
|
133
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
134
|
+
.run(id, input.kind, input.domain ?? 'OTHER', input.title, input.description ?? null, input.whyToday ?? null, input.estimatedMinutes ?? null, input.completionCriteria ?? null, input.evidenceRequirements ?? null, input.verificationStrategy ?? null, input.basis ?? null, input.basisGoalId ?? null, input.basisDirectionId ?? null, input.basisStageId ?? null, input.planId ?? null, input.dateKey, 'DRAFT', now, now);
|
|
135
|
+
return getAction(ctx, id);
|
|
136
|
+
}
|
|
137
|
+
/** Agent 自主创建执行层 action(V1.1 §17.2:当天执行层任务可自主创建) */
|
|
138
|
+
export function createAction(ctx, input) {
|
|
139
|
+
const kind = input.kind ?? 'DAILY';
|
|
140
|
+
const dateKey = input.dateKey ?? todayIn(ctx);
|
|
141
|
+
const a = insertAction(ctx, { ...input, kind, dateKey });
|
|
142
|
+
// 创建即视为已规划(Agent 创建执行层任务无需用户逐条确认)
|
|
143
|
+
ctx.db.prepare(`UPDATE actions SET status='PLANNED', updated_at=? WHERE id=?`).run(ts(), a.id);
|
|
144
|
+
writeAudit(ctx, { entityType: 'action', entityId: a.id, after: getAction(ctx, a.id), reason: 'agent created action' });
|
|
145
|
+
return getAction(ctx, a.id);
|
|
146
|
+
}
|
|
147
|
+
export function getAction(ctx, id) {
|
|
148
|
+
const row = ctx.db.prepare('SELECT * FROM actions WHERE id=? AND deleted_at IS NULL').get(id);
|
|
149
|
+
return row ? rowToAction(row) : null;
|
|
150
|
+
}
|
|
151
|
+
export function listActionsForDate(ctx, dateKey) {
|
|
152
|
+
const rows = ctx.db
|
|
153
|
+
.prepare('SELECT * FROM actions WHERE date_key=? AND deleted_at IS NULL ORDER BY kind DESC, created_at')
|
|
154
|
+
.all(dateKey);
|
|
155
|
+
return rows.map(rowToAction);
|
|
156
|
+
}
|
|
157
|
+
export function listOpenActionsBefore(ctx, dateKey) {
|
|
158
|
+
const rows = ctx.db
|
|
159
|
+
.prepare(`SELECT * FROM actions WHERE date_key < ? AND deleted_at IS NULL
|
|
160
|
+
AND status IN ('DRAFT','PLANNED','IN_PROGRESS','DELAYED','PENDING_REPLAN')
|
|
161
|
+
ORDER BY date_key, created_at`)
|
|
162
|
+
.all(dateKey);
|
|
163
|
+
return rows.map(rowToAction);
|
|
164
|
+
}
|
|
165
|
+
function transitionAction(ctx, id, to, reason, extra = {}) {
|
|
166
|
+
const before = getAction(ctx, id);
|
|
167
|
+
if (!before)
|
|
168
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Action not found: ${id}`);
|
|
169
|
+
// 用户对 DRAFT(尚未确认的当日计划)执行动作,本身就是对该计划的认可。
|
|
170
|
+
// 自动提升 DRAFT → PLANNED,避免用户点了「完成」却收到状态机报错
|
|
171
|
+
// (符合 §21「低风险任务允许一句话确认」与"不让用户管理系统"的定位)。
|
|
172
|
+
let from = before.status;
|
|
173
|
+
if (from === 'DRAFT' && to !== 'PLANNED' && to !== 'CANCELLED') {
|
|
174
|
+
ctx.db.prepare(`UPDATE actions SET status='PLANNED', updated_at=? WHERE id=?`).run(ts(), id);
|
|
175
|
+
from = 'PLANNED';
|
|
176
|
+
}
|
|
177
|
+
assertActionTransition(from, to, before.kind);
|
|
178
|
+
ctx.db
|
|
179
|
+
.prepare('UPDATE actions SET status=?, skipped_reason=?, completed_at=?, updated_at=? WHERE id=?')
|
|
180
|
+
.run(to, extra.skippedReason ?? before.skippedReason ?? null, to === 'COMPLETED' ? ts() : before.completedAt ?? null, ts(), id);
|
|
181
|
+
if (isTerminalActionStatus(to)) {
|
|
182
|
+
// Action 终态:取消未触发提醒(V1.1 §24 / Scenario I)
|
|
183
|
+
cancelRemindersByAction(ctx, id, `action ${to}`);
|
|
184
|
+
}
|
|
185
|
+
const after = getAction(ctx, id);
|
|
186
|
+
writeAudit(ctx, { entityType: 'action', entityId: id, before, after, reason });
|
|
187
|
+
return after;
|
|
188
|
+
}
|
|
189
|
+
export function startAction(ctx, id) {
|
|
190
|
+
return transitionAction(ctx, id, 'IN_PROGRESS', 'start action');
|
|
191
|
+
}
|
|
192
|
+
/** Agent 自主修改执行层任务(V1.1 §17.2)。修改顺带把 PENDING_REPLAN/DRAFT 拉回 PLANNED 由调用方决定 */
|
|
193
|
+
export function updateAction(ctx, id, input, reason = 'agent update action') {
|
|
194
|
+
const before = getAction(ctx, id);
|
|
195
|
+
if (!before)
|
|
196
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Action not found: ${id}`);
|
|
197
|
+
if (isTerminalActionStatus(before.status))
|
|
198
|
+
throw new DomainError(ErrorCodes.INVALID_STATE, `Cannot update terminal action (${before.status})`);
|
|
199
|
+
ctx.db
|
|
200
|
+
.prepare(`UPDATE actions SET title=?, description=?, why_today=?, estimated_minutes=?, completion_criteria=?,
|
|
201
|
+
evidence_requirements=?, verification_strategy=?, date_key=?, updated_at=? WHERE id=?`)
|
|
202
|
+
.run(input.title ?? before.title, input.description ?? before.description ?? null, input.whyToday ?? before.whyToday ?? null, input.estimatedMinutes ?? before.estimatedMinutes ?? null, input.completionCriteria ?? before.completionCriteria ?? null, input.evidenceRequirements ?? before.evidenceRequirements ?? null, input.verificationStrategy ?? before.verificationStrategy ?? null, input.dateKey ?? before.dateKey, ts(), id);
|
|
203
|
+
const after = getAction(ctx, id);
|
|
204
|
+
writeAudit(ctx, { entityType: 'action', entityId: id, before, after, reason });
|
|
205
|
+
return after;
|
|
206
|
+
}
|
|
207
|
+
/** 完成 action。delay/skip 必须带 reason;complete 后触发 GrowthEvent 由 evidence 服务负责 */
|
|
208
|
+
export function completeAction(ctx, id) {
|
|
209
|
+
return transitionAction(ctx, id, 'COMPLETED', 'user completed action');
|
|
210
|
+
}
|
|
211
|
+
export function skipAction(ctx, id, reason) {
|
|
212
|
+
return transitionAction(ctx, id, 'SKIPPED', 'user skipped action', { skippedReason: reason });
|
|
213
|
+
}
|
|
214
|
+
export function delayAction(ctx, id, reason) {
|
|
215
|
+
const delayed = transitionAction(ctx, id, 'DELAYED', 'user delayed action');
|
|
216
|
+
if (!reason)
|
|
217
|
+
return delayed;
|
|
218
|
+
ctx.db.prepare('UPDATE actions SET skipped_reason=?, updated_at=? WHERE id=?').run(reason, ts(), id);
|
|
219
|
+
return getAction(ctx, id);
|
|
220
|
+
}
|
|
221
|
+
export function cancelAction(ctx, id, reason) {
|
|
222
|
+
return transitionAction(ctx, id, 'CANCELLED', reason);
|
|
223
|
+
}
|
|
224
|
+
/** Replan 内部使用:把 PENDING_REPLAN/DELAYED 的 Main Quest 拉回 PLANNED(可同时改日期) */
|
|
225
|
+
export function rearmAction(ctx, id, newDateKey) {
|
|
226
|
+
if (newDateKey)
|
|
227
|
+
updateAction(ctx, id, { dateKey: newDateKey }, 'replan move date');
|
|
228
|
+
return transitionAction(ctx, id, 'PLANNED', 'replanned');
|
|
229
|
+
}
|
|
230
|
+
// rollover 专用(绕过一般转换约束由状态机保证)
|
|
231
|
+
export function expireActionInternal(ctx, id) {
|
|
232
|
+
return transitionAction(ctx, id, 'EXPIRED', 'daily rollover: not completed by end of day');
|
|
233
|
+
}
|
|
234
|
+
export function markPendingReplanInternal(ctx, id) {
|
|
235
|
+
return transitionAction(ctx, id, 'PENDING_REPLAN', 'daily rollover: main quest not completed');
|
|
236
|
+
}
|
|
237
|
+
// ---------- task feedback(V1.1 §20)----------
|
|
238
|
+
export function addTaskFeedback(ctx, actionId, reason, comment) {
|
|
239
|
+
const action = getAction(ctx, actionId);
|
|
240
|
+
if (!action)
|
|
241
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Action not found: ${actionId}`);
|
|
242
|
+
const id = newId();
|
|
243
|
+
ctx.db
|
|
244
|
+
.prepare('INSERT INTO task_feedback (id, action_id, reason, comment, created_at) VALUES (?,?,?,?,?)')
|
|
245
|
+
.run(id, actionId, reason, comment ?? null, ts());
|
|
246
|
+
writeAudit(ctx, { entityType: 'task_feedback', entityId: id, after: { actionId, reason, comment }, reason: 'user feedback' });
|
|
247
|
+
return { id, actionId, reason, comment, createdAt: ts() };
|
|
248
|
+
}
|
|
249
|
+
export function listFeedbackForAction(ctx, actionId) {
|
|
250
|
+
const rows = ctx.db
|
|
251
|
+
.prepare('SELECT * FROM task_feedback WHERE action_id=? ORDER BY created_at DESC')
|
|
252
|
+
.all(actionId);
|
|
253
|
+
return rows.map((r) => ({ id: r.id, actionId: r.action_id, reason: r.reason, comment: r.comment ?? undefined, createdAt: r.created_at }));
|
|
254
|
+
}
|
|
255
|
+
/** 近 N 天反馈聚合(供 Planner 降低重复推荐概率) */
|
|
256
|
+
export function recentFeedbackStats(ctx, days = 14) {
|
|
257
|
+
const since = localDateKey(new Date(Date.now() - days * 86400_000), resolveTimezone(ctx));
|
|
258
|
+
const rows = ctx.db
|
|
259
|
+
.prepare(`SELECT tf.reason AS reason, COUNT(*) AS count FROM task_feedback tf
|
|
260
|
+
JOIN actions a ON a.id = tf.action_id
|
|
261
|
+
WHERE a.date_key >= ? GROUP BY tf.reason ORDER BY count DESC`)
|
|
262
|
+
.all(since);
|
|
263
|
+
return rows.map((r) => ({ reason: r.reason, count: r.count }));
|
|
264
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { newId, ts } from '../shared/util.js';
|
|
2
|
+
export function writeAudit(ctx, entry) {
|
|
3
|
+
ctx.db
|
|
4
|
+
.prepare(`INSERT INTO audit_log (id, actor, entity_type, entity_id, before, after, reason, created_at)
|
|
5
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
6
|
+
.run(newId(), ctx.actor, entry.entityType, entry.entityId, entry.before === undefined ? null : JSON.stringify(entry.before), entry.after === undefined ? null : JSON.stringify(entry.after), entry.reason ?? null, ts());
|
|
7
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { newId, ts } from '../shared/util.js';
|
|
2
|
+
import { DomainError, ErrorCodes } from '../shared/result.js';
|
|
3
|
+
/** Daily Action 只能来自用户明确认可过的方向 / 意图 / 约束(核心原则 7)。 */
|
|
4
|
+
export function listDirections(ctx, onlyConfirmed = true) {
|
|
5
|
+
const rows = onlyConfirmed
|
|
6
|
+
? ctx.db.prepare('SELECT * FROM directions WHERE deleted_at IS NULL AND confirmed=1 ORDER BY created_at').all()
|
|
7
|
+
: ctx.db.prepare('SELECT * FROM directions WHERE deleted_at IS NULL ORDER BY created_at').all();
|
|
8
|
+
return rows;
|
|
9
|
+
}
|
|
10
|
+
export function addDirection(ctx, input) {
|
|
11
|
+
// 新增长期方向必须用户确认(V1.1 §17.4);Agent 推断的只能作为 INFERRED 未确认
|
|
12
|
+
const id = newId();
|
|
13
|
+
const now = ts();
|
|
14
|
+
ctx.db
|
|
15
|
+
.prepare(`INSERT INTO directions (id, title, description, domain, origin, confirmed, created_at, updated_at)
|
|
16
|
+
VALUES (?,?,?,?,?,?,?,?)`)
|
|
17
|
+
.run(id, input.title, input.description ?? null, input.domain ?? 'OTHER', input.confirmed ? 'USER_CONFIRMED' : 'INFERRED', input.confirmed ? 1 : 0, now, now);
|
|
18
|
+
return ctx.db.prepare('SELECT * FROM directions WHERE id=?').get(id);
|
|
19
|
+
}
|
|
20
|
+
export function confirmDirection(ctx, id) {
|
|
21
|
+
const r = ctx.db.prepare('SELECT * FROM directions WHERE id=?').get(id);
|
|
22
|
+
if (!r)
|
|
23
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Direction not found: ${id}`);
|
|
24
|
+
ctx.db.prepare('UPDATE directions SET confirmed=1, origin=?, updated_at=? WHERE id=?').run('USER_CONFIRMED', ts(), id);
|
|
25
|
+
return ctx.db.prepare('SELECT * FROM directions WHERE id=?').get(id);
|
|
26
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { writeAudit } from './context.js';
|
|
2
|
+
import { newId, ts, pj } from '../shared/util.js';
|
|
3
|
+
import { DomainError, ErrorCodes } from '../shared/result.js';
|
|
4
|
+
import { todayIn, resolveTimezone } from './profile.js';
|
|
5
|
+
import { startOfDateKey } from '../shared/time.js';
|
|
6
|
+
import { flagPassportDirty } from '../passport/state.js';
|
|
7
|
+
function rowToEvidence(r) {
|
|
8
|
+
return {
|
|
9
|
+
id: r.id, actionId: r.action_id ?? undefined, type: r.type,
|
|
10
|
+
strength: r.strength, content: r.content,
|
|
11
|
+
metadata: pj(r.metadata, {}), createdAt: r.created_at,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 记录 Evidence。UNVERIFIED 证据不能用于自动宣布完成关键目标(调用方约束),
|
|
16
|
+
* 自动验证类(GIT/METRIC/QUIZ 结果)默认 AUTO_VERIFIED。
|
|
17
|
+
*/
|
|
18
|
+
export function addEvidence(ctx, input) {
|
|
19
|
+
if (!input.content?.trim())
|
|
20
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Evidence content is required');
|
|
21
|
+
const id = newId();
|
|
22
|
+
const strength = input.strength ??
|
|
23
|
+
(['GIT', 'METRIC', 'QUIZ', 'ARTIFACT'].includes(input.type) ? 'AUTO_VERIFIED' : 'USER_CONFIRMED');
|
|
24
|
+
ctx.db
|
|
25
|
+
.prepare(`INSERT INTO evidence (id, action_id, type, strength, content, metadata, created_at)
|
|
26
|
+
VALUES (?,?,?,?,?,?,?)`)
|
|
27
|
+
.run(id, input.actionId ?? null, input.type, strength, input.content.trim(), JSON.stringify(input.metadata ?? {}), ts());
|
|
28
|
+
const ev = ctx.db.prepare('SELECT * FROM evidence WHERE id=?').get(id);
|
|
29
|
+
flagPassportDirty(ctx, 'evidence added');
|
|
30
|
+
return rowToEvidence(ev);
|
|
31
|
+
}
|
|
32
|
+
export function listEvidence(ctx, filters = {}) {
|
|
33
|
+
const conds = ['1=1'];
|
|
34
|
+
const params = [];
|
|
35
|
+
if (filters.actionId) {
|
|
36
|
+
conds.push('e.action_id = ?');
|
|
37
|
+
params.push(filters.actionId);
|
|
38
|
+
}
|
|
39
|
+
if (filters.type) {
|
|
40
|
+
conds.push('e.type = ?');
|
|
41
|
+
params.push(filters.type);
|
|
42
|
+
}
|
|
43
|
+
if (filters.strength) {
|
|
44
|
+
conds.push('e.strength = ?');
|
|
45
|
+
params.push(filters.strength);
|
|
46
|
+
}
|
|
47
|
+
if (filters.sinceDateKey) {
|
|
48
|
+
// 必须按"证据自身产生时间"过滤,而不是它挂靠 action 的 date_key:
|
|
49
|
+
// 跨天完成的旧任务,其证据属于记录当天;无 action 的证据(评估/反思)同样要计入。
|
|
50
|
+
// created_at 是 UTC ISO,自然日边界需按配置时区换算。
|
|
51
|
+
conds.push('e.created_at >= ?');
|
|
52
|
+
params.push(startOfDateKey(filters.sinceDateKey, resolveTimezone(ctx)).toISOString());
|
|
53
|
+
}
|
|
54
|
+
const limit = Math.min(filters.limit ?? 100, 500);
|
|
55
|
+
const rows = ctx.db
|
|
56
|
+
.prepare(`SELECT e.* FROM evidence e WHERE ${conds.join(' AND ')} ORDER BY e.created_at DESC, e.rowid DESC LIMIT ${limit}`)
|
|
57
|
+
.all(...params);
|
|
58
|
+
return rows.map(rowToEvidence);
|
|
59
|
+
}
|
|
60
|
+
export function createAssessment(ctx, input) {
|
|
61
|
+
const id = newId();
|
|
62
|
+
const now = ts();
|
|
63
|
+
// 新建评估处于 EXPOSURE(刚接触),提交结果时升级
|
|
64
|
+
ctx.db
|
|
65
|
+
.prepare(`INSERT INTO assessments (id, action_id, goal_id, topic, level, method, passed, result, created_at, updated_at)
|
|
66
|
+
VALUES (?,?,?,?,'EXPOSURE',?,0,'{}',?,?)`)
|
|
67
|
+
.run(id, input.actionId ?? null, input.goalId ?? null, input.topic, input.method ?? null, now, now);
|
|
68
|
+
return { id, topic: input.topic };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 提交评估结果。规则(§22):
|
|
72
|
+
* - Quiz 高分不等于完全掌握;level 由 Agent 按表现判定
|
|
73
|
+
* - 未通过 → 不升级 Knowledge State(返回给 Agent 决定重规划)
|
|
74
|
+
*/
|
|
75
|
+
export function submitAssessment(ctx, input) {
|
|
76
|
+
const row = ctx.db.prepare('SELECT * FROM assessments WHERE id=?').get(input.id);
|
|
77
|
+
if (!row)
|
|
78
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Assessment not found: ${input.id}`);
|
|
79
|
+
const passed = !!input.passed;
|
|
80
|
+
ctx.db
|
|
81
|
+
.prepare('UPDATE assessments SET passed=?, level=?, result=?, updated_at=? WHERE id=?')
|
|
82
|
+
.run(passed ? 1 : 0, input.level, JSON.stringify({ ...(input.result ?? {}), level: input.level, passed }), ts(), input.id);
|
|
83
|
+
// 评估本身也是 evidence(QUIZ 类型)
|
|
84
|
+
addEvidence(ctx, {
|
|
85
|
+
type: 'QUIZ',
|
|
86
|
+
strength: passed ? 'AUTO_VERIFIED' : 'UNVERIFIED',
|
|
87
|
+
content: `Assessment "${row.topic}": ${input.level} ${passed ? 'PASSED' : 'FAILED'}`,
|
|
88
|
+
metadata: { assessmentId: input.id, level: input.level },
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
assessment: { id: input.id, topic: row.topic, level: input.level, passed },
|
|
92
|
+
knowledgeStateUpdated: passed,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function listAssessments(ctx, goalId, limit = 50) {
|
|
96
|
+
const rows = goalId
|
|
97
|
+
? ctx.db.prepare('SELECT * FROM assessments WHERE goal_id=? ORDER BY created_at DESC LIMIT ?').all(goalId, limit)
|
|
98
|
+
: ctx.db.prepare('SELECT * FROM assessments ORDER BY created_at DESC LIMIT ?').all(limit);
|
|
99
|
+
return rows.map((r) => ({
|
|
100
|
+
id: r.id,
|
|
101
|
+
topic: r.topic,
|
|
102
|
+
method: r.method ?? undefined,
|
|
103
|
+
level: r.level,
|
|
104
|
+
passed: !!r.passed,
|
|
105
|
+
createdAt: r.created_at,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
export function createGrowthEvent(ctx, input) {
|
|
109
|
+
if (!input.evidenceIds?.length) {
|
|
110
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Growth Event requires at least one evidence id (no evidence-free personality judgments)');
|
|
111
|
+
}
|
|
112
|
+
const id = newId();
|
|
113
|
+
const dateKey = input.dateKey ?? todayIn(ctx);
|
|
114
|
+
ctx.db
|
|
115
|
+
.prepare(`INSERT INTO growth_events (id, date_key, title, description, domain, evidence_ids, goal_id, stage_id, tags, created_at)
|
|
116
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
117
|
+
.run(id, dateKey, input.title, input.description ?? null, input.domain ?? 'OTHER', JSON.stringify(input.evidenceIds), input.goalId ?? null, input.stageId ?? null, JSON.stringify(input.tags ?? []), ts());
|
|
118
|
+
flagPassportDirty(ctx, 'growth event created');
|
|
119
|
+
writeAudit(ctx, { entityType: 'growth_event', entityId: id, after: input, reason: 'meaningful progress recorded' });
|
|
120
|
+
return getGrowthEvent(ctx, id);
|
|
121
|
+
}
|
|
122
|
+
export function getGrowthEvent(ctx, id) {
|
|
123
|
+
const r = ctx.db.prepare('SELECT * FROM growth_events WHERE id=?').get(id);
|
|
124
|
+
if (!r)
|
|
125
|
+
return null;
|
|
126
|
+
return {
|
|
127
|
+
id: r.id, dateKey: r.date_key, title: r.title, description: r.description ?? undefined,
|
|
128
|
+
domain: r.domain, evidenceIds: pj(r.evidence_ids, []),
|
|
129
|
+
goalId: r.goal_id ?? undefined, stageId: r.stage_id ?? undefined, tags: pj(r.tags, []),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export function listGrowthEvents(ctx, sinceDateKey, limit = 100) {
|
|
133
|
+
const rows = sinceDateKey
|
|
134
|
+
? ctx.db.prepare('SELECT * FROM growth_events WHERE date_key >= ? ORDER BY date_key DESC, created_at DESC LIMIT ?').all(sinceDateKey, limit)
|
|
135
|
+
: ctx.db.prepare('SELECT * FROM growth_events ORDER BY date_key DESC, created_at DESC LIMIT ?').all(limit);
|
|
136
|
+
return rows.map((r) => getGrowthEvent(ctx, r.id));
|
|
137
|
+
}
|
|
138
|
+
// ---------- human versions(§9:有 Evidence 引用的稳定变化)----------
|
|
139
|
+
export function createHumanVersion(ctx, title, description, evidenceIds) {
|
|
140
|
+
if (!evidenceIds?.length) {
|
|
141
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Human Version must reference evidence (V1.1 §9)');
|
|
142
|
+
}
|
|
143
|
+
const max = ctx.db.prepare('SELECT COALESCE(MAX(version), 0) AS v FROM human_versions').get();
|
|
144
|
+
const version = max.v + 1;
|
|
145
|
+
const id = newId();
|
|
146
|
+
ctx.db
|
|
147
|
+
.prepare('INSERT INTO human_versions (id, version, title, description, evidence_ids, created_at) VALUES (?,?,?,?,?,?)')
|
|
148
|
+
.run(id, version, title, description, JSON.stringify(evidenceIds), ts());
|
|
149
|
+
return { id, version };
|
|
150
|
+
}
|
|
151
|
+
export function listHumanVersions(ctx, limit = 20) {
|
|
152
|
+
return ctx.db
|
|
153
|
+
.prepare('SELECT * FROM human_versions ORDER BY version DESC LIMIT ?')
|
|
154
|
+
.all(limit);
|
|
155
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { writeAudit } from './context.js';
|
|
2
|
+
import { newId, ts, pj } from '../shared/util.js';
|
|
3
|
+
import { assertGoalTransition } from './stateMachine.js';
|
|
4
|
+
import { DomainError, ErrorCodes } from '../shared/result.js';
|
|
5
|
+
function rowToGoal(r) {
|
|
6
|
+
return {
|
|
7
|
+
id: r.id, title: r.title, why: r.why, desiredOutcome: r.desired_outcome,
|
|
8
|
+
successCriteria: pj(r.success_criteria, []), motivation: r.motivation ?? undefined,
|
|
9
|
+
startDate: r.start_date ?? undefined, targetDate: r.target_date ?? undefined,
|
|
10
|
+
status: r.status, dailyTimeBudgetMinutes: r.daily_time_budget_minutes ?? undefined,
|
|
11
|
+
createdAt: r.created_at, updatedAt: r.updated_at,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function rowToStage(r) {
|
|
15
|
+
return {
|
|
16
|
+
id: r.id, goalId: r.goal_id, title: r.title, objective: r.objective ?? undefined,
|
|
17
|
+
order: r.order_index, plannedStartDate: r.planned_start_date ?? undefined,
|
|
18
|
+
plannedEndDate: r.planned_end_date ?? undefined, status: r.status,
|
|
19
|
+
exitCriteria: pj(r.exit_criteria, []),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Goal 必须包含 why / desiredOutcome / successCriteria(V1.1 §18) */
|
|
23
|
+
export function createGoalDraft(ctx, input) {
|
|
24
|
+
if (!input.title?.trim())
|
|
25
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Goal title is required');
|
|
26
|
+
if (!input.why?.trim())
|
|
27
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Goal must include why (V1.1 §18)');
|
|
28
|
+
if (!input.desiredOutcome?.trim())
|
|
29
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Goal must include desiredOutcome');
|
|
30
|
+
if (!Array.isArray(input.successCriteria) || input.successCriteria.length === 0)
|
|
31
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Goal must include at least one success criterion');
|
|
32
|
+
const id = newId();
|
|
33
|
+
const now = ts();
|
|
34
|
+
ctx.db
|
|
35
|
+
.prepare(`INSERT INTO goals (id, title, why, desired_outcome, success_criteria, motivation, start_date,
|
|
36
|
+
target_date, status, daily_time_budget_minutes, created_at, updated_at)
|
|
37
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
38
|
+
.run(id, input.title.trim(), input.why.trim(), input.desiredOutcome.trim(), JSON.stringify(input.successCriteria), input.motivation ?? null, input.startDate ?? null, input.targetDate ?? null, 'DRAFT', input.dailyTimeBudgetMinutes ?? null, now, now);
|
|
39
|
+
const goal = getGoal(ctx, id);
|
|
40
|
+
writeAudit(ctx, { entityType: 'goal', entityId: id, after: goal, reason: 'create goal draft' });
|
|
41
|
+
return goal;
|
|
42
|
+
}
|
|
43
|
+
export function getGoal(ctx, id) {
|
|
44
|
+
const row = ctx.db.prepare('SELECT * FROM goals WHERE id=? AND deleted_at IS NULL').get(id);
|
|
45
|
+
return row ? rowToGoal(row) : null;
|
|
46
|
+
}
|
|
47
|
+
export function listGoals(ctx, statuses) {
|
|
48
|
+
const rows = statuses?.length
|
|
49
|
+
? ctx.db
|
|
50
|
+
.prepare(`SELECT * FROM goals WHERE deleted_at IS NULL AND status IN (${statuses.map(() => '?').join(',')}) ORDER BY created_at`)
|
|
51
|
+
.all(...statuses)
|
|
52
|
+
: ctx.db.prepare('SELECT * FROM goals WHERE deleted_at IS NULL ORDER BY created_at').all();
|
|
53
|
+
return rows.map(rowToGoal);
|
|
54
|
+
}
|
|
55
|
+
export function getActiveGoals(ctx) {
|
|
56
|
+
return listGoals(ctx, ['ACTIVE']);
|
|
57
|
+
}
|
|
58
|
+
function updateGoalStatus(ctx, id, to, reason) {
|
|
59
|
+
const before = getGoal(ctx, id);
|
|
60
|
+
if (!before)
|
|
61
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Goal not found: ${id}`);
|
|
62
|
+
assertGoalTransition(before.status, to);
|
|
63
|
+
ctx.db.prepare('UPDATE goals SET status=?, updated_at=? WHERE id=?').run(to, ts(), id);
|
|
64
|
+
const after = getGoal(ctx, id);
|
|
65
|
+
writeAudit(ctx, { entityType: 'goal', entityId: id, before, after, reason });
|
|
66
|
+
return after;
|
|
67
|
+
}
|
|
68
|
+
export function confirmGoal(ctx, id) {
|
|
69
|
+
return updateGoalStatus(ctx, id, 'CONFIRMED', 'user confirmed goal draft');
|
|
70
|
+
}
|
|
71
|
+
export function activateGoal(ctx, id) {
|
|
72
|
+
return updateGoalStatus(ctx, id, 'ACTIVE', 'goal activated (stage plan confirmed)');
|
|
73
|
+
}
|
|
74
|
+
export function pauseGoal(ctx, id) {
|
|
75
|
+
return updateGoalStatus(ctx, id, 'PAUSED', 'user paused goal');
|
|
76
|
+
}
|
|
77
|
+
export function resumeGoal(ctx, id) {
|
|
78
|
+
return updateGoalStatus(ctx, id, 'ACTIVE', 'user resumed goal');
|
|
79
|
+
}
|
|
80
|
+
/** 重大字段(why/outcome/criteria/deadline)修改记录审计;调用方(Agent)必须先获得用户确认 */
|
|
81
|
+
export function updateGoal(ctx, id, input, reason = 'update goal') {
|
|
82
|
+
const before = getGoal(ctx, id);
|
|
83
|
+
if (!before)
|
|
84
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Goal not found: ${id}`);
|
|
85
|
+
const next = {
|
|
86
|
+
title: input.title ?? before.title,
|
|
87
|
+
why: input.why ?? before.why,
|
|
88
|
+
desiredOutcome: input.desiredOutcome ?? before.desiredOutcome,
|
|
89
|
+
successCriteria: input.successCriteria ?? before.successCriteria,
|
|
90
|
+
motivation: input.motivation ?? before.motivation,
|
|
91
|
+
targetDate: input.targetDate !== undefined ? input.targetDate : before.targetDate,
|
|
92
|
+
dailyTimeBudgetMinutes: input.dailyTimeBudgetMinutes !== undefined ? (input.dailyTimeBudgetMinutes ?? undefined) : before.dailyTimeBudgetMinutes,
|
|
93
|
+
};
|
|
94
|
+
ctx.db
|
|
95
|
+
.prepare(`UPDATE goals SET title=?, why=?, desired_outcome=?, success_criteria=?, motivation=?,
|
|
96
|
+
target_date=?, daily_time_budget_minutes=?, updated_at=? WHERE id=?`)
|
|
97
|
+
.run(next.title, next.why, next.desiredOutcome, JSON.stringify(next.successCriteria), next.motivation ?? null, next.targetDate ?? null, next.dailyTimeBudgetMinutes ?? null, ts(), id);
|
|
98
|
+
const after = getGoal(ctx, id);
|
|
99
|
+
writeAudit(ctx, { entityType: 'goal', entityId: id, before, after, reason });
|
|
100
|
+
return after;
|
|
101
|
+
}
|
|
102
|
+
/** 创建 Stage Plan。确认 Goal 后才允许激活(由调用方控制顺序) */
|
|
103
|
+
export function createStagePlan(ctx, input) {
|
|
104
|
+
const goal = getGoal(ctx, input.goalId);
|
|
105
|
+
if (!goal)
|
|
106
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Goal not found: ${input.goalId}`);
|
|
107
|
+
const existing = listStages(ctx, input.goalId);
|
|
108
|
+
if (existing.length > 0)
|
|
109
|
+
throw new DomainError(ErrorCodes.ALREADY_EXISTS, 'Stage plan already exists for this goal');
|
|
110
|
+
if (!input.stages?.length)
|
|
111
|
+
throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'Stage plan requires at least one stage');
|
|
112
|
+
const now = ts();
|
|
113
|
+
const insert = ctx.db.prepare(`INSERT INTO stages (id, goal_id, title, objective, order_index, planned_start_date, planned_end_date,
|
|
114
|
+
status, exit_criteria, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`);
|
|
115
|
+
const ids = [];
|
|
116
|
+
const tx = ctx.db.transaction(() => {
|
|
117
|
+
input.stages.forEach((s, i) => {
|
|
118
|
+
const id = newId();
|
|
119
|
+
ids.push(id);
|
|
120
|
+
insert.run(id, input.goalId, s.title, s.objective ?? null, s.order ?? i + 1, s.plannedStartDate ?? null, s.plannedEndDate ?? null, 'PLANNED', JSON.stringify(s.exitCriteria ?? []), now, now);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
tx();
|
|
124
|
+
writeAudit(ctx, { entityType: 'stage_plan', entityId: input.goalId, after: { stageCount: ids.length }, reason: 'create stage plan' });
|
|
125
|
+
return listStages(ctx, input.goalId);
|
|
126
|
+
}
|
|
127
|
+
export function listStages(ctx, goalId) {
|
|
128
|
+
const rows = ctx.db
|
|
129
|
+
.prepare('SELECT * FROM stages WHERE goal_id=? AND deleted_at IS NULL ORDER BY order_index')
|
|
130
|
+
.all(goalId);
|
|
131
|
+
return rows.map(rowToStage);
|
|
132
|
+
}
|
|
133
|
+
export function getStage(ctx, id) {
|
|
134
|
+
const row = ctx.db.prepare('SELECT * FROM stages WHERE id=?').get(id);
|
|
135
|
+
return row ? rowToStage(row) : null;
|
|
136
|
+
}
|
|
137
|
+
export function getCurrentStage(ctx, goalId) {
|
|
138
|
+
const rows = ctx.db
|
|
139
|
+
.prepare(`SELECT * FROM stages WHERE goal_id=? AND deleted_at IS NULL
|
|
140
|
+
AND status IN ('ACTIVE','PLANNED','DELAYED') ORDER BY order_index LIMIT 1`)
|
|
141
|
+
.all(goalId);
|
|
142
|
+
return rows[0] ? rowToStage(rows[0]) : null;
|
|
143
|
+
}
|
|
144
|
+
export function updateStage(ctx, input, reason = 'update stage') {
|
|
145
|
+
const before = getStage(ctx, input.id);
|
|
146
|
+
if (!before)
|
|
147
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Stage not found: ${input.id}`);
|
|
148
|
+
ctx.db
|
|
149
|
+
.prepare(`UPDATE stages SET title=?, objective=?, order_index=?, planned_start_date=?, planned_end_date=?,
|
|
150
|
+
status=?, exit_criteria=?, updated_at=? WHERE id=?`)
|
|
151
|
+
.run(input.title ?? before.title, input.objective ?? before.objective ?? null, input.order ?? before.order, input.plannedStartDate ?? before.plannedStartDate ?? null, input.plannedEndDate ?? before.plannedEndDate ?? null, input.status ?? before.status, JSON.stringify(input.exitCriteria ?? before.exitCriteria), ts(), input.id);
|
|
152
|
+
const after = getStage(ctx, input.id);
|
|
153
|
+
writeAudit(ctx, { entityType: 'stage', entityId: input.id, before, after, reason });
|
|
154
|
+
return after;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Replan 后顺延后续 Stage 窗口(Core primitive,由 Agent 决策调用)。
|
|
158
|
+
*
|
|
159
|
+
* 关键约束:
|
|
160
|
+
* - COMPLETED / SKIPPED 是**历史记录**,日期与状态一律不改写,否则会伪造已完成阶段的窗口
|
|
161
|
+
* - ACTIVE 必须保持 ACTIVE,否则会丢失"当前阶段"信号(getCurrentStage 与 UI 高亮都依赖它)
|
|
162
|
+
* - PLANNED 顺延后标记为 DELAYED,用于记录"尚未开始就已滑档"
|
|
163
|
+
*/
|
|
164
|
+
export function shiftStagesFrom(ctx, goalId, fromStageId, days, reason) {
|
|
165
|
+
const stages = listStages(ctx, goalId);
|
|
166
|
+
const from = stages.find((s) => s.id === fromStageId);
|
|
167
|
+
if (!from)
|
|
168
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Stage not found: ${fromStageId}`);
|
|
169
|
+
const addDaysToKey = (key) => {
|
|
170
|
+
if (!key)
|
|
171
|
+
return undefined;
|
|
172
|
+
const d = new Date(`${key}T00:00:00Z`);
|
|
173
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
174
|
+
return d.toISOString().slice(0, 10);
|
|
175
|
+
};
|
|
176
|
+
const FROZEN = ['COMPLETED', 'SKIPPED'];
|
|
177
|
+
for (const s of stages.filter((x) => x.order >= from.order)) {
|
|
178
|
+
if (FROZEN.includes(s.status))
|
|
179
|
+
continue;
|
|
180
|
+
updateStage(ctx, {
|
|
181
|
+
id: s.id,
|
|
182
|
+
plannedStartDate: addDaysToKey(s.plannedStartDate),
|
|
183
|
+
plannedEndDate: addDaysToKey(s.plannedEndDate),
|
|
184
|
+
status: s.status === 'PLANNED' ? 'DELAYED' : s.status,
|
|
185
|
+
}, reason);
|
|
186
|
+
}
|
|
187
|
+
return listStages(ctx, goalId);
|
|
188
|
+
}
|