@aiti-ai/aiti-git-trace 0.0.0-stage → 0.2.11

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 (41) hide show
  1. package/AGENTS.md +28 -0
  2. package/LICENSE +21 -0
  3. package/api.ts +424 -0
  4. package/check-client.mjs +273 -0
  5. package/client/src/common.ts +179 -0
  6. package/client/src/entry.tsx +81 -0
  7. package/client/src/host-shim.ts +118 -0
  8. package/client/src/jsx-runtime.ts +2 -0
  9. package/client/src/panels.tsx +1377 -0
  10. package/client/src/trace-app.tsx +275 -0
  11. package/client/src/trace-files-row.pure.ts +88 -0
  12. package/client/src/trace-files-row.tsx +110 -0
  13. package/client/src/version-mention.ts +90 -0
  14. package/deploy-commit.mjs +52 -0
  15. package/dev-pack.mjs +246 -0
  16. package/dist/aiti-git-trace-client.mjs +4 -0
  17. package/dist/entry-C_le59dp.js +1594 -0
  18. package/dist/panels-BpC4XLni.js +1522 -0
  19. package/ext-icons-deploy.mjs +24 -0
  20. package/icon.svg +1 -0
  21. package/icons/git-branch.svg +1 -0
  22. package/icons/git-commit-horizontal.svg +1 -0
  23. package/index.ts +705 -0
  24. package/package.json +33 -4
  25. package/reconcile-deps.mjs +41 -0
  26. package/reload-instances.mjs +60 -0
  27. package/scripts/aiti-trace.mjs +117 -0
  28. package/skills/index.json +1 -0
  29. package/skills/rollback-session/SKILL.md +34 -0
  30. package/skills/trace-ops/SKILL.md +31 -0
  31. package/skills/watch-path/SKILL.md +34 -0
  32. package/test/fixtures/ops-sample.jsonl +3 -0
  33. package/tools/deploy.mjs +103 -0
  34. package/tools/trace-compact.mjs +58 -0
  35. package/trace-core.test.mjs +1120 -0
  36. package/trace-core.ts +1346 -0
  37. package/trace-files-row.test.mjs +66 -0
  38. package/tsconfig.check.json +21 -0
  39. package/version-mention.test.mjs +76 -0
  40. package/vite.config.ts +43 -0
  41. package/README.md +0 -3
package/index.ts ADDED
@@ -0,0 +1,705 @@
1
+ /**
2
+ * aiti-git-trace · 编外 AI 变更追踪扩展(server 端入口)
3
+ *
4
+ * 方案:docs/aiti-git-trace-plan.md(WP-C:M1 骨架/API/回撤 + M2-7 部分回滚 + M3-4 workspace 配置)
5
+ *
6
+ * 职责:features 声明(§9 全表)、ui 装配(§8.2)、API 委托 api.ts(可单测)。
7
+ *
8
+ * 红线:绝不写用户仓 .git;git 写路径只允许 trace store(hermetic 三件套,trace-core.ts)。
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
11
+ import { dirname as pathDirname, join, join as pathJoin } from "node:path";
12
+ import { handleApi } from "./api.ts";
13
+ import {
14
+ ensureExcludesFile, readOps, sessionRepoRoots, sanitizeSession,
15
+ repoRootOf, workspaceKey, loadWorkspace, storeOf, storeOk, runGit,
16
+ opsDirFor,
17
+ setAppendListener } from "./trace-core.ts";
18
+
19
+ let configDir = "";
20
+ let extDir = "";
21
+ let hostCtx: any = null;
22
+
23
+ // ── 事件出口(docs/aiti-plan-first-plan.md §3.2 v2)──────────────
24
+ // pi 扩展 appendRow 后经 globalThis.__aitiTraceEmit 广播;本扩展装 sink 转 traceBus。
25
+ // 消费方:services.trace.onOp(进程内,主通道)+ GET /events(SSE,浏览器)。
26
+ // 台账文件仍是唯一事实源;事件丢失由消费方跳号补洞(plan-first §3.4)。
27
+ import { EventEmitter } from "node:events";
28
+ export type TraceOpEvent = { kind: "op"; sessionId: string; row: any };
29
+ const traceBus = new EventEmitter();
30
+ traceBus.setMaxListeners(30);
31
+ let sinkPrev: ((evt: TraceOpEvent) => void) | undefined;
32
+ const traceSink = (evt: TraceOpEvent) => {
33
+ try { sinkPrev?.(evt); } catch { /* 前任消费者错误不阻断 */ }
34
+ traceBus.emit("op", evt);
35
+ };
36
+ // web 侧 appendOps(plan-record/回撤记账)→ 同一 bus:SSE/面板对 web 侧写入同样实时
37
+ setAppendListener((sessionId, row) => {
38
+ try { traceBus.emit("op", { kind: "op", sessionId, row }); } catch { /* 通知失败不影响记账 */ }
39
+ });
40
+
41
+ /** 供 api.ts SSE 端点订阅(onOp 同一 bus) */
42
+ export function onTraceOp(listener: (evt: TraceOpEvent) => void) {
43
+ traceBus.on("op", listener);
44
+ return () => traceBus.off("op", listener);
45
+ }
46
+
47
+ // ── 跨扩展服务出口(§16 / docs/aiti-plan-first-plan.md §3.2)────────────
48
+ // 其他扩展获取 git-trace 数据的唯一通道:ctx.services.get("aiti-git-trace.trace")(DI 方案 P1;
49
+ // 旧通道 __piWebExt.byId("aiti-git-trace").ext.services.trace 为过渡期兜底,生产同步后退役)
50
+ // (进程内同步调用;消费方 capability 探测 + version 协商,缺席/老版本 fail-silent 降级)。
51
+ // 红线对偶:本出口只读;消费方不得直接读本扩展 configDir。
52
+ // 导出 builder 供 node:test 注入临时 configDir(与 def 上的 getter 同一实现)。
53
+ export function traceService(dir: string) {
54
+ const storesOf = (sessionId: string): { root: string; role: string }[] => {
55
+ const sid = sanitizeSession(sessionId);
56
+ const out: { root: string; role: string }[] = [];
57
+ const push = (root: unknown, role: unknown) => {
58
+ if (typeof root !== "string" || !root || out.some((s) => s.root === root)) return;
59
+ out.push({ root, role: role === "primary" ? "primary" : "secondary" });
60
+ };
61
+ try {
62
+ const st = JSON.parse(readFileSync(join(pathDirname(dir), "cache", "state", sid + ".json"), "utf8"));
63
+ if (Array.isArray(st?.stores)) for (const s of st.stores) push(s?.root, s?.role);
64
+ } catch { /* 无状态文件(尚未追踪的会话) */ }
65
+ // 台账补全:state 只是运行时快照,引擎重启会丢之前的副仓;台账才是持久记录
66
+ for (const root of sessionRepoRoots(dir, sid)) push(root, "secondary");
67
+ return out;
68
+ };
69
+ return {
70
+ version: 2,
71
+ /** 进程内监听 op 事件(返回退订)。事件 = 台账行实时通知,丢失由消费方跳号补洞。 */
72
+ onOp(listener: (evt: TraceOpEvent) => void) {
73
+ traceBus.on("op", listener);
74
+ return () => traceBus.off("op", listener);
75
+ },
76
+ /** 台账读取:op > sinceOp 的增量(缺省全量);limit>0 时取最新 N 条。op 单调不回退;
77
+ * 台账清理可致收缩——消费方游标须容忍「最新 op < 游标」并重置。 */
78
+ readOps(sessionId: string, opts?: { sinceOp?: number; limit?: number }) {
79
+ let ops = readOps(dir, sanitizeSession(sessionId));
80
+ const since = Number(opts?.sinceOp);
81
+ if (Number.isFinite(since)) ops = ops.filter((o) => (Number(o?.op) || 0) > since);
82
+ const limit = Number(opts?.limit);
83
+ if (Number.isFinite(limit) && limit > 0) ops = ops.slice(-limit);
84
+ return ops;
85
+ },
86
+ /** 会话识别仓(state stores + 台账补全;主仓在前,角色 primary/secondary) */
87
+ sessionStores(sessionId: string) {
88
+ return storesOf(sessionId);
89
+ },
90
+ /** workspace 关注路径(cwd 定位 workspace key;未配置 → 空数组) */
91
+ watchPaths(cwd: string) {
92
+ if (!cwd) return [];
93
+ const repoRoot = repoRootOf(cwd);
94
+ return loadWorkspace(dir, workspaceKey(repoRoot || cwd)).paths
95
+ .map((p) => ({ path: p.path, repoRoot: p.repoRoot ?? null }));
96
+ },
97
+ /** 仓内 md 清单 v2:tracked + untracked(非 ignore)全列。git 的 ls-files 排除选项单槽互斥
98
+ * (--exclude-standard 与 --exclude-per-directory、以及两个 per-directory 不能叠加,实测),
99
+ * 故拆 4 次调用做集合运算:A=tracked;B1/B2=untracked 分别排 .gitignore/.aitiignore 后取交集;
100
+ * I=tracked∩.aitiignore(-ic)剔除。跨仓去重,上限 200 条。(异步化:git 不阻塞事件循环) */
101
+ async listMd(sessionId: string) {
102
+ const sid = sanitizeSession(sessionId);
103
+ const out: { path: string; root: string }[] = [];
104
+ const seen = new Set<string>();
105
+ const spec = ["--", ":(icase)*.md"];
106
+ const lines = async (store: any, args: string[]): Promise<string[]> => {
107
+ try {
108
+ const r = await runGit(store, args, { allowFail: true });
109
+ return r.stdout.split("\0").map((x) => x.trim()).filter(Boolean);
110
+ } catch { return []; }
111
+ };
112
+ for (const { root } of storesOf(sid)) {
113
+ if (!storeOk(root)) continue;
114
+ const store = storeOf(root, sid);
115
+ const tracked = new Set(await lines(store, ["ls-files", "-cz", ...spec]));
116
+ const b1 = new Set(await lines(store, ["ls-files", "-oz", "--exclude-per-directory=.gitignore", ...spec]));
117
+ const b2 = new Set(await lines(store, ["ls-files", "-oz", "--exclude-per-directory=.aitiignore", ...spec]));
118
+ const ignoredTracked = new Set(await lines(store, ["ls-files", "-icz", "--exclude-per-directory=.aitiignore", ...spec]));
119
+ const paths = [...new Set([...tracked, ...[...b1].filter((p) => b2.has(p))])].filter((p) => !ignoredTracked.has(p));
120
+ for (const p of paths) {
121
+ if (seen.has(root + "\0" + p)) continue;
122
+ seen.add(root + "\0" + p);
123
+ out.push({ path: p, root });
124
+ if (out.length >= 200) return out;
125
+ }
126
+ }
127
+ return out;
128
+ },
129
+ /** 会话台账枚举(aiti-doc-center 冷启动回填用;纯列表零解析,按最近改动排序) */
130
+ sessions(): { sid: string; size: number; mtimeMs: number }[] {
131
+ try {
132
+ const opsDir = opsDirFor(dir);
133
+ return readdirSync(opsDir)
134
+ .filter((f) => f.endsWith(".jsonl"))
135
+ .map((f) => {
136
+ try {
137
+ const st = statSync(join(opsDir, f));
138
+ return { sid: f.slice(0, -".jsonl".length), size: st.size, mtimeMs: Math.round(st.mtimeMs) };
139
+ } catch { return null; }
140
+ })
141
+ .filter((x): x is { sid: string; size: number; mtimeMs: number } => !!x)
142
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
143
+ } catch {
144
+ return [];
145
+ }
146
+ },
147
+ };
148
+ }
149
+
150
+ // ---- 影子仓瘦身(方案四 §5.2):schedule 定时任务 + 启动自检(24h 限流),配置 compact.{enabled,days,maxObjectsMB} ----
151
+ function compactConfig(): { enabled: boolean; days: number; maxObjectsMB: number } {
152
+ let c: any = {};
153
+ try { c = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")); } catch { /* 缺省开 */ }
154
+ const cp = c.compact || {};
155
+ return {
156
+ enabled: cp.enabled !== false,
157
+ days: Number(cp.days) || 30,
158
+ maxObjectsMB: Number(cp.maxObjectsMB) || 300,
159
+ };
160
+ }
161
+
162
+ function compactRepos(dryRun: boolean): void {
163
+ const cc = compactConfig();
164
+ if (!cc.enabled && !dryRun) return;
165
+ // gc 是同步重活(大仓数十秒)——spawn 独立子进程跑 CLI,不占 pi-web 主进程事件循环(§2.4 服务端红线)
166
+ const cli = join(extDir || configDir, "tools", "trace-compact.mjs");
167
+ const fallbackCli = join(configDir, "trace-compact.mjs");
168
+ const script = existsSync(cli) ? cli : existsSync(fallbackCli) ? fallbackCli : "";
169
+ if (!script) { console.warn("[aiti-git-trace·compact] 未找到 trace-compact CLI,跳过本轮"); return; }
170
+ const args = [script, "--all", "--ext-config-dir", configDir,
171
+ "--days", String(cc.days), "--max-objects-mb", String(cc.maxObjectsMB)];
172
+ if (dryRun) args.push("--dry-run");
173
+ try {
174
+ const { spawn } = require("node:child_process") as typeof import("node:child_process");
175
+ const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
176
+ child.on("error", (e) => console.warn("[aiti-git-trace·compact] 子进程失败:", e.message));
177
+ child.unref();
178
+ console.log("[aiti-git-trace·compact] 已派发子进程" + (dryRun ? "(dry-run)" : "") + ":" + script);
179
+ } catch (e: any) { console.warn("[aiti-git-trace·compact] 派发失败:", e?.message || e); }
180
+ // 限流标记(.last-compact):写失败要可见(曾静默失败 → 每次扩展重启都多跑一轮 compact)
181
+ const markRoot = configDir ? pathDirname(configDir) : extDir; // 数据分层:cache 在 <extRoot> 下(configDir = <extRoot>/config)
182
+ try {
183
+ if (!markRoot) throw new Error("configDir/extDir 均为空");
184
+ mkdirSync(join(markRoot, "cache"), { recursive: true });
185
+ writeFileSync(join(markRoot, "cache", ".last-compact"), String(Date.now()));
186
+ } catch (e: any) {
187
+ console.warn("[aiti-git-trace·compact] 限流标记写入失败:", e?.message || e);
188
+ }
189
+ }
190
+
191
+ /** schedule 任务(每日一次;aiti-ai-schedule 聚合执行——不自造轮询 timer) */
192
+ function compactTaskRun(ctx: any): void {
193
+ if (ctx?.configDir) configDir = ctx.configDir;
194
+ compactRepos(false);
195
+ }
196
+
197
+ // 启动自检:距上次成功 compact > 24h 才跑(限流防频繁);延迟 60s 让 startup 先回,失败不影响加载
198
+ function startupCompactSelfCheck(): void {
199
+ try {
200
+ const cc = compactConfig();
201
+ if (!cc.enabled) return;
202
+ if (process.env.AITI_TRACE_COMPACT_OFF) return;
203
+ let last = 0;
204
+ try { last = Number(readFileSync(join(pathDirname(configDir), "cache", ".last-compact"), "utf8")) || 0; } catch {}
205
+ if (Date.now() - last < 24 * 3600 * 1000) return;
206
+ const t = setTimeout(() => { try { compactRepos(false); } catch {} }, 60 * 1000);
207
+ (t as unknown as { unref?: () => void }).unref?.();
208
+ } catch { /* 尽力 */ }
209
+ }
210
+
211
+ export default {
212
+ id: "aiti-git-trace",
213
+ name: "AI 变更追踪",
214
+ version: "0.2.0",
215
+ // 能力声明(依赖与能力模型 P1):本扩展提供「变更追踪」能力(多实现占位——未来
216
+ // 其他追踪实现可同声明 cap.git-trace,消费方 dependsOn 指向能力名由平台解析)。
217
+ // 消费方经 services.trace 出口(ctx.services.get("aiti-git-trace.trace"))使用。
218
+ provides: ["cap.git-trace"],
219
+ ai: {
220
+ manage: true,
221
+ intro: "编外 AI 变更追踪:.aiti-trace 追踪仓按 op 编号记录 AI 会话的文件变更;支持查询操作台账、回滚文件变更、管理 cwd 之外的关注路径(多仓追踪)。",
222
+ skills: "skills",
223
+ },
224
+ icon: "icon.svg",
225
+ client: "dist/aiti-git-trace-client.mjs",
226
+
227
+ // 影子仓瘦身定时任务(注册给 aiti-ai-schedule 聚合执行;compact.enabled=false 可关)
228
+ schedules: [
229
+ { id: "trace-compact", name: "影子仓按时间瘦身(默认保 30 天,删旧分支+回收对象+清无主台账)", intervalSec: 86400, enabled: true, run: compactTaskRun },
230
+ ],
231
+
232
+ // §9 features 全表(7 项;关注路径/autoDiscover 是 workspace 级配置,不经 extman)
233
+ features: [
234
+ { id: "trace.enabled", label: "追踪总开关", type: "boolean", default: true, description: "开启后 AI 会话文件变更写入 .aiti-trace 追踪仓(默认关)" },
235
+ { id: "trace.excludeExtra", label: "追加排除", type: "string", default: "", placeholder: "gitignore 语法,一行一条", description: "在内置排除清单之上追加排除(叠加项目 .gitignore)" },
236
+ { id: "trace.includeExtra", label: "否定豁免", type: "string", default: "", placeholder: "!pattern", description: "用 ! 模式救回被内置排除误伤的路径(父目录整目录排除时受 gitignore 否定规则限制)" },
237
+ { id: "trace.builtinExcludes", label: "内置排除清单", type: "boolean", default: true, description: "关掉后仅剩项目 gitignore + 用户追加排除" },
238
+ { id: "trace.maxFileKB", label: "单文件上限 KB", type: "string", default: "2048", placeholder: "2048", description: "超过该大小的文件不入追踪仓,台账记 skipped" },
239
+ { id: "trace.scope", label: "追踪范围", type: "string", default: "repo", placeholder: "repo / cwd", description: "repo=work-tree 为仓根(含同仓越界);cwd=巨型仓下缩小到会话目录" },
240
+ { id: "trace.autoInit", label: "无 git 自动建仓", type: "boolean", default: true, description: "无 git 目录在文件数预算内自动创建 .aiti-trace 简易追踪(§13.4;系统目录除外)" },
241
+ { id: "trace.autoInitMaxFiles", label: "自动建仓文件数预算", type: "string", default: "2000", placeholder: "2000", description: "超过该文件数的不自动建仓(防生成物目录误建)" },
242
+ { id: "trace.autoInitSeedIgnores", label: "简易追踪补常见 gitignore", type: "boolean", default: false, description: "loose 建仓时顺带补 node_modules/ 等常见条目(默认只写 .aiti-trace/ 一行)" },
243
+ { id: "trace.watchUI", label: "关注路径独立入口", type: "boolean", default: false, description: "在工具条单独显示「⎇ 关注路径」按钮(默认关——入口在时间线面板顶部;切换后刷新页面生效)" },
244
+ { id: "trace.timelineUI", label: "时间线独立入口", type: "boolean", default: true, description: "在工具条单独显示「⎇ 时间线」按钮(默认开——主入口在输入框下方控制行,与压缩/导入同排;关闭后刷新页面生效)" },
245
+ ],
246
+
247
+ ui: {
248
+ toolbar: [
249
+ { id: "aiti-git-trace-watch", label: "关注路径", title: "aiti-git-trace:关注路径管理(workspace 持久 + 本会话识别;只追 git 仓)", icon: "icons/git-branch.svg" },
250
+ { id: "aiti-git-trace-timeline", label: "时间线", title: "aiti-git-trace:本会话 ops 时间线(回滚/看 diff)", icon: "icons/git-commit-horizontal.svg" },
251
+ ],
252
+ dialogs: [
253
+ { id: "aiti-git-trace-watch-dialog", label: "aiti-git-trace · 关注路径" },
254
+ { id: "aiti-git-trace-timeline-dialog", label: "aiti-git-trace · 时间线" },
255
+ { id: "aiti-git-trace-fork-dialog", label: "aiti-git-trace · 回撤" },
256
+ ],
257
+ banners: [{ id: "aiti-git-trace-banner", label: "aiti-git-trace" }],
258
+ // aiti-float dock 静态声明(id 与 client 注册一致):时间线(左栏/右栏双位)+ 关注路径
259
+ sidebarExplorerDock: [
260
+ { id: "timeline", label: "时间线" },
261
+ { id: "watch", label: "关注路径" },
262
+ ],
263
+ rightFileDock: [
264
+ { id: "timeline", label: "时间线" },
265
+ { id: "watch", label: "关注路径" },
266
+ ],
267
+ i18n: {
268
+ en: {
269
+ "gt.watch.short": "Watch",
270
+ "gt.watch.traceEnabled": "File change tracking (this workspace)",
271
+ "gt.watch.traceOff": "Tracking fully disabled for this workspace (stops before the next op).",
272
+ "gt.watch.traceOffHint": "Disable for very large repos if tracking overhead hurts AI responsiveness. Re-enable anytime.",
273
+ "gt.watch.experimental": "Experimental: tracking records file changes on a best-effort basis and does NOT guarantee every file is captured (exclude rules, size limits, permissions, etc. are skipped).",
274
+ "gt.watch.title": "Watch paths",
275
+ "gt.watch.auto": "Auto-discover",
276
+ "gt.watch.add": "Add path",
277
+ "gt.watch.placeholder": "/abs/path or repo root…",
278
+ "gt.watch.persist": "Save to workspace",
279
+ "gt.watch.origin": "Origin",
280
+ "gt.watch.repo": "Repo",
281
+ "gt.watch.nongit": "non-git (listed only)",
282
+ "gt.watch.nogit": "No git repo and auto-init is off (or system dir / over budget) — NOT tracked. Watch paths pointing at git repos still work.",
283
+ "gt.watch.loose": "No git repo here — will auto-create .aiti-trace lightweight tracking (small dir).",
284
+ "gt.watch.oversize": "No git repo, and this dir exceeds the file budget — lightweight tracking skipped (trace.autoInitMaxFiles).",
285
+ "gt.watch.denied": "No git repo, and this is a system/home dir — lightweight tracking skipped.",
286
+ "gt.watch.empty": "No watch paths yet.",
287
+ "gt.watch.del": "Remove",
288
+ "gt.watch.saved": "Saved",
289
+ "gt.timeline.title": "Ops timeline",
290
+ "gt.timeline.session": "Session id",
291
+ "gt.timeline.load": "Load",
292
+ "gt.timeline.showAll": "Show no-change ops",
293
+ "gt.timeline.viewTree": "Lineage",
294
+ "gt.timeline.treeSummary": "Lineage: {n} sessions",
295
+ "gt.timeline.treeCurrent": "current",
296
+ "gt.timeline.treeEmpty": "No fork lineage (this session is not a fork and has no children).",
297
+ "gt.timeline.treeOpen": "Open",
298
+ "gt.timeline.treeTip": "Click a session to view its timeline.",
299
+ "gt.tl.cleanAll": "This will DELETE the entire session ledger (irreversible).",
300
+ "gt.tl.cleanBefore": "This will DELETE ledger entries before op #{n} (op #{n} onwards is kept).",
301
+ "gt.tl.cleanAllTip": "Clean this session's ledger",
302
+ "gt.tl.cleanBeforeTip": "Clean ledger entries before this op (keep this one onwards)",
303
+ "gt.common.confirm": "Confirm",
304
+ "gt.common.cancel": "Cancel",
305
+ "gt.watch.sessionRepos": "Git repos recognized in this session",
306
+ "gt.watch.rolePrimary": "primary",
307
+ "gt.watch.roleSecondary": "secondary",
308
+ "gt.watch.pinned": "watched",
309
+ "gt.watch.pin": "watch",
310
+ "gt.watch.pinTip": "Add to workspace watch paths (persists across sessions)",
311
+ "gt.watch.needCwd": "No workspace context on this page — type a cwd and resolve.",
312
+ "gt.watch.cleanup": "Clean workspace tracking history",
313
+ "gt.watch.cleanupConfirm": "Delete ALL session ledgers + reset the trace store for this workspace?",
314
+ "gt.watch.cleanupTip": "Clears tracking data only: deletes all session ledgers under this workspace and resets the trace store (running sessions auto-rebuild an empty store). Cleared trace history can't be recovered. Files and code in your working path are completely unaffected.",
315
+ "gt.watch.cleanupDone": "Cleaned",
316
+ "gt.watch.cleanupSessions": "sessions",
317
+ "gt.watch.cleanupStore": "store reset",
318
+ "gt.timeline.review": "Review",
319
+ "gt.timeline.reviewSel": "Review selected prompts",
320
+ "gt.timeline.reviewGroup": "Review this prompt (all ops in the group)",
321
+ "gt.timeline.reviewSelTip": "Range = first to last selected group (consecutive groups in between included)",
322
+ "gt.timeline.selGroup": "Select this prompt for range review",
323
+ "gt.timeline.empty": "No ops recorded for this session.",
324
+ "gt.timeline.rollback": "Rollback",
325
+ "gt.timeline.viewdiff": "Diff",
326
+ "gt.timeline.openFile": "Open",
327
+ "gt.timeline.openFileTip": "Open in right dock",
328
+ "gt.timeline.atRef": "Mention",
329
+ "gt.timeline.atRefTip": "Mention in current session input (@ path, not sent)",
330
+ "gt.timeline.atNew": "New session",
331
+ "gt.timeline.atNewTip": "New session with pre-filled mention (cwd aligned to file repo)",
332
+ "gt.timeline.copyPath": "Path",
333
+ "gt.timeline.copyPathTip": "Copy absolute path",
334
+ "gt.timeline.copied": "Copied",
335
+ "gt.tl.rowMention": "@ this line to new session (with version)",
336
+ "gt.timeline.confirm": "Rollback worktree to the state before this op?",
337
+ "gt.timeline.group": "prompt",
338
+ "gt.timeline.partialHint": "This op is a first-touch (no before baseline) and cannot be reverse-applied; its after commit is the earliest restorable point.",
339
+ "gt.timeline.cross": "cross-repo",
340
+ "gt.timeline.leak": "external",
341
+ "gt.timeline.partial": "partial",
342
+ "gt.timeline.viewMsg": "By message",
343
+ "gt.timeline.viewFile": "By file",
344
+ "gt.timeline.viewTip": "Switch grouping: by user message / by file",
345
+ "gt.timeline.fileTouch": "ops",
346
+ "gt.timeline.fileExpand": "Show the ops that touched this file",
347
+ "gt.timeline.fileDiffTip": "Review this file across the whole session range (earliest baseline → latest)",
348
+ "gt.timeline.fileEmpty": "No file-level changes (baseline-only snapshots are not counted)",
349
+ "gt.timeline.truncTip": "Only the first 50 changed paths per op are listed (the ±N count is complete)",
350
+ "gt.fork.title": "AI changes — what to do?",
351
+ "gt.fork.desc": "Operations belonging to the retracted prompt:",
352
+ "gt.fork.rollback": "Rollback file changes",
353
+ "gt.fork.keep": "Keep intermediate changes",
354
+ "gt.fork.cancel": "Cancel retract",
355
+ "gt.fork.done": "File changes rolled back",
356
+ "gt.fork.aiTakeover": "Let AI handle",
357
+ "gt.fork.ignore": "Ignore, keep as-is",
358
+ "gt.fork.skippedHint": "Rolled back; {n} op(s) without baseline were skipped and kept.",
359
+ "gt.fork.failTitle": "Rollback failed midway; some changes not reverted",
360
+ "gt.fork.failDetail": "Applied {applied}; skipped (no baseline) {skipped}; failed at #{op}: {err}",
361
+ "gt.fork.failHint": "\"Let AI handle\" pastes the full scene into the AI input; \"Ignore\" keeps current state.",
362
+ "gt.fork.gap": "no baseline",
363
+ "gt.fork.gapTip": "First tracked op in this repo (no before baseline) — its changes cannot be auto-reverted and will be kept.",
364
+ "gt.banner.text": "This retract involved {n} tracked operations.",
365
+ "gt.common.close": "Close",
366
+ // ── 变更总览(overview 方案 §4,2026-09-08;append-only)──
367
+ "gt.timeline.viewOverview": "Overview",
368
+ "gt.timeline.viewTip2": "Switch view: overview / by operation / lineage",
369
+ "gt.timeline.turnFilter": "Turn filter (overview)",
370
+ "gt.timeline.allTurns": "All turns",
371
+ "gt.ov.ops": "ops",
372
+ "gt.ov.files": "files",
373
+ "gt.ov.rollbacks": "rollbacks",
374
+ "gt.ov.binary": "binary",
375
+ "gt.ov.netNote": "Line counts are net range diffs and may include other edits to the same files.",
376
+ "gt.ov.loading": "Loading…",
377
+ "gt.ov.noFiles": "No file changes in this range.",
378
+ "gt.ov.storeMissing": "Trace store missing for this session's repos — nothing to summarize.",
379
+ "gt.ov.filesTruncated": "More than 2000 changed files; showing the first 2000.",
380
+ "gt.ov.more": "Show more",
381
+ "gt.ov.diffTruncated": "Diff truncated ({n} lines total).",
382
+ "gt.ov.openReview": "Open full diff in review",
383
+ "gt.fork.turnFiles": "{n} files",
384
+ "gt.footer.title": "File changes traced by git-trace beyond the write/edit list ({n} traced ops). Click a file to view its diff in the timeline.",
385
+ "gt.footer.more": "+{n} more",
386
+ "gt.footer.collapse": "Collapse",
387
+ "gt.footer.truncated": "Some changed files are not listed (ledger path cap).",
388
+ },
389
+ "zh-CN": {
390
+ "gt.watch.short": "关注路径",
391
+ "gt.watch.traceEnabled": "文件变更追踪(本工作区)",
392
+ "gt.watch.traceOff": "已彻底关闭本工作区的文件变更追踪(当前会话下一个操作前即停止)。",
393
+ "gt.watch.traceOffHint": "特大项目若追踪影响 AI 流畅度,建议关闭;随时可重新开启。",
394
+ "gt.watch.experimental": "实验性功能:文件追踪尽力记录 AI 会话的文件变更,不保证追踪到所有文件(排除规则、大小上限、权限等均会跳过)。",
395
+ "gt.watch.title": "关注路径",
396
+ "gt.watch.auto": "自动识别(识别 tool/bash 写路径)",
397
+ "gt.watch.add": "添加路径",
398
+ "gt.watch.placeholder": "/绝对路径 或 仓根…",
399
+ "gt.watch.persist": "保存到 workspace",
400
+ "gt.watch.origin": "来源",
401
+ "gt.watch.repo": "归属仓",
402
+ "gt.watch.nongit": "非 git(仅列示不入库)",
403
+ "gt.watch.nogit": "此处没有 git 仓库,且自动建仓已关闭(或系统目录/超预算)——不会追踪。关注路径指向 git 仓的仍会追踪。",
404
+ "gt.watch.loose": "此处没有 git 仓库——目录较小,将自动创建 .aiti-trace 简易追踪。",
405
+ "gt.watch.oversize": "此处没有 git 仓库,且目录超过文件数预算——未建简易追踪(可调 trace.autoInitMaxFiles)。",
406
+ "gt.watch.denied": "此处没有 git 仓库,且属于系统/家目录——不建简易追踪。",
407
+ "gt.watch.empty": "暂无关注路径。",
408
+ "gt.watch.del": "移除",
409
+ "gt.watch.saved": "已保存",
410
+ "gt.timeline.title": "操作时间线",
411
+ "gt.timeline.session": "会话 id",
412
+ "gt.timeline.load": "加载",
413
+ "gt.timeline.showAll": "显示无变更操作",
414
+ "gt.timeline.viewTree": "家谱",
415
+ "gt.timeline.treeSummary": "家谱:{n} 个会话",
416
+ "gt.timeline.treeCurrent": "当前",
417
+ "gt.timeline.treeEmpty": "本会话无 fork 亲缘(非 fork 产生、也无子会话)。",
418
+ "gt.timeline.treeOpen": "打开",
419
+ "gt.timeline.treeTip": "点击任意会话 → 查看其时间线。",
420
+ "gt.tl.cleanAll": "将删除本会话全部台账(不可恢复)。",
421
+ "gt.tl.cleanBefore": "将删除 op #{n} 之前的台账(保留 #{n} 起的记录)。",
422
+ "gt.tl.cleanAllTip": "清空本会话台账",
423
+ "gt.tl.cleanBeforeTip": "清理此操作之前的台账(保留本条起)",
424
+ "gt.common.confirm": "确认",
425
+ "gt.common.cancel": "取消",
426
+ "gt.watch.sessionRepos": "本会话识别的 git 仓",
427
+ "gt.watch.rolePrimary": "主仓",
428
+ "gt.watch.roleSecondary": "副仓",
429
+ "gt.watch.pinned": "已关注",
430
+ "gt.watch.pin": "关注",
431
+ "gt.watch.pinTip": "加入工作区关注路径(跨会话持久)",
432
+ "gt.watch.needCwd": "本页没有工作区上下文——请手动填写 cwd 后 resolve。",
433
+ "gt.watch.cleanup": "清理本工作区追踪历史",
434
+ "gt.watch.cleanupConfirm": "删除本工作区全部会话台账并重置追踪仓?",
435
+ "gt.watch.cleanupTip": "只清除追踪记录:删除本工作区全部会话台账并重置追踪仓(运行中的会话自动重建空仓继续)。追踪历史一经清除无法找回。工作路径里的文件与代码完全不受影响。",
436
+ "gt.watch.cleanupDone": "已清理",
437
+ "gt.watch.cleanupSessions": "个会话",
438
+ "gt.watch.cleanupStore": "追踪仓已重置",
439
+ "gt.timeline.review": "评审",
440
+ "gt.timeline.reviewSel": "评审所选 prompt",
441
+ "gt.timeline.reviewGroup": "评审此 prompt(组内全部操作)",
442
+ "gt.timeline.reviewSelTip": "范围 = 首个~最后选中的组(中间连续组一并包含)",
443
+ "gt.timeline.selGroup": "勾选此 prompt 参与跨组评审",
444
+ "gt.timeline.empty": "该会话暂无操作记录。",
445
+ "gt.timeline.rollback": "回滚",
446
+ "gt.timeline.viewdiff": "看 diff",
447
+ "gt.timeline.openFile": "打开",
448
+ "gt.timeline.openFileTip": "在右侧文件标签页打开",
449
+ "gt.timeline.atRef": "引用",
450
+ "gt.timeline.atRefTip": "引用到当前会话输入框(@ 路径,不发送)",
451
+ "gt.timeline.atNew": "新会话",
452
+ "gt.timeline.atNewTip": "新建会话并预填引用(cwd 对齐文件所在仓)",
453
+ "gt.timeline.copyPath": "路径",
454
+ "gt.timeline.copyPathTip": "复制绝对路径",
455
+ "gt.timeline.copied": "已复制",
456
+ "gt.tl.rowMention": "@ 此行到新会话(带版本)",
457
+ "gt.timeline.confirm": "把工作区回滚到此操作之前的状态?",
458
+ "gt.timeline.group": "用户消息",
459
+ "gt.timeline.partialHint": "该操作为首触(无 before 基线),无法逆向回滚;最早可恢复点为其 after 提交。",
460
+ "gt.timeline.cross": "跨仓",
461
+ "gt.timeline.leak": "外溢",
462
+ "gt.timeline.partial": "部分",
463
+ "gt.timeline.viewMsg": "按消息",
464
+ "gt.timeline.viewFile": "按文件",
465
+ "gt.timeline.viewTip": "切换分组:按用户消息 / 按文件",
466
+ "gt.timeline.fileTouch": "次操作",
467
+ "gt.timeline.fileExpand": "展开触达此文件的操作",
468
+ "gt.timeline.fileDiffTip": "评审此文件的整个会话区间(最早基线 → 最新)",
469
+ "gt.timeline.fileEmpty": "没有文件级变更(首触基线快照不计入)",
470
+ "gt.timeline.truncTip": "单 op 变更路径仅记录前 50 条(±N 计数为全量)",
471
+ "gt.fork.title": "AI 变更处置",
472
+ "gt.fork.desc": "被回撤 prompt 涉及以下操作:",
473
+ "gt.fork.rollback": "回撤文件变更",
474
+ "gt.fork.keep": "保留中间变更",
475
+ "gt.fork.cancel": "取消回撤",
476
+ "gt.fork.done": "已回撤文件变更",
477
+ "gt.fork.aiTakeover": "让 AI 处理",
478
+ "gt.fork.ignore": "忽略,保留现状",
479
+ "gt.fork.skippedHint": "已回撤;{n} 个无基线操作被跳过、其变更保留。",
480
+ "gt.fork.failTitle": "回撤中途失败,部分变更未撤销",
481
+ "gt.fork.failDetail": "已撤 {applied};跳过(无基线){skipped};失败 @#{op}:{err}",
482
+ "gt.fork.failHint": "选「让 AI 处理」会把完整现场细节注入 AI 输入框;「忽略」则保留现状(已撤的生效,剩余变更保留)。",
483
+ "gt.fork.gap": "无基线",
484
+ "gt.fork.gapTip": "该仓首次被追踪,无 before 基线,此变更无法自动撤销(将保留)。",
485
+ "gt.banner.text": "本次回撤涉及 {n} 个被追踪操作。",
486
+ "gt.common.close": "关闭",
487
+ // ── 变更总览(overview 方案 §4,2026-09-08;append-only)──
488
+ "gt.timeline.viewOverview": "变更总览",
489
+ "gt.timeline.viewTip2": "切换视图:变更总览 / 按操作 / 家谱",
490
+ "gt.timeline.turnFilter": "轮次过滤(总览)",
491
+ "gt.timeline.allTurns": "全部轮次",
492
+ "gt.ov.ops": "次操作",
493
+ "gt.ov.files": "个文件",
494
+ "gt.ov.rollbacks": "次回撤",
495
+ "gt.ov.binary": "二进制",
496
+ "gt.ov.netNote": "行数按区间净变更,可能含对同一文件的其他改动。",
497
+ "gt.ov.loading": "加载中…",
498
+ "gt.ov.noFiles": "该范围内没有文件级变更。",
499
+ "gt.ov.storeMissing": "本会话涉及的仓没有 trace store——无变更可汇总。",
500
+ "gt.ov.filesTruncated": "变更文件超过 2000,仅显示前 2000。",
501
+ "gt.ov.more": "显示更多",
502
+ "gt.ov.diffTruncated": "Diff 已截断(共 {n} 行)。",
503
+ "gt.ov.openReview": "在评审中打开完整 diff",
504
+ "gt.fork.turnFiles": "{n} 个文件",
505
+ "gt.footer.title": "git-trace 追踪到、「改动的文件」之外的变更({n} 个被追踪操作);点文件在时间线中查看 diff。",
506
+ "gt.footer.more": "+{n}",
507
+ "gt.footer.collapse": "收起",
508
+ "gt.footer.truncated": "部分变更文件未列出(台账路径数截断)。",
509
+ },
510
+ "zh-TW": {
511
+ "gt.watch.short": "關注路徑",
512
+ "gt.watch.traceEnabled": "檔案變更追蹤(本工作區)",
513
+ "gt.watch.traceOff": "已徹底關閉本工作區的檔案變更追蹤(當前會話下一個操作前即停止)。",
514
+ "gt.watch.traceOffHint": "特大專案若追蹤影響 AI 流暢度,建議關閉;隨時可重新開啟。",
515
+ "gt.watch.experimental": "實驗性功能:檔案追蹤盡力記錄 AI 會話的檔案變更,不保證追蹤到所有檔案(排除規則、大小上限、權限等均會跳過)。",
516
+ "gt.watch.title": "關注路徑",
517
+ "gt.watch.auto": "自動識別(識別 tool/bash 寫路徑)",
518
+ "gt.watch.add": "新增路徑",
519
+ "gt.watch.placeholder": "/絕對路徑 或 倉根…",
520
+ "gt.watch.persist": "儲存到 workspace",
521
+ "gt.watch.origin": "來源",
522
+ "gt.watch.repo": "歸屬倉",
523
+ "gt.watch.nongit": "非 git(僅列示不入庫)",
524
+ "gt.watch.nogit": "此處沒有 git 倉庫,且自動建倉已關閉(或系統目錄/超預算)——不會追蹤。關注路徑指向 git 倉的仍會追蹤。",
525
+ "gt.watch.loose": "此處沒有 git 倉庫——目錄較小,將自動建立 .aiti-trace 簡易追蹤。",
526
+ "gt.watch.oversize": "此處沒有 git 倉庫,且目錄超過檔案數預算——未建簡易追蹤(可調 trace.autoInitMaxFiles)。",
527
+ "gt.watch.denied": "此處沒有 git 倉庫,且屬於系統/家目錄——不建簡易追蹤。",
528
+ "gt.watch.empty": "暫無關注路徑。",
529
+ "gt.watch.del": "移除",
530
+ "gt.watch.saved": "已儲存",
531
+ "gt.timeline.title": "操作時間線",
532
+ "gt.timeline.session": "會話 id",
533
+ "gt.timeline.load": "載入",
534
+ "gt.timeline.showAll": "顯示無變更操作",
535
+ "gt.timeline.viewTree": "家譜",
536
+ "gt.timeline.treeSummary": "家譜:{n} 個會話",
537
+ "gt.timeline.treeCurrent": "當前",
538
+ "gt.timeline.treeEmpty": "本會話無 fork 親緣(非 fork 產生、也無子會話)。",
539
+ "gt.timeline.treeOpen": "打開",
540
+ "gt.timeline.treeTip": "點擊任意會話 → 查看其時間線。",
541
+ "gt.tl.cleanAll": "將刪除本會話全部台帳(不可恢復)。",
542
+ "gt.tl.cleanBefore": "將刪除 op #{n} 之前的台帳(保留 #{n} 起的記錄)。",
543
+ "gt.tl.cleanAllTip": "清空本會話台帳",
544
+ "gt.tl.cleanBeforeTip": "清理此操作之前的台帳(保留本條起)",
545
+ "gt.common.confirm": "確認",
546
+ "gt.common.cancel": "取消",
547
+ "gt.watch.sessionRepos": "本會話識別的 git 倉",
548
+ "gt.watch.rolePrimary": "主倉",
549
+ "gt.watch.roleSecondary": "副倉",
550
+ "gt.watch.pinned": "已關注",
551
+ "gt.watch.pin": "關注",
552
+ "gt.watch.pinTip": "加入工作區關注路徑(跨會話持久)",
553
+ "gt.watch.needCwd": "本頁沒有工作區上下文——請手動填寫 cwd 後 resolve。",
554
+ "gt.watch.cleanup": "清理本工作區追蹤歷史",
555
+ "gt.watch.cleanupConfirm": "刪除本工作區全部會話台帳並重置追蹤倉?",
556
+ "gt.watch.cleanupTip": "只清除追蹤記錄:刪除本工作區全部會話台帳並重置追蹤倉(執行中的會話自動重建空倉繼續)。追蹤歷史一經清除無法找回。工作路徑裡的檔案與程式碼完全不受影響。",
557
+ "gt.watch.cleanupDone": "已清理",
558
+ "gt.watch.cleanupSessions": "個會話",
559
+ "gt.watch.cleanupStore": "追蹤倉已重置",
560
+ "gt.timeline.review": "評審",
561
+ "gt.timeline.reviewSel": "評審所選 prompt",
562
+ "gt.timeline.reviewGroup": "評審此 prompt(組內全部操作)",
563
+ "gt.timeline.reviewSelTip": "範圍 = 首個~最後選中的組(中間連續組一併包含)",
564
+ "gt.timeline.selGroup": "勾選此 prompt 參與跨組評審",
565
+ "gt.timeline.empty": "該會話暫無操作記錄。",
566
+ "gt.timeline.rollback": "回滾",
567
+ "gt.timeline.viewdiff": "看 diff",
568
+ "gt.timeline.openFile": "打開",
569
+ "gt.timeline.openFileTip": "在右側檔案分頁開啟",
570
+ "gt.timeline.atRef": "引用",
571
+ "gt.timeline.atRefTip": "引用到當前會話輸入框(@ 路徑,不發送)",
572
+ "gt.timeline.atNew": "新會話",
573
+ "gt.timeline.atNewTip": "新建會話並預填引用(cwd 對齊檔案所在倉)",
574
+ "gt.timeline.copyPath": "路徑",
575
+ "gt.timeline.copyPathTip": "複製絕對路徑",
576
+ "gt.timeline.copied": "已複製",
577
+ "gt.tl.rowMention": "@ 此行到新會話(帶版本)",
578
+ "gt.timeline.confirm": "把工作區回滾到此操作之前的狀態?",
579
+ "gt.timeline.group": "用戶訊息",
580
+ "gt.timeline.partialHint": "該操作為首觸(無 before 基線),無法逆向回滾;最早可恢復點為其 after 提交。",
581
+ "gt.timeline.cross": "跨倉",
582
+ "gt.timeline.leak": "外溢",
583
+ "gt.timeline.partial": "部分",
584
+ "gt.timeline.viewMsg": "按訊息",
585
+ "gt.timeline.viewFile": "按檔案",
586
+ "gt.timeline.viewTip": "切換分組:按用戶訊息 / 按檔案",
587
+ "gt.timeline.fileTouch": "次操作",
588
+ "gt.timeline.fileExpand": "展開觸達此檔案的操作",
589
+ "gt.timeline.fileDiffTip": "評審此檔案的整個會話區間(最早基線 → 最新)",
590
+ "gt.timeline.fileEmpty": "沒有檔案級變更(首觸基線快照不計入)",
591
+ "gt.timeline.truncTip": "單 op 變更路徑僅記錄前 50 條(±N 計數為全量)",
592
+ "gt.fork.title": "AI 變更處置",
593
+ "gt.fork.desc": "被回撤 prompt 涉及以下操作:",
594
+ "gt.fork.rollback": "回撤檔案變更",
595
+ "gt.fork.keep": "保留中間變更",
596
+ "gt.fork.cancel": "取消回撤",
597
+ "gt.fork.done": "已回撤檔案變更",
598
+ "gt.fork.aiTakeover": "讓 AI 處理",
599
+ "gt.fork.ignore": "忽略,保留現狀",
600
+ "gt.fork.skippedHint": "已回撤;{n} 個無基線操作被跳過、其變更保留。",
601
+ "gt.fork.failTitle": "回撤中途失敗,部分變更未撤銷",
602
+ "gt.fork.failDetail": "已撤 {applied};跳過(無基線){skipped};失敗 @#{op}:{err}",
603
+ "gt.fork.failHint": "選「讓 AI 處理」會把完整現場細節注入 AI 輸入框;「忽略」則保留現狀(已撤的生效,剩餘變更保留)。",
604
+ "gt.fork.gap": "無基線",
605
+ "gt.fork.gapTip": "該倉首次被追蹤,無 before 基線,此變更無法自動撤銷(將保留)。",
606
+ "gt.banner.text": "本次回撤涉及 {n} 個被追蹤操作。",
607
+ "gt.common.close": "關閉",
608
+ // ── 變更總覽(overview 方案 §4,2026-09-08;append-only)──
609
+ "gt.timeline.viewOverview": "變更總覽",
610
+ "gt.timeline.viewTip2": "切換視圖:變更總覽 / 按操作 / 家譜",
611
+ "gt.timeline.turnFilter": "輪次過濾(總覽)",
612
+ "gt.timeline.allTurns": "全部輪次",
613
+ "gt.ov.ops": "次操作",
614
+ "gt.ov.files": "個檔案",
615
+ "gt.ov.rollbacks": "次回撤",
616
+ "gt.ov.binary": "二進位",
617
+ "gt.ov.netNote": "行數按區間淨變更,可能含對同一檔案的其他改動。",
618
+ "gt.ov.loading": "載入中…",
619
+ "gt.ov.noFiles": "該範圍內沒有檔案級變更。",
620
+ "gt.ov.storeMissing": "本會話涉及的倉沒有 trace store——無變更可彙總。",
621
+ "gt.ov.filesTruncated": "變更檔案超過 2000,僅顯示前 2000。",
622
+ "gt.ov.more": "顯示更多",
623
+ "gt.ov.diffTruncated": "Diff 已截斷(共 {n} 行)。",
624
+ "gt.ov.openReview": "在評審中開啟完整 diff",
625
+ "gt.fork.turnFiles": "{n} 個檔案",
626
+ "gt.footer.title": "git-trace 追蹤到、「改動的檔案」之外的變更({n} 個被追蹤操作);點檔案在時間線中查看 diff。",
627
+ "gt.footer.more": "+{n}",
628
+ "gt.footer.collapse": "收起",
629
+ "gt.footer.truncated": "部分變更檔案未列出(台帳路徑數截斷)。",
630
+ },
631
+ },
632
+ },
633
+
634
+ // 跨扩展服务出口(惰性 getter:startup 后 configDir 才就绪;消费方每次访问现取,热重载/重装不悬空)
635
+ services: {
636
+ get trace() { return traceService(configDir); },
637
+ },
638
+
639
+ server: {
640
+ startup(ctx: any) {
641
+ hostCtx = ctx;
642
+ configDir = ctx.configDir || "";
643
+ extDir = ctx.dir || "";
644
+ try { ensureExcludesFile(configDir); } catch {}
645
+ // 数据分层(state 层):台账落 state/ops(opsFile);存量迁移由部署流程的一次性脚本负责
646
+ // (scripts/migrate-state-tier.mjs),扩展不带运行时迁移。
647
+ // 跨扩展服务出口(DI 方案 P1):ctx.services 通道。traceService 方法全部惰性读盘,
648
+ // provide 时固化实例安全;旧通道(__piWebExt.byId 穿透)双轨保留至消费方迁完。
649
+ ctx.services?.provide?.("aiti-git-trace.trace", traceService(configDir));
650
+ // 事件 sink:链式安装(保留前任),shutdown 时恢复——防热重载逐层堆旧 wrapper
651
+ sinkPrev = (globalThis as any).__aitiTraceEmit;
652
+ (globalThis as any).__aitiTraceEmit = traceSink;
653
+ // 特性值落成 config.json(pi 扩展引擎创建时读一次;切换后需 reload 生效)
654
+ try {
655
+ // features.json 在 <dataRoot> 根级(configDir = <dataRoot>/<extId>/config → 上溯两级)
656
+ const feats = JSON.parse(readFileSync(join(pathDirname(pathDirname(configDir)), "features.json"), "utf8"));
657
+ const mine = feats["aiti-git-trace"] ?? {};
658
+ const parseLines = (v: unknown) => String(v ?? "").split("\n").map((x: string) => x.trim()).filter(Boolean);
659
+ const cfg = {
660
+ enabled: mine["trace.enabled"] !== false,
661
+ leakPolicy: "warn", // 已定调只记录不拦截(feature 项已移除;字段保留兼容旧 config.json)
662
+ autoInit: mine["trace.autoInit"] !== false,
663
+ autoInitMaxFiles: parseInt(String(mine["trace.autoInitMaxFiles"] || "2000"), 10) || 2000,
664
+ seedCommonIgnores: mine["trace.autoInitSeedIgnores"] === true,
665
+ // [P2-3] 以下 5 个此前为死开关——现已接通(引擎 opts 有对应消费方)
666
+ excludeExtra: parseLines(mine["trace.excludeExtra"]),
667
+ includeExtra: parseLines(mine["trace.includeExtra"]),
668
+ builtinExcludes: mine["trace.builtinExcludes"] !== false,
669
+ maxFileKB: parseInt(String(mine["trace.maxFileKB"] || "2048"), 10) || 2048,
670
+ scope: mine["trace.scope"] === "cwd" ? "cwd" : "repo",
671
+ };
672
+ writeFileSync(join(configDir, "config.json"), JSON.stringify(cfg), "utf8");
673
+ } catch { /* features.json 缺失/坏行:保持缺省(开) */ }
674
+ startupCompactSelfCheck(); // 影子仓瘦身启动自检(24h 限流;异步延迟执行)
675
+ console.log("[aiti-git-trace] 就绪 · configDir=" + configDir);
676
+ },
677
+
678
+ shutdown() {
679
+ // 卸载 sink:仅当全局槽位仍是自己(后来者未覆盖时才安全恢复前任)
680
+ try {
681
+ if ((globalThis as any).__aitiTraceEmit === traceSink) {
682
+ (globalThis as any).__aitiTraceEmit = sinkPrev;
683
+ }
684
+ } catch {}
685
+ sinkPrev = undefined;
686
+ traceBus.removeAllListeners("op");
687
+ },
688
+
689
+ async api(req: Request, ctx: any) {
690
+ return handleApi(req, { path: ctx.path, searchParams: ctx.searchParams }, {
691
+ configDir, dir: extDir,
692
+ services: hostCtx?.services, // [P1-3] 意图维 join:services.plans(plan-first)进程内消费
693
+ onTraceOp,
694
+ // 宿主 allowed-roots 安全边界(缺失时放行——本地单测环境)
695
+ allowRoot: async (p: string) => {
696
+ try {
697
+ const fa = hostCtx?.core?.fileAccess;
698
+ if (!fa?.isAllowed) return true;
699
+ return await fa.isAllowed(p);
700
+ } catch { return true; }
701
+ },
702
+ });
703
+ },
704
+ },
705
+ } as any;