@andyqiu/codeforge 0.5.4 → 0.5.5

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.
@@ -59,7 +59,7 @@ fallback_models:
59
59
  - ❌ 不允许**为并行而并行**:改同文件 / 步骤间有真实依赖时必须串行
60
60
  - ❌ 不允许在父对话直接吐长交付物内容
61
61
  - ❌ 不允许自动派 coder 修 BLOCK;REQUEST_CHANGES 允许自动派 coder 修,**最多 3 次**
62
- - **不允许自己主动调 `session_merge action=merge`** —— merge 由用户通过 `/merge` 命令触发(ADR:worktree-session-isolation)
62
+ - codeforge 可在 review-fix-review APPROVE 后调 `session_merge action=merge`;其他 sub-agent 调此操作会被 guard 拦截(ADR:codeforge-merge-permission);force=true 必须先告知用户并解释跳过 review 的理由
63
63
 
64
64
  ## 能力边界(场景分派表)
65
65
 
@@ -78,6 +78,7 @@ fallback_models:
78
78
  | **reviewer 报 REQUEST_CHANGES(代码 review,`code_review_loop_count` < 3)** | **自动派 coder 修**(带具体到行的 reviewer 意见 + 原 plan_id + sessionId),loop +1 | ❌ 同时派多个 coder;❌ 不带 reviewer 意见 |
79
79
  | **reviewer 报 REQUEST_CHANGES(loop = 3)** | 转告用户「reviewer 3 次仍 REQUEST_CHANGES」,问「接受 `/merge` / 手动改 / `/discard-session`」三选一 | ❌ 继续派 coder |
80
80
  | **reviewer 报 BLOCK** | 转告用户 + 建议派 planner 重设计(带原 plan_id + BLOCK 理由),等用户拍板 | ❌ 派 coder 强行绕过 BLOCK |
81
+ | **review-fix-review 全部通过(APPROVE)** | codeforge orchestrator 自动调 `session_merge action=merge` 完成合入(ADR:codeforge-merge-permission);用户也可通过 `/merge` 命令触发 | ❌ force=true 不告知用户;❌ 派其他 sub-agent 调 session_merge action=merge(会被 guard 拦截) |
81
82
  | **coder 回报「PRE 阻断、拒绝启动」** | 转告用户阻断点 + 解除路径,等用户拍板,**不自动派下一棒** | ❌ 自动重派 coder 并强塞 `pre_ack=` |
82
83
  | 用户中途插入新需求(原 task 未结束) | 询问用户「先取消 / 等当前完 / 并行」三选一 | ❌ 默默丢弃;❌ 同时派多个不告知 |
83
84
  | **可并行任务** | 自动判断依赖,无强依赖时自动并行调度 | ❌ 串行派 N 个独立 task |
@@ -12,6 +12,7 @@ description: 查看或切换当前 session 的自主度档位(step/semi/full/a
12
12
  - `/autonomy semi` — 危险工具确认,安全工具自动(默认)
13
13
  - `/autonomy full` — 全自动工具执行(reviewer APPROVE 后仍需 `/merge` 手动触发)
14
14
  - `/autonomy auto` — 全自动 + APPROVE 自动 merge(**最小干预模式**)
15
+ - `/autonomy status` — **Phase 2.2** 显示实时状态(档位 / paused / 五维 budget / 全局今日 USD / block-pending)
15
16
 
16
17
  ## ⚠️ auto 模式须知
17
18
 
@@ -23,6 +24,7 @@ description: 查看或切换当前 session 的自主度档位(step/semi/full/a
23
24
  - **REQUEST_CHANGES** 自动循环(≤ 3 次),仍由 codeforge agent 派 coder 修
24
25
  - **BLOCK** 通过 channels 通知(需配置 `.codeforge/channels.json`)
25
26
  - channels 全失败时 fallback 写 `<runtimeDir>/sessions/autonomous-blocks.ndjson`
27
+ - **Phase 2.2**:下次 session.start 时 session-recovery plugin 自动扫描并提示用户处理
26
28
  - **仅根 session 生效**(子 session 一律跳过,防 orchestrator 嵌套)
27
29
 
28
30
  ### 默认预算上限(任一耗尽自动降回 semi)
@@ -34,6 +36,17 @@ description: 查看或切换当前 session 的自主度档位(step/semi/full/a
34
36
  | Wall clock | 30 分钟 |
35
37
  | auto merge 次数 | 5 |
36
38
  | REQUEST_CHANGES 循环 | 3 |
39
+ | **全局每日 USD(Phase 2)** | **$10**(跨 session 累计;env `CODEFORGE_AUTONOMY_GLOBAL_MAX_USD` 或 `.codeforge/autonomy.json::global_max_usd_per_day` 覆盖) |
40
+
41
+ ### Phase 2.2 新增:stuck-detector
42
+
43
+ driver 在每次 reviewer 完成时喂 progress 信号:
44
+ - APPROVE → progress +1(任务推进)
45
+ - REQUEST_CHANGES → progress 0(无推进)
46
+ - BLOCK → progress -1(明显退步)
47
+
48
+ 连续 5 轮无进展 → 自动降回 semi + emit `autonomous.stuck` channel event。
49
+ **用户 `/autonomy auto` 显式切回时自动 reset stuck 窗口**(清 stale 信号)。
37
50
 
38
51
  ### 逃生口
39
52
 
@@ -41,6 +54,7 @@ description: 查看或切换当前 session 的自主度档位(step/semi/full/a
41
54
  - `/discard-session` — 不可逆,硬丢 worktree + branch
42
55
  - budget 耗尽 — 自动降回 semi
43
56
  - BLOCK — 强制通知 + 降档
57
+ - stuck 检测 — 5 轮无进展自动降档
44
58
 
45
59
  ## 行为对比
46
60
 
@@ -53,10 +67,25 @@ description: 查看或切换当前 session 的自主度档位(step/semi/full/a
53
67
 
54
68
  ## 配置存储
55
69
 
56
- `<runtimeDir>/autonomy/<sessionId>.json`(withFileLock 跨进程并发安全)
70
+ - per-session:`<runtimeDir>/autonomy/<sessionId>.json`(withFileLock 跨进程并发安全)
71
+ - 全局每日 USD:`<runtimeDir>/autonomy/global-budget-<YYYY-MM-DD>.json`(withFileLock)
57
72
 
58
73
  ## 关联 ADR
59
74
 
60
75
  - [autonomous-mode](../docs/adr/autonomous-mode.md) — 主 ADR(设计、双重验证、五维预算)
76
+ - [autonomy-global-budget](../docs/adr/autonomy-global-budget.md) — Phase 2 全局 USD 预算 + stuck-detector + block-pending 消费
61
77
  - [plandex-three-autonomy-modes](../docs/adr/plandex-three-autonomy-modes.md) — 三档基线
62
78
  - [worktree-session-isolation](../docs/adr/worktree-session-isolation.md) — merge 闭环
79
+
80
+ ---
81
+
82
+ ## /autonomy status 处理流程(Phase 2.2)
83
+
84
+ 当用户输入 `/autonomy status`:
85
+
86
+ 1. 调 `tools/autonomy-status.ts::getAutonomyStatus(<absRoot>, <sessionId>)` 拿 `AutonomyStatusReport`
87
+ 2. 调 `renderAutonomyStatus(report)` 转 Markdown 文本
88
+ 3. 直接输出渲染结果到对话(无需 toast / channels)
89
+
90
+ > 实现位置:`tools/autonomy-status.ts`(与 `tools/autonomy-mode.ts` 同款 ts 工具)。
91
+ > agent 通过 read + 内联调用使用,无需 opencode tool 注册(零侵入约束)。
package/dist/index.js CHANGED
@@ -13393,13 +13393,6 @@ async function getCurrentWorktreeHead(worktreePath) {
13393
13393
  return "";
13394
13394
  }
13395
13395
  }
13396
- async function getCurrentMainHead(mainRoot) {
13397
- try {
13398
- return (await runGit2(path13.resolve(mainRoot), ["rev-parse", "HEAD"])).trim();
13399
- } catch {
13400
- return "";
13401
- }
13402
- }
13403
13396
  async function isWorktreeDirty(worktreePath) {
13404
13397
  try {
13405
13398
  const out = (await runGit2(worktreePath, ["status", "--porcelain"])).trim();
@@ -13437,6 +13430,27 @@ Codeforge-Base: ${baseSha.slice(0, 12)}`;
13437
13430
  return subject + body + footer;
13438
13431
  }
13439
13432
  var ORPHAN_GRACE_MS = 60000;
13433
+ async function pruneDiscardedRegistryEntries(mainRoot, opts = {}) {
13434
+ const keepRecent = opts.keepRecent ?? 50;
13435
+ if (keepRecent < 0) {
13436
+ throw new Error(`pruneDiscardedRegistryEntries: keepRecent 必须 ≥ 0,收到 ${keepRecent}`);
13437
+ }
13438
+ return await mutateRegistry(path13.resolve(mainRoot), (reg) => {
13439
+ const discarded = [];
13440
+ const others = [];
13441
+ for (const e of reg.entries) {
13442
+ if (e.status === "discarded")
13443
+ discarded.push(e);
13444
+ else
13445
+ others.push(e);
13446
+ }
13447
+ discarded.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0);
13448
+ const kept = discarded.slice(0, keepRecent);
13449
+ const pruned = discarded.length - kept.length;
13450
+ reg.entries = [...others, ...kept];
13451
+ return { pruned, kept: kept.length };
13452
+ });
13453
+ }
13440
13454
  async function pruneOrphanWorktrees(mainRoot) {
13441
13455
  const resolved = path13.resolve(mainRoot);
13442
13456
  const cleaned = [];
@@ -13554,7 +13568,12 @@ async function pruneOrphanWorktrees(mainRoot) {
13554
13568
  failed.push({ worktreePath: candidate, error: lastError ?? "unknown" });
13555
13569
  }
13556
13570
  }
13557
- return { cleaned, failed, skipped };
13571
+ let discardedPruned = 0;
13572
+ try {
13573
+ const r = await pruneDiscardedRegistryEntries(resolved);
13574
+ discardedPruned = r.pruned;
13575
+ } catch {}
13576
+ return { cleaned, failed, skipped, discardedPruned };
13558
13577
  }
13559
13578
 
13560
13579
  // lib/merge-loop.ts
@@ -19133,6 +19152,92 @@ function isRecoveryWorthShowing(plan) {
19133
19152
  return hasSignal;
19134
19153
  }
19135
19154
 
19155
+ // lib/block-pending.ts
19156
+ init_runtime_paths();
19157
+ import { promises as fs16 } from "node:fs";
19158
+ import * as path20 from "node:path";
19159
+ function blockPendingFilePath(absRoot) {
19160
+ const rd = runtimeDir(absRoot, { ensure: false });
19161
+ return path20.join(rd, "sessions", "autonomous-blocks.ndjson");
19162
+ }
19163
+ function consumeLockPath(absRoot) {
19164
+ return blockPendingFilePath(absRoot) + ".consume.lock";
19165
+ }
19166
+ async function scanBlockPending(absRoot, filterSessionId) {
19167
+ const file = blockPendingFilePath(absRoot);
19168
+ let raw;
19169
+ try {
19170
+ raw = await fs16.readFile(file, "utf8");
19171
+ } catch {
19172
+ return [];
19173
+ }
19174
+ if (!raw)
19175
+ return [];
19176
+ const consumed = new Set;
19177
+ const entries = [];
19178
+ for (const line of raw.split(`
19179
+ `)) {
19180
+ const trimmed = line.trim();
19181
+ if (!trimmed)
19182
+ continue;
19183
+ let obj;
19184
+ try {
19185
+ obj = JSON.parse(trimmed);
19186
+ } catch {
19187
+ continue;
19188
+ }
19189
+ if (!obj || typeof obj !== "object")
19190
+ continue;
19191
+ if (obj["type"] === "consume") {
19192
+ const sid = obj["sessionId"];
19193
+ const ts = obj["timestamp"];
19194
+ if (typeof sid === "string" && typeof ts === "string") {
19195
+ consumed.add(`${sid}|${ts}`);
19196
+ }
19197
+ continue;
19198
+ }
19199
+ const sessionId = obj["sessionId"];
19200
+ const timestamp = obj["timestamp"];
19201
+ if (typeof sessionId !== "string" || typeof timestamp !== "string")
19202
+ continue;
19203
+ const entry = {
19204
+ sessionId,
19205
+ timestamp,
19206
+ reason: typeof obj["reason"] === "string" ? obj["reason"] : undefined,
19207
+ summary_excerpt: typeof obj["summary_excerpt"] === "string" ? obj["summary_excerpt"] : undefined,
19208
+ consumed_at: typeof obj["consumed_at"] === "string" ? obj["consumed_at"] : undefined
19209
+ };
19210
+ entries.push(entry);
19211
+ }
19212
+ return entries.filter((e) => {
19213
+ if (e.consumed_at)
19214
+ return false;
19215
+ if (consumed.has(`${e.sessionId}|${e.timestamp}`))
19216
+ return false;
19217
+ if (filterSessionId && e.sessionId !== filterSessionId)
19218
+ return false;
19219
+ return true;
19220
+ });
19221
+ }
19222
+ async function markBlocksConsumed(absRoot, entries) {
19223
+ if (entries.length === 0)
19224
+ return;
19225
+ const file = blockPendingFilePath(absRoot);
19226
+ await fs16.mkdir(path20.dirname(file), { recursive: true });
19227
+ const now = new Date().toISOString();
19228
+ const lines = entries.map((e) => ({
19229
+ type: "consume",
19230
+ sessionId: e.sessionId,
19231
+ timestamp: e.timestamp,
19232
+ consumed_at: now
19233
+ })).map((row) => JSON.stringify(row)).join(`
19234
+ `) + `
19235
+ `;
19236
+ await withFileLock(consumeLockPath(absRoot), async () => {
19237
+ await fs16.appendFile(file, lines, "utf8");
19238
+ });
19239
+ }
19240
+
19136
19241
  // plugins/session-recovery.ts
19137
19242
  var PLUGIN_NAME17 = "session-recovery";
19138
19243
  logLifecycle(PLUGIN_NAME17, "import", {});
@@ -19149,10 +19254,23 @@ async function processSessionStart(currentSessionId, opts = {}) {
19149
19254
  return { ok: false, injected: false, reason: "scan_error", error: r.error };
19150
19255
  }
19151
19256
  const plan = r.plan;
19152
- if (!isRecoveryWorthShowing(plan)) {
19153
- return { ok: true, injected: false, plan, reason: "no_signal" };
19257
+ let pendingBlocks = [];
19258
+ if (opts.root) {
19259
+ try {
19260
+ pendingBlocks = await scanBlockPending(opts.root);
19261
+ } catch (err) {
19262
+ opts.log?.warn?.(`[${PLUGIN_NAME17}] scanBlockPending 异常:${err instanceof Error ? err.message : String(err)}`);
19263
+ }
19264
+ }
19265
+ const hasPendingBlocks = pendingBlocks.length > 0;
19266
+ if (!isRecoveryWorthShowing(plan) && !hasPendingBlocks) {
19267
+ return { ok: true, injected: false, plan, reason: "no_signal", pendingBlocks };
19154
19268
  }
19155
- const prompt = renderPrompt(plan);
19269
+ const blockPrompt = hasPendingBlocks ? renderBlockPendingPrompt(pendingBlocks) : "";
19270
+ const recoveryPrompt = isRecoveryWorthShowing(plan) ? renderPrompt(plan) : "";
19271
+ const prompt = [blockPrompt, recoveryPrompt].filter((s) => s.length > 0).join(`
19272
+
19273
+ `);
19156
19274
  const injection = { source: "session-recovery", plan, prompt };
19157
19275
  if (opts.injectRecovery) {
19158
19276
  try {
@@ -19160,10 +19278,36 @@ async function processSessionStart(currentSessionId, opts = {}) {
19160
19278
  } catch (err) {
19161
19279
  const msg = err instanceof Error ? err.message : String(err);
19162
19280
  opts.log?.warn?.(`[${PLUGIN_NAME17}] injectRecovery 异常:${msg}`);
19163
- return { ok: false, injected: false, plan, reason: "inject_error", error: msg };
19281
+ return { ok: false, injected: false, plan, reason: "inject_error", error: msg, pendingBlocks };
19282
+ }
19283
+ }
19284
+ if (hasPendingBlocks && opts.root) {
19285
+ try {
19286
+ await markBlocksConsumed(opts.root, pendingBlocks);
19287
+ } catch (err) {
19288
+ opts.log?.warn?.(`[${PLUGIN_NAME17}] markBlocksConsumed 异常:${err instanceof Error ? err.message : String(err)}`);
19164
19289
  }
19165
19290
  }
19166
- return { ok: true, injected: true, plan, reason: "ok" };
19291
+ return { ok: true, injected: true, plan, reason: "ok", pendingBlocks };
19292
+ }
19293
+ function renderBlockPendingPrompt(entries) {
19294
+ const lines = [];
19295
+ lines.push(`⚠️ **${entries.length} 个未读 BLOCK 通知**(上次 auto 模式 reviewer 阻断、channels 通知失败):`);
19296
+ lines.push("");
19297
+ entries.forEach((e, i) => {
19298
+ const sidShort = e.sessionId.length > 12 ? e.sessionId.slice(0, 8) + "…" : e.sessionId;
19299
+ const reasonPart = e.reason ? `:${e.reason}` : "";
19300
+ lines.push(`${i + 1}. session \`${sidShort}\` @ ${e.timestamp}${reasonPart}`);
19301
+ if (e.summary_excerpt) {
19302
+ const excerpt = e.summary_excerpt.length > 200 ? e.summary_excerpt.slice(0, 200) + "…" : e.summary_excerpt;
19303
+ lines.push(` > ${excerpt.replace(/\n/g, `
19304
+ > `)}`);
19305
+ }
19306
+ });
19307
+ lines.push("");
19308
+ lines.push("请按用户偏好处理上述 BLOCK(重派 reviewer / 用户决策),再继续其它工作。");
19309
+ return lines.join(`
19310
+ `);
19167
19311
  }
19168
19312
  function renderPrompt(plan) {
19169
19313
  const lines = [];
@@ -19215,10 +19359,11 @@ var sessionRecoveryServer = async (ctx) => {
19215
19359
  ok: r.ok,
19216
19360
  injected: r.injected,
19217
19361
  reason: r.reason,
19218
- last_session_id: r.plan?.last_session_id
19362
+ last_session_id: r.plan?.last_session_id,
19363
+ pending_blocks_count: r.pendingBlocks?.length ?? 0
19219
19364
  });
19220
19365
  if (r.injected && r.plan) {
19221
- log9.info(`[${PLUGIN_NAME17}] 注入恢复提示(last=${r.plan.last_session_id?.slice(0, 8) ?? "?"}, reason=${r.plan.reason})`);
19366
+ log9.info(`[${PLUGIN_NAME17}] 注入恢复提示(last=${r.plan.last_session_id?.slice(0, 8) ?? "?"}, reason=${r.plan.reason}, pending_blocks=${r.pendingBlocks?.length ?? 0})`);
19222
19367
  }
19223
19368
  });
19224
19369
  }
@@ -19227,8 +19372,8 @@ var sessionRecoveryServer = async (ctx) => {
19227
19372
  var handler17 = sessionRecoveryServer;
19228
19373
 
19229
19374
  // plugins/subtasks.ts
19230
- import { promises as fs16 } from "node:fs";
19231
- import * as path20 from "node:path";
19375
+ import { promises as fs17 } from "node:fs";
19376
+ import * as path21 from "node:path";
19232
19377
 
19233
19378
  // lib/parallel-merge.ts
19234
19379
  init_autonomy();
@@ -20053,7 +20198,7 @@ function sleep2(ms) {
20053
20198
  init_runtime_paths();
20054
20199
  var PLUGIN_NAME18 = "subtasks";
20055
20200
  function getLogFile(root = process.cwd()) {
20056
- return path20.join(runtimeDir(root), "logs", "subtasks.log");
20201
+ return path21.join(runtimeDir(root), "logs", "subtasks.log");
20057
20202
  }
20058
20203
  var VERB_RE = /^([a-zA-Z]{3,12})/;
20059
20204
  var CN_VERBS = [
@@ -20358,8 +20503,8 @@ async function writeLog(level, msg, data) {
20358
20503
  `;
20359
20504
  try {
20360
20505
  const logFile = getLogFile();
20361
- await fs16.mkdir(path20.dirname(logFile), { recursive: true });
20362
- await fs16.appendFile(logFile, line, "utf8");
20506
+ await fs17.mkdir(path21.dirname(logFile), { recursive: true });
20507
+ await fs17.appendFile(logFile, line, "utf8");
20363
20508
  } catch {}
20364
20509
  }
20365
20510
  logLifecycle(PLUGIN_NAME18, "import");
@@ -20875,12 +21020,12 @@ var tokenManagerServer = async (ctx) => {
20875
21020
  var handler20 = tokenManagerServer;
20876
21021
 
20877
21022
  // plugins/tool-policy.ts
20878
- import { promises as fs17 } from "node:fs";
20879
- import * as path22 from "node:path";
21023
+ import { promises as fs18 } from "node:fs";
21024
+ import * as path23 from "node:path";
20880
21025
  init_autonomy();
20881
21026
 
20882
21027
  // lib/file-regex-acl.ts
20883
- import * as path21 from "node:path";
21028
+ import * as path22 from "node:path";
20884
21029
  function compileRule(r) {
20885
21030
  if (r instanceof RegExp)
20886
21031
  return r;
@@ -20946,7 +21091,7 @@ function normalizePath2(p) {
20946
21091
  let s = p.replace(/\\/g, "/");
20947
21092
  if (s.startsWith("./"))
20948
21093
  s = s.slice(2);
20949
- s = path21.posix.normalize(s);
21094
+ s = path22.posix.normalize(s);
20950
21095
  return s;
20951
21096
  }
20952
21097
  function checkFileAccess(acl, file, op) {
@@ -21056,11 +21201,11 @@ function decideToolCall(ctx, cfg = {}, currentAgent) {
21056
21201
  action = "deny";
21057
21202
  return { action, reasons, autonomy: a, acl: aclResults };
21058
21203
  }
21059
- var POLICY_PATH = path22.join(".codeforge", "policy.json");
21204
+ var POLICY_PATH = path23.join(".codeforge", "policy.json");
21060
21205
  async function loadPolicy(root = process.cwd()) {
21061
- const file = path22.join(root, POLICY_PATH);
21206
+ const file = path23.join(root, POLICY_PATH);
21062
21207
  try {
21063
- const raw = await fs17.readFile(file, "utf8");
21208
+ const raw = await fs18.readFile(file, "utf8");
21064
21209
  const data = JSON.parse(raw);
21065
21210
  return data;
21066
21211
  } catch {
@@ -21170,7 +21315,7 @@ var handler21 = toolPolicyServer;
21170
21315
  // plugins/update-checker.ts
21171
21316
  import { existsSync as existsSync5 } from "node:fs";
21172
21317
  import { homedir as homedir8 } from "node:os";
21173
- import { join as join20 } from "node:path";
21318
+ import { join as join21 } from "node:path";
21174
21319
 
21175
21320
  // lib/update-checker-impl.ts
21176
21321
  import { createHash as createHash5 } from "node:crypto";
@@ -21187,7 +21332,7 @@ import {
21187
21332
  writeFileSync as writeFileSync2
21188
21333
  } from "node:fs";
21189
21334
  import { homedir as homedir7, tmpdir } from "node:os";
21190
- import { dirname as dirname11, join as join19 } from "node:path";
21335
+ import { dirname as dirname12, join as join20 } from "node:path";
21191
21336
  import { fileURLToPath } from "node:url";
21192
21337
  import * as https from "node:https";
21193
21338
  import * as zlib from "node:zlib";
@@ -21195,7 +21340,7 @@ import * as zlib from "node:zlib";
21195
21340
  // lib/version-injected.ts
21196
21341
  function getInjectedVersion() {
21197
21342
  try {
21198
- const v = "0.5.4";
21343
+ const v = "0.5.5";
21199
21344
  if (typeof v === "string" && /^\d+\.\d+\.\d+/.test(v)) {
21200
21345
  return v;
21201
21346
  }
@@ -21284,18 +21429,18 @@ function readLocalVersion() {
21284
21429
  return injected;
21285
21430
  try {
21286
21431
  const here = fileURLToPath(import.meta.url);
21287
- const root = dirname11(dirname11(here));
21288
- const pkg = JSON.parse(readFileSync5(join19(root, "package.json"), "utf8"));
21432
+ const root = dirname12(dirname12(here));
21433
+ const pkg = JSON.parse(readFileSync5(join20(root, "package.json"), "utf8"));
21289
21434
  return typeof pkg.version === "string" ? pkg.version : "0.0.0";
21290
21435
  } catch {
21291
21436
  return "0.0.0";
21292
21437
  }
21293
21438
  }
21294
21439
  function defaultCacheDir() {
21295
- return process.env["CODEFORGE_CACHE_DIR"] ?? join19(homedir7(), ".cache", "codeforge");
21440
+ return process.env["CODEFORGE_CACHE_DIR"] ?? join20(homedir7(), ".cache", "codeforge");
21296
21441
  }
21297
21442
  function defaultCacheFile() {
21298
- return join19(defaultCacheDir(), "update-check.json");
21443
+ return join20(defaultCacheDir(), "update-check.json");
21299
21444
  }
21300
21445
  function readCache(file) {
21301
21446
  try {
@@ -21313,7 +21458,7 @@ function readCache(file) {
21313
21458
  }
21314
21459
  function writeCache(file, entry) {
21315
21460
  try {
21316
- mkdirSync3(dirname11(file), { recursive: true });
21461
+ mkdirSync3(dirname12(file), { recursive: true });
21317
21462
  writeFileSync2(file, JSON.stringify(entry, null, 2), "utf8");
21318
21463
  } catch {}
21319
21464
  }
@@ -21451,14 +21596,14 @@ function defaultHttpFetcher(url, timeoutMs) {
21451
21596
  });
21452
21597
  }
21453
21598
  async function downloadAndExtractBundle(opts) {
21454
- const tmpRoot = opts.tmpDir ?? mkdtempSync(join19(tmpdir(), "codeforge-update-"));
21599
+ const tmpRoot = opts.tmpDir ?? mkdtempSync(join20(tmpdir(), "codeforge-update-"));
21455
21600
  mkdirSync3(tmpRoot, { recursive: true });
21456
21601
  const fetcher = opts.tarballFetcher ?? defaultBinaryFetcher;
21457
21602
  const tarballBuf = await fetcher(opts.tarballUrl);
21458
21603
  verifyIntegrity(tarballBuf, opts.expectedIntegrity);
21459
21604
  const tarBuf = zlib.gunzipSync(tarballBuf);
21460
21605
  extractTarToDir(tarBuf, tmpRoot);
21461
- const bundlePath = join19(tmpRoot, "package", "dist", "index.js");
21606
+ const bundlePath = join20(tmpRoot, "package", "dist", "index.js");
21462
21607
  if (!existsSync4(bundlePath)) {
21463
21608
  throw new Error(`bundle_not_found: ${bundlePath}`);
21464
21609
  }
@@ -21498,11 +21643,11 @@ function extractTarToDir(tarBuf, destRoot) {
21498
21643
  offset += 512;
21499
21644
  if (typeFlag === "0" || typeFlag === "" || typeFlag === "\x00") {
21500
21645
  const fileBuf = tarBuf.subarray(offset, offset + size);
21501
- const dest = join19(destRoot, fullName);
21502
- mkdirSync3(dirname11(dest), { recursive: true });
21646
+ const dest = join20(destRoot, fullName);
21647
+ mkdirSync3(dirname12(dest), { recursive: true });
21503
21648
  writeFileSync2(dest, fileBuf);
21504
21649
  } else if (typeFlag === "5") {
21505
- mkdirSync3(join19(destRoot, fullName), { recursive: true });
21650
+ mkdirSync3(join20(destRoot, fullName), { recursive: true });
21506
21651
  }
21507
21652
  offset += Math.ceil(size / 512) * 512;
21508
21653
  }
@@ -21555,7 +21700,7 @@ function atomicReplaceBundle(opts) {
21555
21700
  if (!existsSync4(source)) {
21556
21701
  throw new Error(`atomic_source_missing: ${source}`);
21557
21702
  }
21558
- mkdirSync3(dirname11(target), { recursive: true });
21703
+ mkdirSync3(dirname12(target), { recursive: true });
21559
21704
  const newPath = `${target}.new`;
21560
21705
  const backupPath = `${target}.bak.${oldVersion}`;
21561
21706
  let strategy = "rename";
@@ -21607,11 +21752,11 @@ function cleanupOldBackups(target, keep) {
21607
21752
  if (keep <= 0)
21608
21753
  return;
21609
21754
  try {
21610
- const dir = dirname11(target);
21755
+ const dir = dirname12(target);
21611
21756
  const base = target.substring(dir.length + 1);
21612
21757
  const prefix = `${base}.bak.`;
21613
21758
  const all = readdirSync3(dir).filter((f) => f.startsWith(prefix)).map((f) => {
21614
- const full = join19(dir, f);
21759
+ const full = join20(dir, f);
21615
21760
  let mtimeMs = 0;
21616
21761
  try {
21617
21762
  mtimeMs = statSync5(full).mtimeMs;
@@ -21633,7 +21778,7 @@ function loadCompatibility(opts) {
21633
21778
  const root = opts?.cwd ?? inferPluginRoot();
21634
21779
  if (!root)
21635
21780
  return null;
21636
- file = join19(root, "compatibility.json");
21781
+ file = join20(root, "compatibility.json");
21637
21782
  }
21638
21783
  if (!existsSync4(file))
21639
21784
  return null;
@@ -21658,7 +21803,7 @@ function loadCompatibility(opts) {
21658
21803
  function inferPluginRoot() {
21659
21804
  try {
21660
21805
  const here = fileURLToPath(import.meta.url);
21661
- return dirname11(dirname11(here));
21806
+ return dirname12(dirname12(here));
21662
21807
  } catch {
21663
21808
  return null;
21664
21809
  }
@@ -21853,14 +21998,14 @@ function detectOpencodeVersion() {
21853
21998
  }
21854
21999
  function getOpencodeBundlePath() {
21855
22000
  const candidates = [];
21856
- candidates.push(join20(homedir8(), ".config", "opencode", "codeforge", "index.js"));
22001
+ candidates.push(join21(homedir8(), ".config", "opencode", "codeforge", "index.js"));
21857
22002
  if (process.platform === "win32") {
21858
22003
  const appData = process.env["APPDATA"];
21859
22004
  if (appData)
21860
- candidates.push(join20(appData, "opencode", "codeforge", "index.js"));
22005
+ candidates.push(join21(appData, "opencode", "codeforge", "index.js"));
21861
22006
  const localAppData = process.env["LOCALAPPDATA"];
21862
22007
  if (localAppData)
21863
- candidates.push(join20(localAppData, "opencode", "codeforge", "index.js"));
22008
+ candidates.push(join21(localAppData, "opencode", "codeforge", "index.js"));
21864
22009
  }
21865
22010
  for (const c of candidates) {
21866
22011
  if (existsSync5(c))
@@ -21921,11 +22066,11 @@ async function postToast(ctx, message) {
21921
22066
  var handler22 = updateCheckerServer;
21922
22067
 
21923
22068
  // plugins/workflow-engine.ts
21924
- import * as path24 from "node:path";
22069
+ import * as path25 from "node:path";
21925
22070
 
21926
22071
  // lib/workflow-loader.ts
21927
- import { promises as fs18 } from "node:fs";
21928
- import * as path23 from "node:path";
22072
+ import { promises as fs19 } from "node:fs";
22073
+ import * as path24 from "node:path";
21929
22074
  import { z as z32 } from "zod";
21930
22075
  var ActionSchema = z32.object({
21931
22076
  tool: z32.string().min(1, "action.tool 不能为空"),
@@ -22011,7 +22156,7 @@ function parseWorkflowYaml(yaml, sourcePath = "<inline>") {
22011
22156
  async function loadWorkflowFromFile(filePath) {
22012
22157
  let txt;
22013
22158
  try {
22014
- txt = await fs18.readFile(filePath, "utf8");
22159
+ txt = await fs19.readFile(filePath, "utf8");
22015
22160
  } catch (err) {
22016
22161
  return {
22017
22162
  ok: false,
@@ -22026,7 +22171,7 @@ async function loadWorkflowsFromDir(dir) {
22026
22171
  const failed = [];
22027
22172
  let entries;
22028
22173
  try {
22029
- entries = await fs18.readdir(dir);
22174
+ entries = await fs19.readdir(dir);
22030
22175
  } catch (err) {
22031
22176
  const e = err;
22032
22177
  if (e.code === "ENOENT")
@@ -22038,7 +22183,7 @@ async function loadWorkflowsFromDir(dir) {
22038
22183
  continue;
22039
22184
  if (!/\.ya?ml$/i.test(name))
22040
22185
  continue;
22041
- const full = path23.join(dir, name);
22186
+ const full = path24.join(dir, name);
22042
22187
  const r = await loadWorkflowFromFile(full);
22043
22188
  if (r.ok)
22044
22189
  loaded.push(r);
@@ -22428,7 +22573,7 @@ async function handleCommandInvoked(raw, workflowsDir = "workflows") {
22428
22573
  }
22429
22574
  var workflowEngineServer = async (ctx) => {
22430
22575
  const directory = ctx.directory ?? process.cwd();
22431
- const workflowsDir = path24.join(directory, "workflows");
22576
+ const workflowsDir = path25.join(directory, "workflows");
22432
22577
  ensureRegistry(workflowsDir).catch((err) => fallbackLog2.warn(`[${PLUGIN_NAME23}] preload workflows failed`, {
22433
22578
  error: err instanceof Error ? err.message : String(err)
22434
22579
  }));
@@ -22472,7 +22617,7 @@ var workflowEngineServer = async (ctx) => {
22472
22617
  var handler23 = workflowEngineServer;
22473
22618
 
22474
22619
  // plugins/session-worktree-guard.ts
22475
- import path25 from "node:path";
22620
+ import path26 from "node:path";
22476
22621
  var PLUGIN_NAME24 = "session-worktree-guard";
22477
22622
  logLifecycle(PLUGIN_NAME24, "import", {});
22478
22623
  var WRITE_INTENT_RE = />(?!=)|\btee\b|\brm\b|\bmv\b|\bcp\b|\bmkdir\b|\btouch\b|\bchmod\b|\bchown\b|\bln\b/;
@@ -22501,7 +22646,7 @@ var WRITE_TOOLS = new Set(["write", "edit", "ast_edit"]);
22501
22646
  function rewritePath(value, mainRoot, worktreeRoot) {
22502
22647
  if (!value)
22503
22648
  return null;
22504
- const resolved = path25.isAbsolute(value) ? value : path25.resolve(mainRoot, value);
22649
+ const resolved = path26.isAbsolute(value) ? value : path26.resolve(mainRoot, value);
22505
22650
  if (resolved === mainRoot)
22506
22651
  return worktreeRoot;
22507
22652
  const prefix = mainRoot.endsWith("/") ? mainRoot : mainRoot + "/";
@@ -22631,6 +22776,32 @@ var sessionWorktreeGuardPlugin = async (ctx) => {
22631
22776
  }
22632
22777
  }
22633
22778
  const worktreePath = entry.worktreePath;
22779
+ if (toolName === "session_merge") {
22780
+ const action = argsObj["action"];
22781
+ if (action === "merge") {
22782
+ const caller = input.agent;
22783
+ if (caller !== undefined && caller !== "codeforge") {
22784
+ const reason = `[session-worktree-guard] DENIED: session_merge action=merge 仅 codeforge orchestrator 或用户可调;当前 caller=${caller}`;
22785
+ log13.warn(reason, {
22786
+ sessionId,
22787
+ tool: toolName,
22788
+ action,
22789
+ caller
22790
+ });
22791
+ safeWriteLog(PLUGIN_NAME24, {
22792
+ hook: "tool.execute.before",
22793
+ tool: toolName,
22794
+ sessionID: input.sessionID,
22795
+ action: "deny",
22796
+ source: "merge-caller-whitelist",
22797
+ caller,
22798
+ merge_action: "merge"
22799
+ });
22800
+ denied = new DeniedError(reason);
22801
+ return;
22802
+ }
22803
+ }
22804
+ }
22634
22805
  if (toolName !== "plan_read" && entry.requiredPlanId && entry.planReadOk !== true) {
22635
22806
  let isWriteOp = WRITE_TOOLS.has(toolName);
22636
22807
  if (!isWriteOp && toolName === "bash") {
@@ -22818,9 +22989,8 @@ var worktreeLifecyclePlugin = async (ctx) => {
22818
22989
  return;
22819
22990
  }
22820
22991
  try {
22821
- const mainHead = await getCurrentMainHead(mainRoot);
22822
- const worktreeHead = mainHead ? await getCurrentWorktreeHead(entry.worktreePath) : "";
22823
- if (worktreeHead && worktreeHead === mainHead) {
22992
+ const worktreeHead = await getCurrentWorktreeHead(entry.worktreePath);
22993
+ if (worktreeHead && worktreeHead === entry.baseSha) {
22824
22994
  const fastDirty = await isWorktreeDirty(entry.worktreePath);
22825
22995
  if (!fastDirty) {
22826
22996
  await discardSession({ sessionId: ended.sessionID, mainRoot });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andyqiu/codeforge",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "CodeForge — opencode 的零侵入扩展包",
5
5
  "type": "module",
6
6
  "private": false,