@ai-agent-forge/plugin-decision-escalation 0.0.0-stage → 0.88.15

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/dist/index.js ADDED
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Decision escalation capability plugin (`docs/design/决策升级插件设计.md`):
3
+ * a formal ask channel for forks (decision_ask), a local confirmation page
4
+ * plus TUI-text answers resolved through decision_resolve, a factory-180s
5
+ * timeout de-escalation chain (single adjudicator for routine forks,
6
+ * advocates + judge for significant ones, via the public
7
+ * `agent-forge.agent-task-scheduler@1` capability), and an irreversible
8
+ * redline that keeps the agent stopped.
9
+ *
10
+ * Persistence protocol, structurally identical to the goal/plan/alignment
11
+ * plugins: append-only `decision.state` full snapshots (the single rebuild
12
+ * source) plus one `decision.transition` audit entry per conversion,
13
+ * announced as `decision.changed` (version 1, correlationId
14
+ * "decision.state", data = the complete DecisionState snapshot).
15
+ *
16
+ * Injections (设计 §6): user answers ride `decision.answered`
17
+ * (`display: false`, model context like alignment.feedback); timeout
18
+ * adjudication/halted notices ride `decision.notice` (`display: true`, the
19
+ * user must see them). Both trigger a new turn via `sendMessage`.
20
+ */
21
+ import { randomUUID } from "node:crypto";
22
+ import { Type } from "typebox";
23
+ import { requireSchedulerCaller, runAdjudication } from "./adjudication.js";
24
+ import { applyAdjudicationFailure, applyDecisionAnswer, applyDecisionAsk, applyDecisionCancel, applyDecisionTimeout, findDecision, rebuildDecisionState, recordVerdict, } from "./engine.js";
25
+ import { startDecisionServer } from "./http-server.js";
26
+ import { DECISION_LIMITS, DecisionError, } from "./types.js";
27
+ const STATE_ENTRY = "decision.state";
28
+ const TRANSITION_ENTRY = "decision.transition";
29
+ /** 出厂默认(设计 §10):问询超时 180 秒,config `timeoutMs` 可覆盖。 */
30
+ const DEFAULT_TIMEOUT_MS = 180_000;
31
+ /**
32
+ * 注入接受判定的兜底窗口(alignment v3.10 同款):宿主在回合开头落
33
+ * custom_message 条目(正常毫秒级可见)。第三方宿主若不投影该类条目,窗口
34
+ * 结束仍未拒绝即视为已接受。
35
+ */
36
+ const INJECT_ACCEPT_TIMEOUT_MS = 750;
37
+ function isObject(value) {
38
+ return value !== null && typeof value === "object" && !Array.isArray(value);
39
+ }
40
+ /**
41
+ * 双调用约定(goal/alignment 同款):会话路径以 (toolCallId, params, signal,
42
+ * onUpdate) 调用插件工具——first 是调用 id 字符串;runtime facade 以
43
+ * (input, context?, signal?) 调用——first 是入参对象。按 first 的类型区分,
44
+ * 两条路径(含 runtime 携带 context)都取到真实入参。
45
+ */
46
+ function toolInput(first, second) {
47
+ if (typeof first === "string") {
48
+ if (!isObject(second))
49
+ return {};
50
+ return second;
51
+ }
52
+ if (!isObject(first))
53
+ throw new Error("decision tool input must be an object");
54
+ return first;
55
+ }
56
+ /** config `timeoutMs`(设计 §10):数值覆盖出厂 180s;非法类型显式报错。 */
57
+ function readTimeoutMs(config) {
58
+ const value = config.timeoutMs;
59
+ if (value === undefined)
60
+ return DEFAULT_TIMEOUT_MS;
61
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
62
+ throw new Error("decision-escalation: config.timeoutMs must be a positive integer (ms)");
63
+ }
64
+ return value;
65
+ }
66
+ function loadState(api) {
67
+ const entries = (api.session?.getBranchEntries() ?? []);
68
+ return rebuildDecisionState(entries);
69
+ }
70
+ function loadTransitions(api) {
71
+ const entries = api.session?.getBranchEntries() ?? [];
72
+ return entries
73
+ .filter((item) => item.customType === TRANSITION_ENTRY)
74
+ .map((item) => item.data)
75
+ .filter((item) => isObject(item));
76
+ }
77
+ function pageView(record, timeoutMs) {
78
+ return {
79
+ decisionId: record.id,
80
+ question: record.question,
81
+ options: record.options,
82
+ contextDigest: record.contextDigest,
83
+ riskClass: record.riskClass,
84
+ redLine: record.redLine,
85
+ status: record.status,
86
+ ...(record.status === "answered" && record.choice !== undefined ? { answeredChoice: record.choice } : {}),
87
+ timeoutMs,
88
+ revision: record.revision,
89
+ };
90
+ }
91
+ function optionLine(record, choice) {
92
+ const option = record.options[choice];
93
+ return option === undefined ? `choice ${choice}` : `[${choice}] ${option.label}`;
94
+ }
95
+ /** `decision.answered` injection body (设计 §6): user choice delivered to the model. */
96
+ function answeredMessage(record, choice, note) {
97
+ const lines = [
98
+ `[decision.answered] 决策 ${record.id} 已作答:${optionLine(record, choice)}`,
99
+ `问题:${record.question}`,
100
+ ];
101
+ if (note !== undefined && note !== "")
102
+ lines.push(`用户补充:${note}`);
103
+ lines.push(`请立即调用 decision_resolve(decisionId "${record.id}",choice ${choice},source "user")落账,然后按选择继续执行。`);
104
+ return lines.join("\n");
105
+ }
106
+ /** `decision.notice` halted body (设计 §5.1): redline / failed adjudication. */
107
+ function haltedMessage(record, detail) {
108
+ const lines = [
109
+ `[decision.notice] 决策 ${record.id} 已停止推进(问题:${record.question})`,
110
+ ...record.options.map((option, index) => `[${index}] ${option.label} — ${option.description}(后果:${option.consequences},可逆性:${option.reversibility ?? "未声明"})`),
111
+ ];
112
+ if (detail !== undefined && detail !== "")
113
+ lines.push(`原因:${detail}`);
114
+ lines.push("纪律:停止执行,把该分叉与上述选项整理后写给用户,等待用户明示;不得自行裁决后继续,不得自动调用 decision_resolve。");
115
+ return lines.join("\n");
116
+ }
117
+ /** `decision.notice` adjudication body (设计 §5): the full verdict drives the next turn. */
118
+ function adjudicationNoticeMessage(record) {
119
+ const verdict = record.verdict;
120
+ if (verdict === undefined)
121
+ throw new DecisionError("invalid-input", `no verdict stored on decision ${record.id}`);
122
+ return [
123
+ `[decision.notice] 决策 ${record.id} 已超时,由独立裁决链裁决完成(问题:${record.question})`,
124
+ `裁决:${optionLine(record, verdict.choice)}(置信度 ${verdict.confidence})`,
125
+ `理由:${verdict.rationale}`,
126
+ `关键证据:${verdict.keyEvidence.join(";")}`,
127
+ `请立即调用 decision_resolve(decisionId "${record.id}",choice ${verdict.choice},source "adjudication")落账,然后按裁决继续执行。`,
128
+ "纪律:有实质异议时停止并说明异议与依据,不得静默推翻,也不得静默服从明知有致命缺陷的裁决。",
129
+ ].join("\n");
130
+ }
131
+ const PROMPT_GUIDELINES = [
132
+ "L0 微小分叉自主决策,不问;L1+ 且无法从既有指令推出用户偏好时才 decision_ask。",
133
+ "context_digest 必须对称:每个选项都要写支持与反对证据;不得为倾向选项写更充分的论证。",
134
+ "任何选项不可逆时必须如实声明 reversibility,不得打包成 reversible 规避红线。",
135
+ "收到 halted 通知:停止执行,把分叉与选项整理给用户,不得自行裁决后继续。",
136
+ "收到裁决书:执行裁决;有实质异议时停止并说明异议与依据,不得静默推翻,也不得静默服从明知有致命缺陷的裁决。",
137
+ ];
138
+ const askSchema = Type.Object({
139
+ question: Type.String({ description: "分叉一句话" }),
140
+ options: Type.Array(Type.Object({
141
+ label: Type.String({ description: "选项短标签" }),
142
+ description: Type.String({ description: "选项一句话说明" }),
143
+ consequences: Type.String({ description: "选它的直接后果" }),
144
+ reversibility: Type.Optional(Type.Union([Type.Literal("reversible"), Type.Literal("irreversible")], {
145
+ description: "可逆性;缺失按红线处理(保守默认)",
146
+ })),
147
+ }), { description: "2..4 个封闭选项", minItems: 2, maxItems: 4 }),
148
+ context_digest: Type.String({
149
+ description: "对称决策简报:每个选项的支持证据、反对证据、影响范围;缺项视为无效简报",
150
+ }),
151
+ risk_class: Type.Union([Type.Literal("routine"), Type.Literal("significant")], {
152
+ description: "L1/L2 自评,仅决定超时后走单裁还是对抗裁决",
153
+ }),
154
+ });
155
+ const resolveSchema = Type.Object({
156
+ decisionId: Type.String({ description: "decision_ask 返回的决策 id" }),
157
+ choice: Type.Integer({ description: "选项下标(0 起)" }),
158
+ source: Type.Union([Type.Literal("user"), Type.Literal("adjudication")], {
159
+ description: "user=用户作答;adjudication=已注入的裁决书(必须与裁决一致,不得伪造)",
160
+ }),
161
+ note: Type.Optional(Type.String({ description: "落账备注(可选)" })),
162
+ });
163
+ function listLines(state, count) {
164
+ if (state.decisions.length === 0)
165
+ return "no decisions in this session; ask one with decision_ask";
166
+ const lines = [`decisions: ${state.decisions.length}`];
167
+ for (const record of state.decisions.slice(-count).reverse()) {
168
+ lines.push(`- ${record.id} [${record.status}${record.redLine ? "/redline" : `/${record.riskClass}`}] ${record.question.replace(/\n/g, " ").slice(0, 80)} · ${record.resolvedBy ?? "-"}${record.choice === undefined ? "" : ` choice=${record.choice}`} · ${new Date(record.createdAt).toISOString()}`);
169
+ }
170
+ return lines.join("\n");
171
+ }
172
+ function showLines(record, transitions) {
173
+ const lines = [
174
+ `${record.id} [${record.status}${record.redLine ? "/redline" : `/${record.riskClass}`}] ${record.question}`,
175
+ `created: ${new Date(record.createdAt).toISOString()} · revision ${record.revision}`,
176
+ ];
177
+ for (const [index, option] of record.options.entries()) {
178
+ const marker = record.choice === index ? " ← 已选" : "";
179
+ lines.push(`[${index}] ${option.label} — ${option.description}`);
180
+ lines.push(` 后果:${option.consequences} · 可逆性:${option.reversibility}${marker}`);
181
+ }
182
+ lines.push(`对称简报:${record.contextDigest}`);
183
+ if (record.resolvedBy !== undefined)
184
+ lines.push(`resolved_by: ${record.resolvedBy}${record.choice === undefined ? "" : `(${optionLine(record, record.choice)})`}`);
185
+ if (record.note !== undefined)
186
+ lines.push(`note: ${record.note}`);
187
+ if (record.verdict !== undefined) {
188
+ lines.push(`裁决书:choice=${record.verdict.choice} confidence=${record.verdict.confidence}`, `理由:${record.verdict.rationale}`, `关键证据:${record.verdict.keyEvidence.map((item) => `\n - ${item}`).join("")}`);
189
+ }
190
+ const events = transitions.filter((entry) => entry.decisionId === record.id);
191
+ if (events.length > 0) {
192
+ lines.push("审计:");
193
+ for (const entry of events) {
194
+ lines.push(` rev${entry.revision} ${entry.event}${entry.reason.detail === undefined ? "" : ` — ${entry.reason.detail}`}`);
195
+ }
196
+ }
197
+ return lines.join("\n");
198
+ }
199
+ export function createCapability(api, options = {}) {
200
+ let state = loadState(api);
201
+ let server;
202
+ /** Disposed sessions never inject again: checked at every timer-job await point. */
203
+ let disposed = false;
204
+ const effectiveTimeoutMs = options.timeoutMs ?? readTimeoutMs(api.config);
205
+ const timers = new Map();
206
+ /**
207
+ * 计时路径没有调用方可观察异常:注入失败在这里保留原始错误(不吞不改,
208
+ * 不产生未处理的 promise 拒绝),并在 /decision log 可见。
209
+ */
210
+ const timeoutFailures = [];
211
+ /**
212
+ * 双 POST 幂等标记:内存态,不进持久化——同一决策的页面作答
213
+ * 只注入一次,重复提交直接幂等返回。
214
+ */
215
+ const deliveredAnswers = new Map();
216
+ const persist = (next, transitions) => {
217
+ state = next;
218
+ api.session?.appendEntry(STATE_ENTRY, state);
219
+ for (const entry of transitions)
220
+ api.session?.appendEntry(TRANSITION_ENTRY, entry);
221
+ void api.publish({ type: "decision.changed", version: 1, correlationId: STATE_ENTRY, data: state });
222
+ return state;
223
+ };
224
+ /**
225
+ * 注入是否已被会话接受(alignment v3.10 同款):宿主在回合开始时就把
226
+ * custom message 落成条目并持久化,因此"条目可见"就是送达事实;不能等
227
+ * `sendMessage` 的 promise——它要到整个模型回合结束才 resolve。
228
+ */
229
+ const customMessageCount = () => {
230
+ const entries = api.session?.getBranchEntries() ?? [];
231
+ return entries.filter((entry) => entry.type === "custom_message").length;
232
+ };
233
+ const awaitAccepted = async (baseline, sent) => {
234
+ let settled = false;
235
+ let failure;
236
+ const outcome = sent.then(() => {
237
+ settled = true;
238
+ }, (error) => {
239
+ settled = true;
240
+ failure = error;
241
+ });
242
+ const started = Date.now();
243
+ // 迭代上限是假定时器(Date 也被冻结)时的兜底:不靠时钟也能退出循环。
244
+ for (let attempt = 0; attempt < 100_000; attempt += 1) {
245
+ if (customMessageCount() > baseline) {
246
+ // 回合继续在后台跑;它之后的失败由宿主呈现。
247
+ void outcome;
248
+ return;
249
+ }
250
+ if (settled) {
251
+ await outcome;
252
+ if (failure !== undefined)
253
+ throw failure;
254
+ return;
255
+ }
256
+ if (Date.now() - started >= INJECT_ACCEPT_TIMEOUT_MS) {
257
+ // 无条目证据的宿主:sendMessage 已调用且未拒绝,按已接受处理。
258
+ void outcome;
259
+ return;
260
+ }
261
+ // setImmediate 而不是 setTimeout:条目写入在毫秒级,且宿主的单测可能
262
+ // 伪造 setTimeout(假定时器下 setTimeout 不推进会把送达判定挂死)。
263
+ await new Promise((resolve) => setImmediate(resolve));
264
+ }
265
+ await outcome;
266
+ if (failure !== undefined)
267
+ throw failure;
268
+ };
269
+ const inject = async (customType, content, display) => {
270
+ // alignment 两处降级同款:sendMessage 缺失显式抛错,不静默。
271
+ const send = api.session?.sendMessage;
272
+ if (!send) {
273
+ throw new DecisionError("invalid-input", "host session does not expose sendMessage; the decision answer cannot be injected");
274
+ }
275
+ const baseline = customMessageCount();
276
+ const sent = send({ customType, content, display }, { triggerTurn: true });
277
+ await awaitAccepted(baseline, sent);
278
+ // triggerTurn 注入会同步驱动模型回合,回合内的 decision_resolve 已把变更
279
+ // 落账 entries——此处从 entries 重建内存态,不回写回合前快照。
280
+ if (api.session)
281
+ state = loadState(api);
282
+ };
283
+ const clearTimer = (decisionId) => {
284
+ const timer = timers.get(decisionId);
285
+ if (timer !== undefined) {
286
+ clearTimeout(timer);
287
+ timers.delete(decisionId);
288
+ }
289
+ };
290
+ const requireAsked = (decisionId) => {
291
+ const record = findDecision(state, decisionId);
292
+ if (!record)
293
+ throw new DecisionError("not-found", `unknown decision: ${decisionId}`);
294
+ if (record.status !== "asked") {
295
+ throw new DecisionError("not-active", `decision is not active (status ${record.status}): ${decisionId}`);
296
+ }
297
+ return record;
298
+ };
299
+ const injectNotice = (record, detail) => inject("decision.notice", haltedMessage(record, detail), true);
300
+ /** Timeout fire (设计 §5): halt redlines, adjudicate reversible levels. */
301
+ const handleTimeout = async (decisionId) => {
302
+ timers.delete(decisionId);
303
+ if (disposed)
304
+ return; // 会话已关闭:不派发、不注入
305
+ const record = requireAsked(decisionId);
306
+ const mode = record.redLine ? "halt" : "adjudicate";
307
+ const run = applyDecisionTimeout(state, { decisionId, mode }, Date.now());
308
+ persist(run.state, run.transitions);
309
+ if (disposed)
310
+ return; // 已关闭:落账保留,放弃后续派发
311
+ if (mode === "halt") {
312
+ await injectNotice(run.state.decisions.find((item) => item.id === decisionId) ?? record);
313
+ return;
314
+ }
315
+ // 裁决链(§5):scheduler 缺失、submit/drain 抛错、协议失败一律按 halted
316
+ // 通知收尾——不重试、不静默降级。
317
+ const caller = requireSchedulerCaller(api);
318
+ const outcome = caller === undefined
319
+ ? {
320
+ ok: false,
321
+ code: "unavailable",
322
+ detail: "agent-forge.agent-task-scheduler capability is not available",
323
+ }
324
+ : await runAdjudication(caller, {
325
+ decisionId: record.id,
326
+ question: record.question,
327
+ options: record.options,
328
+ contextDigest: record.contextDigest,
329
+ riskClass: record.riskClass,
330
+ });
331
+ if (disposed)
332
+ return; // 已关闭:放弃裁决落账与注入
333
+ const adjudicating = findDecision(state, decisionId);
334
+ if (adjudicating === undefined)
335
+ throw new DecisionError("not-found", `decision vanished during adjudication: ${decisionId}`);
336
+ if (!outcome.ok) {
337
+ const failed = applyAdjudicationFailure(state, { decisionId, code: outcome.code, detail: outcome.detail }, Date.now());
338
+ persist(failed.state, failed.transitions);
339
+ await injectNotice(failed.state.decisions.find((item) => item.id === decisionId) ?? adjudicating, outcome.detail);
340
+ return;
341
+ }
342
+ const verdictRun = recordVerdict(state, { decisionId, verdict: outcome.verdict }, Date.now());
343
+ const withVerdict = persist(verdictRun.state, verdictRun.transitions);
344
+ const decided = findDecision(withVerdict, decisionId);
345
+ if (decided === undefined)
346
+ throw new DecisionError("not-found", `decision vanished after verdict: ${decisionId}`);
347
+ await inject("decision.notice", adjudicationNoticeMessage(decided), true);
348
+ };
349
+ const recordTimeoutFailure = (error) => {
350
+ // 计时路径没有调用方可观察异常:保留原始错误供 /decision 诊断与
351
+ // 测试断言,不吞不改。
352
+ timeoutFailures.push(error);
353
+ };
354
+ const timerFired = (decisionId) => {
355
+ void handleTimeout(decisionId).catch(recordTimeoutFailure);
356
+ };
357
+ const armTimer = (decisionId, remainingMs) => {
358
+ clearTimer(decisionId);
359
+ timers.set(decisionId, setTimeout(() => timerFired(decisionId), remainingMs ?? effectiveTimeoutMs));
360
+ };
361
+ /**
362
+ * 页面目标决策从 state 派生:
363
+ * 最新的 asked 优先;否则最近一条(超时降级/终态记录保持可查看)。
364
+ * 重载后同一 URL 继续可答旧决策,不再依赖一次性捕获。
365
+ */
366
+ const pageRecord = () => state.decisions.find((record) => record.status === "asked") ?? state.decisions.at(-1);
367
+ const ensureServer = async () => {
368
+ // 页面生命周期(alignment 走查 2026-10-04 同款):服务随第一个
369
+ // decision_ask 启动后常驻到插件 dispose(会话关闭);下一次问询在
370
+ // 同一 URL 上覆盖内容。
371
+ if (server)
372
+ return server;
373
+ server = await startDecisionServer({
374
+ getState: () => {
375
+ const record = pageRecord();
376
+ return record === undefined ? undefined : pageView(record, effectiveTimeoutMs);
377
+ },
378
+ onAnswer: (post) => handleAnswerPost(post),
379
+ }, { port: options.serverPort });
380
+ return server;
381
+ };
382
+ /** Page POST (设计 §7): validate the choice, inject `decision.answered`. */
383
+ const handleAnswerPost = async (post) => {
384
+ const record = pageRecord();
385
+ if (record === undefined)
386
+ return { status: 409, body: { error: "no decision awaiting an answer" } };
387
+ // 双 POST 幂等:同一决策的作答只注入一次,重复提交直接返回。
388
+ const deliveredChoice = deliveredAnswers.get(record.id);
389
+ if (deliveredChoice !== undefined) {
390
+ return {
391
+ status: 200,
392
+ body: { ok: true, delivered: true, idempotent: true, decisionId: record.id, choice: deliveredChoice },
393
+ };
394
+ }
395
+ if (record.status !== "asked") {
396
+ return {
397
+ status: 409,
398
+ body: { code: "not-active", error: `decision is not active (status ${record.status}): ${record.id}` },
399
+ };
400
+ }
401
+ if (!Number.isInteger(post.choice)) {
402
+ return { status: 400, body: { code: "invalid-input", error: "choice must be an option index" } };
403
+ }
404
+ const choice = post.choice;
405
+ if (choice < 0 || choice >= record.options.length) {
406
+ return {
407
+ status: 400,
408
+ body: { code: "choice-out-of-range", error: `choice must be within 0..${record.options.length - 1}` },
409
+ };
410
+ }
411
+ let note;
412
+ if (post.note !== undefined && post.note !== null && post.note !== "") {
413
+ if (typeof post.note !== "string") {
414
+ return { status: 400, body: { code: "invalid-input", error: "note must be a string" } };
415
+ }
416
+ if (post.note.length > DECISION_LIMITS.noteMaxLength) {
417
+ return {
418
+ status: 400,
419
+ body: { code: "invalid-input", error: `note exceeds ${DECISION_LIMITS.noteMaxLength} characters` },
420
+ };
421
+ }
422
+ note = post.note;
423
+ }
424
+ try {
425
+ await inject("decision.answered", answeredMessage(record, choice, note), false);
426
+ deliveredAnswers.set(record.id, choice);
427
+ // 作答送达即撤销超时计时器:否则窗口末端作答会在模型
428
+ // resolve 前被计时器触发的裁决链推翻。
429
+ clearTimer(record.id);
430
+ return { status: 200, body: { ok: true, delivered: true, decisionId: record.id, choice } };
431
+ }
432
+ catch (error) {
433
+ return {
434
+ status: 503,
435
+ body: {
436
+ error: `answer delivery failed: ${error instanceof Error ? error.message : String(error)}`,
437
+ retry: true,
438
+ },
439
+ };
440
+ }
441
+ };
442
+ const runAsk = async (input) => {
443
+ // 作答通道依赖注入(设计 §2/§7):sendMessage 缺失在落账前显式失败,
444
+ // 不留下一个永远等不到作答的决策(alignment 两处降级同款)。
445
+ if (!api.session?.sendMessage) {
446
+ throw new DecisionError("invalid-input", "host session does not expose sendMessage; decision answers cannot be delivered on this host");
447
+ }
448
+ // 决策 id 由插件生成(设计 §4.1 输入没有 id 字段):模型拿到的
449
+ // decisionId 是本工具返回值,不自己起名。
450
+ const askInput = {
451
+ id: `decision-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
452
+ question: input.question,
453
+ options: input.options,
454
+ contextDigest: input.context_digest,
455
+ riskClass: input.risk_class,
456
+ };
457
+ // 先懒启动页面(失败不落账),再落 asked 快照与计时器,最后立即返回。
458
+ // 已有 asked 决策时 applyDecisionAsk 结构化拒绝(decision-active-exists),
459
+ // 服务已在同一 URL 上服务该决策。
460
+ const running = await ensureServer();
461
+ const run = applyDecisionAsk(state, askInput, Date.now());
462
+ persist(run.state, run.transitions);
463
+ armTimer(askInput.id);
464
+ const record = findDecision(state, askInput.id);
465
+ return {
466
+ decisionId: record.id,
467
+ url: running.url,
468
+ timeoutMs: effectiveTimeoutMs,
469
+ riskClass: record.riskClass,
470
+ redLine: record.redLine,
471
+ };
472
+ };
473
+ const askTool = api.registerTool({
474
+ name: "decision_ask",
475
+ label: "Ask a fork decision",
476
+ description: "Ask the user to decide a fork (2..4 closed options): returns a decision id, a local confirmation-page URL and progress facts immediately. Non-blocking: the answer arrives later as a decision.answered message; on timeout reversible forks are adjudicated independently and irreversible ones halt",
477
+ parameters: askSchema,
478
+ promptGuidelines: PROMPT_GUIDELINES,
479
+ readonlySafe: true,
480
+ execute: async (first, second) => runAsk(toolInput(first, second)),
481
+ });
482
+ const resolveTool = api.registerTool({
483
+ name: "decision_resolve",
484
+ label: "Resolve a decision",
485
+ description: 'Record the answer that closed a decision_ask fork: source "user" for a user reply (from the page or TUI text), source "adjudication" only to book an injected adjudication verdict (must match it; fabricating one is rejected)',
486
+ parameters: resolveSchema,
487
+ promptGuidelines: PROMPT_GUIDELINES,
488
+ readonlySafe: true,
489
+ execute: async (first, second) => {
490
+ const input = toolInput(first, second);
491
+ const run = applyDecisionAnswer(state, {
492
+ decisionId: typeof input.decisionId === "string" ? input.decisionId : "",
493
+ choice: input.choice,
494
+ source: input.source,
495
+ note: input.note,
496
+ }, Date.now());
497
+ persist(run.state, run.transitions);
498
+ // 决策已终态:超时计时器随之撤销。
499
+ clearTimer(typeof input.decisionId === "string" ? input.decisionId : "");
500
+ const record = findDecision(state, typeof input.decisionId === "string" ? input.decisionId : "");
501
+ return {
502
+ decisionId: record.id,
503
+ status: record.status,
504
+ resolvedBy: record.resolvedBy,
505
+ choice: record.choice,
506
+ option: record.choice === undefined ? undefined : record.options[record.choice]?.label,
507
+ };
508
+ },
509
+ });
510
+ const command = api.registerCommand({
511
+ name: "decision",
512
+ description: "Decision audit: `log [N]` lists the recent forks (id, question, status, resolved_by, choice), `show <id>` prints the full record including the adjudication verdict, `cancel <id>` cancels an active fork",
513
+ execute: async (args) => {
514
+ try {
515
+ const parts = args
516
+ .trim()
517
+ .split(/\s+/)
518
+ .filter((part) => part !== "");
519
+ if (parts.length === 0) {
520
+ return {
521
+ status: "success",
522
+ message: "Usage: /decision log [N] | /decision show <id> | /decision cancel <id>",
523
+ };
524
+ }
525
+ if (parts[0] === "log") {
526
+ const count = Number.parseInt(parts[1] ?? "20", 10);
527
+ const lines = [listLines(loadState(api), Number.isFinite(count) && count > 0 ? count : 20)];
528
+ // 超时作业的注入失败在这里可见(原始错误保留,不吞不改)。
529
+ for (const failure of timeoutFailures) {
530
+ lines.push(`timeout-job failure: ${failure instanceof Error ? failure.message : String(failure)}`);
531
+ }
532
+ return { status: "success", message: lines.join("\n") };
533
+ }
534
+ if (parts[0] === "show") {
535
+ const id = parts[1] ?? "";
536
+ const record = findDecision(loadState(api), id);
537
+ if (!record)
538
+ return { status: "error", message: `unknown decision: ${id || "(none given)"}` };
539
+ return { status: "success", message: showLines(record, loadTransitions(api)) };
540
+ }
541
+ if (parts[0] === "cancel") {
542
+ const id = parts[1] ?? "";
543
+ const run = applyDecisionCancel(state, { decisionId: id }, Date.now());
544
+ persist(run.state, run.transitions);
545
+ clearTimer(id);
546
+ return { status: "success", message: `cancelled: ${id}` };
547
+ }
548
+ return {
549
+ status: "error",
550
+ message: "Usage: /decision log [N] | /decision show <id> | /decision cancel <id>",
551
+ };
552
+ }
553
+ catch (error) {
554
+ return { status: "error", message: error instanceof Error ? error.message : String(error) };
555
+ }
556
+ },
557
+ });
558
+ const stopServer = () => {
559
+ const stopping = server;
560
+ server = undefined;
561
+ if (stopping)
562
+ void stopping.stop().catch(() => undefined);
563
+ };
564
+ // 会话重载重臂:装载时对仍处 asked 的决策按剩余时间重臂计时器
565
+ //(createdAt + timeoutMs - now);已过期(<=0)立即走与正常超时同一语义
566
+ // 的降级链,不留一个静默失效的窗口。
567
+ for (const reloaded of state.decisions) {
568
+ if (reloaded.status !== "asked")
569
+ continue;
570
+ const remaining = reloaded.createdAt + effectiveTimeoutMs - Date.now();
571
+ if (remaining <= 0)
572
+ timerFired(reloaded.id);
573
+ else
574
+ armTimer(reloaded.id, remaining);
575
+ }
576
+ // The command registration doubles as the teardown hook for the local
577
+ // confirmation server and the pending timeout timers (plugin dispose must
578
+ // release the port and must not fire adjudications into a closed session).
579
+ const commandWithTeardown = {
580
+ id: command.id,
581
+ kind: command.kind,
582
+ dispose: () => {
583
+ disposed = true; // 先置位,在途超时作业在 await 点放弃注入
584
+ void command.dispose();
585
+ for (const decisionId of [...timers.keys()])
586
+ clearTimer(decisionId);
587
+ stopServer();
588
+ },
589
+ };
590
+ return [askTool, resolveTool, commandWithTeardown];
591
+ }
592
+ //# sourceMappingURL=index.js.map