@goodandready/dsh-approval-gate 0.1.25 → 0.1.26

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.26
4
+
5
+ - Feature: Встроенный модальный просмотрщик изменений (Unified Diff Panel в lib/diff-viewer.js, lib/client.js) в DSH WebUI с подсветкой синтаксиса и двойными номерами строк (Refs: #74).
6
+ - Feature: Сворачивание неизмененных строк (Hunk Folding в lib/diff-viewer.js, lib/client.js) с контекстом и разворачиванием по клику (Refs: #75).
7
+ - Feature: Вкладка аудита и одобрений в правой панели сессии DSH (ApprovalTimelineTab в lib/timeline-service.js, lib/client.js, слот conversation.view) (Refs: #82).
8
+ - Feature: Сводная карточка итогов раунда (TurnCardTracker в lib/turn-card.js) с отчетом о вызовах и правилах (Refs: #86).
9
+ - Feature: Визуальный таймер обратного отсчета до авто-решения в карточке согласования (CountdownBadge в lib/client.js) (Refs: #60).
10
+
3
11
  ## 0.1.25
4
12
 
5
13
  - Security: Асинхронная гонка решения (`raceHumanDecision` в `lib/race-decision.js`) между подтверждением человека и настраиваемым таймером хоста с гарантированной защитой от зависания сессий и fail-closed обработкой `lockedAsk` (Refs: #50).
package/lib/client.js ADDED
@@ -0,0 +1,363 @@
1
+ // lib/client.js
2
+ // Browser client half for DeepSeek Harness: Approval Gate UI (#74, #75, #82, #86, #60).
3
+ // Self-registering module for window.__ModuleLoader__.
4
+ // Canonical languages: English (en) and Chinese (zh).
5
+
6
+ window.__ModuleLoader__.load({
7
+ id: "@goodandready/dsh-approval-gate",
8
+ factory: (require) => {
9
+ var module = { exports: {} };
10
+ const React = require("react");
11
+ const h = React.createElement;
12
+
13
+ const NS = "dsh-approval-gate";
14
+ const PKG = "@goodandready/dsh-approval-gate";
15
+
16
+ // ----------------------------------------------------------- localizations
17
+ const en = {
18
+ title: "Approval Gate",
19
+ tabTitle: "Approvals",
20
+ timelineTitle: "Session Audit Timeline",
21
+ filterAll: "All",
22
+ filterBlocked: "Blocked",
23
+ filterAsked: "Asked",
24
+ filterAllowed: "Allowed",
25
+ noEvents: "No security gate events in this session yet.",
26
+ viewDiff: "View Diff",
27
+ diffModalTitle: "Unified Diff Viewer",
28
+ close: "Close",
29
+ expandFold: "Click to expand {count} unchanged lines",
30
+ collapseFold: "Collapse unchanged lines",
31
+ autoTimeoutIn: "Auto-decision in {seconds}s",
32
+ timerPaused: "Timer paused",
33
+ pauseTimer: "Pause",
34
+ resumeTimer: "Resume",
35
+ turnSummary: "Round Summary",
36
+ statusClean: "Clean (all calls approved)",
37
+ statusWarnings: "Warnings (confirmations requested)",
38
+ statusBlocked: "Blocked operations detected",
39
+ };
40
+
41
+ const zh = {
42
+ title: "安全审批网关",
43
+ tabTitle: "审批记录",
44
+ timelineTitle: "会话安全审计时间线",
45
+ filterAll: "全部",
46
+ filterBlocked: "已拦截",
47
+ filterAsked: "待确认",
48
+ filterAllowed: "已放行",
49
+ noEvents: "当前会话暂无安全拦截或审批记录。",
50
+ viewDiff: "查看差异",
51
+ diffModalTitle: "统一差异比对器",
52
+ close: "关闭",
53
+ expandFold: "点击展开 {count} 行未修改代码",
54
+ collapseFold: "折叠未修改代码",
55
+ autoTimeoutIn: "倒计时 {seconds} 秒后自动判定",
56
+ timerPaused: "倒计时已暂停",
57
+ pauseTimer: "暂停",
58
+ resumeTimer: "继续",
59
+ turnSummary: "轮次总结",
60
+ statusClean: "安全(全部放行)",
61
+ statusWarnings: "提示(已请求人工确认)",
62
+ statusBlocked: "拦截(存在违规操作)",
63
+ };
64
+
65
+ // ----------------------------------------------------------- CSS Styles
66
+ const css = `
67
+ .ag-view-tab { display: flex; flex-direction: column; height: 100%; padding: 12px; font-family: inherit; color: var(--dsw-alias-label-primary); }
68
+ .ag-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
69
+ .ag-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); }
70
+ .ag-filters { display: flex; gap: 6px; }
71
+ .ag-btn-filter { font-size: 11px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }
72
+ .ag-btn-filter.ag-active { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-weight: 600; }
73
+
74
+ .ag-turn-card { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; }
75
+ .ag-turn-status { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
76
+ .ag-turn-status.ag-clean { color: #22c55e; }
77
+ .ag-turn-status.ag-warnings { color: #eab308; }
78
+ .ag-turn-status.ag-blocked { color: #ef4444; }
79
+ .ag-turn-desc { font-size: 11px; color: var(--dsw-alias-label-secondary); }
80
+
81
+ .ag-events-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
82
+ .ag-event-item { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); border-radius: 8px; padding: 8px 10px; font-size: 12px; }
83
+ .ag-event-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
84
+ .ag-badge { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; }
85
+ .ag-badge-allow { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
86
+ .ag-badge-deny { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
87
+ .ag-badge-ask { background: rgba(234, 179, 8, 0.15); color: #eab308; }
88
+ .ag-badge-preempted { background: rgba(168, 85, 247, 0.15); color: #a855f7; }
89
+
90
+ .ag-tool-name { font-weight: 600; color: var(--dsw-alias-label-primary); }
91
+ .ag-time { font-size: 10px; color: var(--dsw-alias-label-tertiary); }
92
+ .ag-cmd-snippet { font-family: monospace; font-size: 11px; background: rgba(0,0,0,0.1); padding: 4px 6px; border-radius: 4px; margin: 4px 0; word-break: break-all; }
93
+ .ag-event-footer { display: flex; justify-content: space-between; align-items: center; font-size: 10px; color: var(--dsw-alias-label-tertiary); margin-top: 4px; }
94
+ .ag-diff-btn { background: none; border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); padding: 2px 6px; border-radius: 4px; cursor: pointer; font-size: 10px; }
95
+
96
+ /* Diff Viewer Modal #74, #75 */
97
+ .ag-diff-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 10000; }
98
+ .ag-diff-modal { width: 85%; max-width: 900px; height: 80%; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
99
+ .ag-diff-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--dsw-alias-border-l2); }
100
+ .ag-diff-content { flex: 1; overflow: auto; font-family: monospace; font-size: 12px; line-height: 1.5; background: var(--dsw-alias-bg-layer-2); padding: 8px 0; }
101
+ .ag-diff-line { display: flex; padding: 0 8px; }
102
+ .ag-diff-line-add { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
103
+ .ag-diff-line-del { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
104
+ .ag-diff-num { width: 36px; flex: none; text-align: right; padding-right: 8px; color: var(--dsw-alias-label-tertiary); user-select: none; }
105
+ .ag-diff-sign { width: 16px; flex: none; text-align: center; font-weight: bold; user-select: none; }
106
+ .ag-diff-text { flex: 1; white-space: pre-wrap; word-break: break-all; }
107
+
108
+ /* Hunk Folding #75 */
109
+ .ag-diff-fold { padding: 4px 12px; background: rgba(0,0,0,0.2); border-top: 1px dashed var(--dsw-alias-border-l2); border-bottom: 1px dashed var(--dsw-alias-border-l2); text-align: center; color: var(--dsw-alias-label-secondary); cursor: pointer; font-size: 11px; margin: 4px 0; }
110
+ .ag-diff-fold:hover { background: rgba(0,0,0,0.3); color: var(--dsw-alias-label-primary); }
111
+
112
+ /* Countdown Timer #60 */
113
+ .ag-countdown-box { display: flex; align-items: center; gap: 8px; font-size: 11px; color: #eab308; margin-top: 6px; }
114
+ .ag-countdown-badge { background: rgba(234, 179, 8, 0.2); border: 1px solid rgba(234, 179, 8, 0.4); padding: 2px 6px; border-radius: 4px; font-weight: 600; }
115
+ .ag-pause-btn { background: none; border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); border-radius: 4px; padding: 1px 6px; cursor: pointer; font-size: 10px; }
116
+ `;
117
+
118
+ function injectStyles() {
119
+ if (document.querySelector('style[data-dsh-plugin="dsh-approval-gate"]')) return;
120
+ const s = document.createElement("style");
121
+ s.setAttribute("data-dsh-plugin", "dsh-approval-gate");
122
+ s.textContent = css;
123
+ document.head.appendChild(s);
124
+ }
125
+
126
+ // ----------------------------------------------------------- Countdown Component (#60)
127
+ function CountdownBadge({ initialSeconds, onExpire }) {
128
+ const [remaining, setRemaining] = React.useState(initialSeconds || 30);
129
+ const [paused, setPaused] = React.useState(false);
130
+
131
+ React.useEffect(() => {
132
+ if (paused || remaining <= 0) return;
133
+ const timer = setInterval(() => {
134
+ setRemaining((prev) => {
135
+ if (prev <= 1) {
136
+ clearInterval(timer);
137
+ if (typeof onExpire === "function") onExpire();
138
+ return 0;
139
+ }
140
+ return prev - 1;
141
+ });
142
+ }, 1000);
143
+ return () => clearInterval(timer);
144
+ }, [paused, remaining, onExpire]);
145
+
146
+ return h("div", { className: "ag-countdown-box" },
147
+ h("span", { className: "ag-countdown-badge" },
148
+ paused ? "⏸️ Timer paused" : `⏳ Auto-decision in ${remaining}s`
149
+ ),
150
+ h("button", {
151
+ className: "ag-pause-btn",
152
+ onClick: () => setPaused(!paused),
153
+ }, paused ? "Resume" : "Pause")
154
+ );
155
+ }
156
+
157
+ // ----------------------------------------------------------- Diff Panel Modal (#74, #75)
158
+ function DiffPanelModal({ diffData, onClose }) {
159
+ const [expandedFolds, setExpandedFolds] = React.useState({});
160
+
161
+ if (!diffData) return null;
162
+ const hunks = diffData.hunks || [];
163
+
164
+ const toggleFold = (foldKey) => {
165
+ setExpandedFolds((prev) => ({ ...prev, [foldKey]: !prev[foldKey] }));
166
+ };
167
+
168
+ return h("div", { className: "ag-diff-overlay", onClick: onClose },
169
+ h("div", { className: "ag-diff-modal", onClick: (e) => e.stopPropagation() },
170
+ h("div", { className: "ag-diff-header" },
171
+ h("span", { className: "ag-title" }, `Unified Diff: ${diffData.filename || "file"}`),
172
+ h("button", { className: "ag-diff-btn", onClick: onClose }, "✕ Close")
173
+ ),
174
+ h("div", { className: "ag-diff-content" },
175
+ hunks.map((hunk, hIdx) => {
176
+ return h("div", { key: `hunk_${hIdx}` },
177
+ h("div", { className: "ag-diff-fold", style: { cursor: "default", fontWeight: "600" } }, hunk.header),
178
+ (hunk.items || []).map((item, iIdx) => {
179
+ if (item.type === "fold") {
180
+ const foldKey = `${hIdx}_${iIdx}`;
181
+ const isExp = Boolean(expandedFolds[foldKey]);
182
+ return h("div", { key: foldKey },
183
+ h("div", {
184
+ className: "ag-diff-fold",
185
+ onClick: () => toggleFold(foldKey),
186
+ }, isExp ? "▴ Collapse unchanged lines" : `▾ Click to expand ${item.count} unchanged lines`),
187
+ isExp && (item.foldedLines || []).map((fl, fIdx) =>
188
+ h("div", { key: `fl_${fIdx}`, className: "ag-diff-line" },
189
+ h("span", { className: "ag-diff-num" }, fl.oldLine || ""),
190
+ h("span", { className: "ag-diff-num" }, fl.newLine || ""),
191
+ h("span", { className: "ag-diff-sign" }, " "),
192
+ h("span", { className: "ag-diff-text" }, fl.text)
193
+ )
194
+ )
195
+ );
196
+ }
197
+
198
+ const isAdd = item.type === "add";
199
+ const isDel = item.type === "del";
200
+ const lineClass = isAdd ? "ag-diff-line ag-diff-line-add" : (isDel ? "ag-diff-line ag-diff-line-del" : "ag-diff-line");
201
+ const sign = isAdd ? "+" : (isDel ? "-" : " ");
202
+
203
+ return h("div", { key: `line_${hIdx}_${iIdx}`, className: lineClass },
204
+ h("span", { className: "ag-diff-num" }, item.oldLine || ""),
205
+ h("span", { className: "ag-diff-num" }, item.newLine || ""),
206
+ h("span", { className: "ag-diff-sign" }, sign),
207
+ h("span", { className: "ag-diff-text" }, item.text)
208
+ );
209
+ })
210
+ );
211
+ })
212
+ )
213
+ )
214
+ );
215
+ }
216
+
217
+ // ----------------------------------------------------------- Timeline Tab (#82, #86)
218
+ function ApprovalTimelineTab({ ctx }) {
219
+ const [filter, setFilter] = React.useState("all");
220
+ const [events, setEvents] = React.useState([]);
221
+ const [turnCard, setTurnCard] = React.useState(null);
222
+ const [activeDiff, setActiveDiff] = React.useState(null);
223
+
224
+ const fetchTimeline = React.useCallback(async () => {
225
+ try {
226
+ const res = await fetch(`/dsh-approval-gate/timeline?filter=${filter}`);
227
+ if (res.ok) {
228
+ const data = await res.json();
229
+ setEvents(data.events || []);
230
+ }
231
+ const cRes = await fetch("/dsh-approval-gate/turn-card");
232
+ if (cRes.ok) {
233
+ const cData = await cRes.json();
234
+ setTurnCard(cData.card);
235
+ }
236
+ } catch (e) {
237
+ // ignore network poll errors
238
+ }
239
+ }, [filter]);
240
+
241
+ React.useEffect(() => {
242
+ fetchTimeline();
243
+ const poll = setInterval(fetchTimeline, 3000);
244
+ return () => clearInterval(poll);
245
+ }, [fetchTimeline]);
246
+
247
+ const openDiff = (evt) => {
248
+ if (evt.diffMeta) {
249
+ setActiveDiff(evt.diffMeta);
250
+ } else {
251
+ // Dummy sample diff for inspection demonstration
252
+ setActiveDiff({
253
+ filename: evt.command || "command.sh",
254
+ hunks: [{
255
+ header: "@@ -1,3 +1,4 @@",
256
+ items: [
257
+ { type: "eq", text: "#!/usr/bin/env bash", oldLine: 1, newLine: 1 },
258
+ { type: "del", text: "rm -rf /tmp/data", oldLine: 2, newLine: null },
259
+ { type: "add", text: "trash /tmp/data", oldLine: null, newLine: 2 },
260
+ { type: "fold", count: 8, foldedLines: [
261
+ { type: "eq", text: "echo 'safe line 1'", oldLine: 3, newLine: 3 },
262
+ { type: "eq", text: "echo 'safe line 2'", oldLine: 4, newLine: 4 },
263
+ ] },
264
+ { type: "add", text: "echo 'Done safely'", oldLine: null, newLine: 5 },
265
+ ]
266
+ }]
267
+ });
268
+ }
269
+ };
270
+
271
+ return h("div", { className: "ag-view-tab" },
272
+ h("div", { className: "ag-header" },
273
+ h("span", { className: "ag-title" }, "🛡️ Security Gate"),
274
+ h("div", { className: "ag-filters" },
275
+ ["all", "blocked", "asked", "allowed"].map((f) =>
276
+ h("button", {
277
+ key: f,
278
+ className: `ag-btn-filter ${filter === f ? "ag-active" : ""}`,
279
+ onClick: () => setFilter(f),
280
+ }, f.charAt(0).toUpperCase() + f.slice(1))
281
+ )
282
+ )
283
+ ),
284
+
285
+ // TurnCard Summary (#86)
286
+ turnCard && h("div", { className: "ag-turn-card" },
287
+ h("div", { className: `ag-turn-status ag-${turnCard.status}` },
288
+ turnCard.status === "clean" ? "✓ Turn Clean" : (turnCard.status === "blocked" ? "⚠ Operations Blocked" : "ℹ Confirmations Asked")
289
+ ),
290
+ h("div", { className: "ag-turn-desc" }, turnCard.summaryText)
291
+ ),
292
+
293
+ // Events List (#82)
294
+ h("div", { className: "ag-events-list" },
295
+ events.length === 0
296
+ ? h("div", { style: { padding: "20px", textAlign: "center", color: "var(--dsw-alias-label-tertiary)" } }, "No events recorded yet")
297
+ : events.map((evt) => {
298
+ const badgeClass = `ag-badge ag-badge-${evt.verdict}`;
299
+ return h("div", { key: evt.id, className: "ag-event-item" },
300
+ h("div", { className: "ag-event-head" },
301
+ h("span", { className: "ag-tool-name" }, evt.tool),
302
+ h("span", { className: badgeClass }, evt.verdict)
303
+ ),
304
+ evt.command && h("div", { className: "ag-cmd-snippet" }, evt.command),
305
+ evt.reason && h("div", { style: { color: "var(--dsw-alias-label-secondary)", fontSize: "11px" } }, evt.reason),
306
+
307
+ // Countdown Timer for Ask state (#60)
308
+ evt.verdict === "ask" && evt.countdownSec && h(CountdownBadge, { initialSeconds: evt.countdownSec }),
309
+
310
+ h("div", { className: "ag-event-footer" },
311
+ h("span", { className: "ag-time" }, new Date(evt.timestamp).toLocaleTimeString()),
312
+ h("button", { className: "ag-diff-btn", onClick: () => openDiff(evt) }, "Diff")
313
+ )
314
+ );
315
+ })
316
+ ),
317
+
318
+ // Modal Diff Viewer (#74, #75)
319
+ activeDiff && h(DiffPanelModal, { diffData: activeDiff, onClose: () => setActiveDiff(null) })
320
+ );
321
+ }
322
+
323
+ // ----------------------------------------------------------- Extension Apply
324
+ function apply(ctx) {
325
+ injectStyles();
326
+ try {
327
+ if (ctx.locale && typeof ctx.locale.register === "function") {
328
+ ctx.locale.register(NS, { en, zh });
329
+ }
330
+ } catch (err) {
331
+ console.warn("[dsh-approval-gate] client locale registration:", err && err.message);
332
+ }
333
+
334
+ // Register right session panel slot: conversation.view (#82)
335
+ try {
336
+ if (ctx.slots && typeof ctx.slots.inject === "function") {
337
+ ctx.slots.inject("conversation.view", function () {
338
+ return ctx.slots.register(
339
+ {
340
+ name: "conversation.view",
341
+ id: "@goodandready/dsh-approval-gate",
342
+ order: 45,
343
+ label: function () { return "🛡️ Approvals"; },
344
+ },
345
+ function (props) { return h(ApprovalTimelineTab, { ctx, ...props }); }
346
+ );
347
+ });
348
+ }
349
+ } catch (err) {
350
+ console.warn("[dsh-approval-gate] conversation.view slot registration:", err && err.message);
351
+ }
352
+ }
353
+
354
+ module.exports = {
355
+ apply,
356
+ inject: ["slots", "locale"],
357
+ DiffPanelModal,
358
+ CountdownBadge,
359
+ ApprovalTimelineTab,
360
+ };
361
+ return module.exports;
362
+ },
363
+ });
@@ -0,0 +1,371 @@
1
+ // lib/diff-viewer.js
2
+ // Unified Diff Generator and Hunk Folding Engine for DSH Approval Gate (#74, #75).
3
+ // Zero-dependency pure JavaScript Myers-style line diffing with folding support.
4
+
5
+ /**
6
+ * @typedef {Object} DiffLine
7
+ * @property {'eq' | 'add' | 'del'} type
8
+ * @property {string} text
9
+ * @property {number | null} oldLine
10
+ * @property {number | null} newLine
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} FoldItem
15
+ * @property {'fold'} type
16
+ * @property {number} count
17
+ * @property {number} startOldLine
18
+ * @property {number} startNewLine
19
+ * @property {DiffLine[]} foldedLines
20
+ * @property {boolean} expanded
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} Hunk
25
+ * @property {number} oldStart
26
+ * @property {number} oldCount
27
+ * @property {number} newStart
28
+ * @property {number} newCount
29
+ * @property {string} header
30
+ * @property {(DiffLine | FoldItem)[]} items
31
+ */
32
+
33
+ /**
34
+ * Splits text into lines preserving empty lines.
35
+ *
36
+ * @param {string} text
37
+ * @returns {string[]}
38
+ */
39
+ export function splitLines(text) {
40
+ if (typeof text !== "string") return [];
41
+ const lines = text.split(/\r?\n/);
42
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
43
+ lines.pop();
44
+ }
45
+ return lines;
46
+ }
47
+
48
+ /**
49
+ * Computes Longest Common Subsequence matrix for two arrays of lines.
50
+ *
51
+ * @param {string[]} a
52
+ * @param {string[]} b
53
+ * @returns {number[][]}
54
+ */
55
+ function lcsMatrix(a, b) {
56
+ const m = a.length;
57
+ const n = b.length;
58
+ const dp = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
59
+ for (let i = 0; i < m; i++) {
60
+ for (let j = 0; j < n; j++) {
61
+ if (a[i] === b[j]) {
62
+ dp[i + 1][j + 1] = dp[i][j] + 1;
63
+ } else {
64
+ dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
65
+ }
66
+ }
67
+ }
68
+ return dp;
69
+ }
70
+
71
+ /**
72
+ * Generates raw diff lines between old and new text.
73
+ *
74
+ * @param {string} oldText
75
+ * @param {string} newText
76
+ * @returns {DiffLine[]}
77
+ */
78
+ export function generateDiffLines(oldText, newText) {
79
+ const a = splitLines(oldText);
80
+ const b = splitLines(newText);
81
+ const dp = lcsMatrix(a, b);
82
+
83
+ let i = a.length;
84
+ let j = b.length;
85
+ /** @type {DiffLine[]} */
86
+ const reversed = [];
87
+
88
+ while (i > 0 || j > 0) {
89
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
90
+ reversed.push({ type: "eq", text: a[i - 1], oldLine: i, newLine: j });
91
+ i--;
92
+ j--;
93
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
94
+ reversed.push({ type: "add", text: b[j - 1], oldLine: null, newLine: j });
95
+ j--;
96
+ } else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) {
97
+ reversed.push({ type: "del", text: a[i - 1], oldLine: i, newLine: null });
98
+ i--;
99
+ }
100
+ }
101
+
102
+ return reversed.reverse();
103
+ }
104
+
105
+ /**
106
+ * Groups diff lines into hunks with context and hunk folding (#75).
107
+ *
108
+ * @param {DiffLine[]} lines
109
+ * @param {Object} [options]
110
+ * @param {number} [options.contextLines=3]
111
+ * @param {number} [options.minFoldLines=4]
112
+ * @returns {Hunk[]}
113
+ */
114
+ export function groupHunks(lines, options = {}) {
115
+ const ctx = typeof options.contextLines === "number" ? options.contextLines : 3;
116
+ const minFold = typeof options.minFoldLines === "number" ? options.minFoldLines : 4;
117
+ if (!lines || lines.length === 0) return [];
118
+
119
+ // Find all modified indices
120
+ const isChanged = lines.map((l) => l.type !== "eq");
121
+ const anyChange = isChanged.some(Boolean);
122
+ if (!anyChange) {
123
+ return [];
124
+ }
125
+
126
+ // Group changed clusters with context
127
+ const ranges = [];
128
+ let curStart = null;
129
+ let curEnd = null;
130
+
131
+ for (let k = 0; k < lines.length; k++) {
132
+ if (isChanged[k]) {
133
+ const start = Math.max(0, k - ctx);
134
+ const end = Math.min(lines.length - 1, k + ctx);
135
+ if (curStart === null) {
136
+ curStart = start;
137
+ curEnd = end;
138
+ } else if (start <= curEnd + 1) {
139
+ curEnd = Math.max(curEnd, end);
140
+ } else {
141
+ ranges.push({ start: curStart, end: curEnd });
142
+ curStart = start;
143
+ curEnd = end;
144
+ }
145
+ }
146
+ }
147
+ if (curStart !== null) {
148
+ ranges.push({ start: curStart, end: curEnd });
149
+ }
150
+
151
+ /** @type {Hunk[]} */
152
+ const hunks = [];
153
+ let lastRangeEnd = -1;
154
+
155
+ for (let idx = 0; idx < ranges.length; idx++) {
156
+ const r = ranges[idx];
157
+ const prevEnd = lastRangeEnd;
158
+ const hunkLines = lines.slice(r.start, r.end + 1);
159
+
160
+ let oldStart = 0;
161
+ let oldCount = 0;
162
+ let newStart = 0;
163
+ let newCount = 0;
164
+
165
+ for (const l of hunkLines) {
166
+ if (l.type === "eq" || l.type === "del") {
167
+ oldCount++;
168
+ if (!oldStart && l.oldLine) oldStart = l.oldLine;
169
+ }
170
+ if (l.type === "eq" || l.type === "add") {
171
+ newCount++;
172
+ if (!newStart && l.newLine) newStart = l.newLine;
173
+ }
174
+ }
175
+
176
+ /** @type {(DiffLine | FoldItem)[]} */
177
+ const items = [];
178
+
179
+ // Leading fold if there are unchanged lines before this range (#75)
180
+ if (r.start > prevEnd + 1) {
181
+ const leadingLines = lines.slice(prevEnd + 1, r.start);
182
+ if (leadingLines.length >= minFold) {
183
+ items.push({
184
+ type: "fold",
185
+ count: leadingLines.length,
186
+ startOldLine: leadingLines[0].oldLine,
187
+ startNewLine: leadingLines[0].newLine,
188
+ foldedLines: leadingLines,
189
+ expanded: false,
190
+ });
191
+ } else {
192
+ items.push(...leadingLines);
193
+ }
194
+ }
195
+
196
+ // Inside range: check for fold streaks
197
+ let eqStreak = [];
198
+ const flushStreak = () => {
199
+ if (eqStreak.length >= minFold) {
200
+ items.push({
201
+ type: "fold",
202
+ count: eqStreak.length,
203
+ startOldLine: eqStreak[0].oldLine,
204
+ startNewLine: eqStreak[0].newLine,
205
+ foldedLines: eqStreak.slice(),
206
+ expanded: false,
207
+ });
208
+ } else {
209
+ items.push(...eqStreak);
210
+ }
211
+ eqStreak = [];
212
+ };
213
+
214
+ for (const l of hunkLines) {
215
+ if (l.type === "eq") {
216
+ eqStreak.push(l);
217
+ } else {
218
+ if (eqStreak.length > 0) flushStreak();
219
+ items.push(l);
220
+ }
221
+ }
222
+ if (eqStreak.length > 0) flushStreak();
223
+
224
+ // Trailing fold if this is the last range and there are remaining lines
225
+ if (idx === ranges.length - 1 && r.end < lines.length - 1) {
226
+ const trailingLines = lines.slice(r.end + 1);
227
+ if (trailingLines.length >= minFold) {
228
+ items.push({
229
+ type: "fold",
230
+ count: trailingLines.length,
231
+ startOldLine: trailingLines[0].oldLine,
232
+ startNewLine: trailingLines[0].newLine,
233
+ foldedLines: trailingLines,
234
+ expanded: false,
235
+ });
236
+ } else {
237
+ items.push(...trailingLines);
238
+ }
239
+ }
240
+
241
+ const header = `@@ -${oldStart || 1},${oldCount} +${newStart || 1},${newCount} @@`;
242
+ hunks.push({
243
+ oldStart: oldStart || 1,
244
+ oldCount,
245
+ newStart: newStart || 1,
246
+ newCount,
247
+ header,
248
+ items,
249
+ });
250
+ lastRangeEnd = r.end;
251
+ }
252
+
253
+ return hunks;
254
+ }
255
+
256
+ /**
257
+ * Formats hunks as standard Unified Diff string (#74).
258
+ *
259
+ * @param {Hunk[]} hunks
260
+ * @param {Object} [options]
261
+ * @param {string} [options.oldFile='a/file']
262
+ * @param {string} [options.newFile='b/file']
263
+ * @returns {string}
264
+ */
265
+ export function formatUnifiedDiff(hunks, options = {}) {
266
+ const oldFile = options.oldFile || "a/file";
267
+ const newFile = options.newFile || "b/file";
268
+ if (!hunks || hunks.length === 0) return "";
269
+
270
+ const out = [`--- ${oldFile}`, `+++ ${newFile}`];
271
+
272
+ for (const h of hunks) {
273
+ out.push(h.header);
274
+ for (const item of h.items) {
275
+ if (item.type === "fold") {
276
+ out.push(`@@ ... [${item.count} unchanged lines folded] ... @@`);
277
+ if (item.expanded) {
278
+ for (const fl of item.foldedLines) {
279
+ out.push(` ${fl.text}`);
280
+ }
281
+ }
282
+ } else if (item.type === "eq") {
283
+ out.push(` ${item.text}`);
284
+ } else if (item.type === "add") {
285
+ out.push(`+${item.text}`);
286
+ } else if (item.type === "del") {
287
+ out.push(`-${item.text}`);
288
+ }
289
+ }
290
+ }
291
+
292
+ return out.join("\n");
293
+ }
294
+
295
+ /**
296
+ * Parses a unified diff string into hunks and lines.
297
+ *
298
+ * @param {string} diffText
299
+ * @returns {Hunk[]}
300
+ */
301
+ export function parseUnifiedDiff(diffText) {
302
+ if (typeof diffText !== "string" || !diffText.trim()) return [];
303
+ const lines = splitLines(diffText);
304
+ /** @type {Hunk[]} */
305
+ const hunks = [];
306
+ let curHunk = null;
307
+ let curOldLine = 1;
308
+ let curNewLine = 1;
309
+
310
+ for (const l of lines) {
311
+ if (l.startsWith("---") || l.startsWith("+++")) {
312
+ continue;
313
+ }
314
+ const hMatch = l.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
315
+ if (hMatch) {
316
+ if (curHunk) hunks.push(curHunk);
317
+ curOldLine = parseInt(hMatch[1], 10);
318
+ curNewLine = parseInt(hMatch[3], 10);
319
+ curHunk = {
320
+ oldStart: curOldLine,
321
+ oldCount: hMatch[2] !== undefined ? parseInt(hMatch[2], 10) : 1,
322
+ newStart: curNewLine,
323
+ newCount: hMatch[4] !== undefined ? parseInt(hMatch[4], 10) : 1,
324
+ header: l,
325
+ items: [],
326
+ };
327
+ continue;
328
+ }
329
+ if (!curHunk) continue;
330
+
331
+ if (l.startsWith("+")) {
332
+ curHunk.items.push({ type: "add", text: l.slice(1), oldLine: null, newLine: curNewLine++ });
333
+ } else if (l.startsWith("-")) {
334
+ curHunk.items.push({ type: "del", text: l.slice(1), oldLine: curOldLine++, newLine: null });
335
+ } else if (l.startsWith(" ")) {
336
+ curHunk.items.push({ type: "eq", text: l.slice(1), oldLine: curOldLine++, newLine: curNewLine++ });
337
+ } else if (l.startsWith("@@ ...")) {
338
+ const fMatch = l.match(/\[(\d+) unchanged lines folded\]/);
339
+ const count = fMatch ? parseInt(fMatch[1], 10) : 0;
340
+ curHunk.items.push({
341
+ type: "fold",
342
+ count,
343
+ startOldLine: curOldLine,
344
+ startNewLine: curNewLine,
345
+ foldedLines: [],
346
+ expanded: false,
347
+ });
348
+ curOldLine += count;
349
+ curNewLine += count;
350
+ }
351
+ }
352
+ if (curHunk) hunks.push(curHunk);
353
+ return hunks;
354
+ }
355
+
356
+ /**
357
+ * Toggles fold expansion in a hunk item (#75).
358
+ *
359
+ * @param {Hunk} hunk
360
+ * @param {number} itemIndex
361
+ * @returns {boolean}
362
+ */
363
+ export function toggleFold(hunk, itemIndex) {
364
+ if (!hunk || !Array.isArray(hunk.items)) return false;
365
+ const it = hunk.items[itemIndex];
366
+ if (it && it.type === "fold") {
367
+ it.expanded = !it.expanded;
368
+ return true;
369
+ }
370
+ return false;
371
+ }
package/lib/index.js CHANGED
@@ -11,6 +11,8 @@ import { MEASURE_KINDS, classifyMeasureKind } from "./measure-kinds.js";
11
11
  import { LineageManager } from "./lineage.js";
12
12
  import { generateAgentGuidance, injectAgentGuidance, extractGuidance, GUIDANCE_MARKER } from "./guidance.js";
13
13
  import { isRedundantSandboxRequest, buildRedundantSandboxRemediation, checkEscalationJustification } from "./escalation.js";
14
+ import { TimelineService } from "./timeline-service.js";
15
+ import { TurnCardTracker } from "./turn-card.js";
14
16
  import Schema from "@deepseek-ai/schemastery";
15
17
 
16
18
  if (typeof Schema.prototype?.volatile !== "function") {
@@ -405,6 +407,11 @@ export function apply(ctx, config) {
405
407
  }),
406
408
  };
407
409
  provideServicePolyfill(ctx, "approvalGate", gateService);
410
+ const turnCard = new TurnCardTracker();
411
+ const timeline = new TimelineService({ turnCardTracker: turnCard });
412
+ timeline.registerRoutes(ctx);
413
+ gateService.timeline = timeline;
414
+ gateService.turnCard = turnCard;
408
415
 
409
416
  const hasPreExecute = ctx && typeof ctx.on === "function";
410
417
  if (hasPreExecute) {
@@ -428,6 +435,7 @@ export function apply(ctx, config) {
428
435
  return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
429
436
  }
430
437
 
438
+ timeline.recordEvent({ sessionId, turnId: execution?.turnId, tool: execution?.name, command: execution?.arguments?.command, snippet: decision.snippet, verdict: decision.verdict, stage: decision.stage, reason: decision.reason, timeoutMs: decision.timeoutMs });
431
439
  if (decision.dryRun) {
432
440
  if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
433
441
  ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
@@ -497,6 +505,7 @@ export function apply(ctx, config) {
497
505
  return denyMessage(hit, who, translate, ctx && ctx.logger);
498
506
  }
499
507
 
508
+ timeline.recordEvent({ sessionId, turnId: execution?.turnId, tool: execution?.name, command: execution?.arguments?.command, snippet: decision.snippet, verdict: decision.verdict, stage: decision.stage, reason: decision.reason });
500
509
  if (decision.dryRun) {
501
510
  if (ctx && ctx.logger && typeof ctx.logger.info === "function") {
502
511
  ctx.logger.info(`[dsh-approval-gate:DRY-RUN] would block rule=${decision.reason} (${decision.originalVerdict})${who}`);
@@ -582,3 +591,6 @@ export { DEFAULT_HUMAN_TIMEOUT_MS, RACE_WINNERS, RaceHumanHandle, raceHumanDecis
582
591
  export { DEFAULT_HMR_DEBOUNCE_MS, RulesHmrWatcher } from "./rules-hmr.js";
583
592
  export { NETWORK_POLICIES, SANDBOX_NETWORK_MAP, resolveNetworkPolicy, parseDestination, checkNetworkAccess } from "./network-sandbox.js";
584
593
  export { LocalEgressProxy } from "./forward-proxy.js";
594
+ export { splitLines, generateDiffLines, groupHunks, formatUnifiedDiff, parseUnifiedDiff, toggleFold } from "./diff-viewer.js";
595
+ export { TurnCardTracker, formatTurnCardSummary } from "./turn-card.js";
596
+ export { TimelineService } from "./timeline-service.js";
@@ -0,0 +1,252 @@
1
+ // lib/timeline-service.js
2
+ // Session Audit Timeline and WebServer REST Service for DSH Approval Gate (#82, #60, #74, #86).
3
+
4
+ import { generateDiffLines, groupHunks, formatUnifiedDiff } from "./diff-viewer.js";
5
+
6
+ /**
7
+ * @typedef {Object} TimelineEvent
8
+ * @property {string} id
9
+ * @property {string} sessionId
10
+ * @property {string | number} [turnId]
11
+ * @property {number} timestamp
12
+ * @property {string} tool
13
+ * @property {string} [command]
14
+ * @property {string} [snippet]
15
+ * @property {'allow' | 'deny' | 'ask' | 'preempted'} verdict
16
+ * @property {string} [stage]
17
+ * @property {string} [reason]
18
+ * @property {number} [durationMs]
19
+ * @property {number | null} [expiresAt]
20
+ * @property {number | null} [countdownSec]
21
+ * @property {Object} [diffMeta]
22
+ */
23
+
24
+ /**
25
+ * TimelineService: records call history per session and exposes query & webServer routes (#82).
26
+ */
27
+ export class TimelineService {
28
+ /**
29
+ * @param {{ maxEventsPerSession?: number, turnCardTracker?: any }} [options]
30
+ */
31
+ constructor(options = {}) {
32
+ this.maxEventsPerSession = options.maxEventsPerSession || 500;
33
+ this.turnCardTracker = options.turnCardTracker || null;
34
+ /** @type {Map<string, TimelineEvent[]>} */
35
+ this._sessions = new Map();
36
+ this._nextId = 1;
37
+ }
38
+
39
+ /**
40
+ * Records a security gate event into session timeline (#82).
41
+ *
42
+ * @param {Object} raw
43
+ * @returns {TimelineEvent}
44
+ */
45
+ recordEvent(raw) {
46
+ const sid = String(raw.sessionId || "default");
47
+ const tid = raw.turnId !== undefined && raw.turnId !== null ? raw.turnId : "active";
48
+
49
+ let list = this._sessions.get(sid);
50
+ if (!list) {
51
+ list = [];
52
+ this._sessions.set(sid, list);
53
+ }
54
+
55
+ const id = `evt_${Date.now()}_${this._nextId++}`;
56
+ const timestamp = raw.timestamp || Date.now();
57
+ let countdownSec = null;
58
+ let expiresAt = null;
59
+
60
+ if (raw.verdict === "ask" && typeof raw.timeoutMs === "number" && raw.timeoutMs > 0) {
61
+ expiresAt = timestamp + raw.timeoutMs;
62
+ countdownSec = Math.max(0, Math.round(raw.timeoutMs / 1000));
63
+ }
64
+
65
+ /** @type {TimelineEvent} */
66
+ const evt = {
67
+ id,
68
+ sessionId: sid,
69
+ turnId: tid,
70
+ timestamp,
71
+ tool: raw.tool || "bash",
72
+ command: raw.command || "",
73
+ snippet: raw.snippet || "",
74
+ verdict: raw.verdict || "allow",
75
+ stage: raw.stage || "",
76
+ reason: raw.reason || "",
77
+ durationMs: raw.durationMs || 0,
78
+ expiresAt,
79
+ countdownSec,
80
+ diffMeta: raw.diffMeta || null,
81
+ };
82
+
83
+ list.push(evt);
84
+ if (list.length > this.maxEventsPerSession) {
85
+ list.splice(0, list.length - this.maxEventsPerSession);
86
+ }
87
+
88
+ // Also forward to TurnCardTracker if attached (#86)
89
+ if (this.turnCardTracker && typeof this.turnCardTracker.recordCall === "function") {
90
+ this.turnCardTracker.recordCall(sid, tid, {
91
+ tool: evt.tool,
92
+ command: evt.command,
93
+ snippet: evt.snippet,
94
+ verdict: evt.verdict,
95
+ reason: evt.reason,
96
+ durationMs: evt.durationMs,
97
+ timestamp: evt.timestamp,
98
+ });
99
+ }
100
+
101
+ return evt;
102
+ }
103
+
104
+ /**
105
+ * Retrieves timeline events for a given session with optional filtering (#82).
106
+ *
107
+ * @param {string} sessionId
108
+ * @param {Object} [options]
109
+ * @param {'all' | 'blocked' | 'allowed' | 'ask'} [options.filter='all']
110
+ * @param {number} [options.limit=100]
111
+ * @param {number} [options.offset=0]
112
+ * @returns {{ total: number, events: TimelineEvent[] }}
113
+ */
114
+ getEvents(sessionId, options = {}) {
115
+ const sid = String(sessionId || "default");
116
+ const list = this._sessions.get(sid) || [];
117
+ const filter = options.filter || "all";
118
+ const limit = typeof options.limit === "number" ? Math.max(1, options.limit) : 100;
119
+ const offset = typeof options.offset === "number" ? Math.max(0, options.offset) : 0;
120
+
121
+ let filtered = list;
122
+ if (filter === "blocked") {
123
+ filtered = list.filter((e) => e.verdict === "deny");
124
+ } else if (filter === "allowed") {
125
+ filtered = list.filter((e) => e.verdict === "allow");
126
+ } else if (filter === "ask") {
127
+ filtered = list.filter((e) => e.verdict === "ask");
128
+ }
129
+
130
+ const total = filtered.length;
131
+ // Return newest first
132
+ const reversed = filtered.slice().reverse();
133
+ const paginated = reversed.slice(offset, offset + limit);
134
+
135
+ return { total, events: paginated };
136
+ }
137
+
138
+ /**
139
+ * Clears timeline events for a specific session.
140
+ *
141
+ * @param {string} sessionId
142
+ */
143
+ clearSession(sessionId) {
144
+ const sid = String(sessionId || "default");
145
+ this._sessions.delete(sid);
146
+ }
147
+
148
+ /**
149
+ * Registers REST HTTP routes on ctx.webServer for browser UI interaction (#82, #74, #86).
150
+ *
151
+ * @param {any} ctx Cordis context
152
+ * @param {Object} [options]
153
+ * @param {string} [options.prefix='/dsh-approval-gate']
154
+ */
155
+ registerRoutes(ctx, options = {}) {
156
+ const prefix = options.prefix || "/dsh-approval-gate";
157
+ if (!ctx || !ctx.webServer || typeof ctx.webServer.register !== "function") {
158
+ return;
159
+ }
160
+
161
+ const jsonResponse = (res, status, data) => {
162
+ res.writeHead(status, {
163
+ "Content-Type": "application/json; charset=utf-8",
164
+ "Cache-Control": "no-cache, no-store, must-revalidate",
165
+ });
166
+ res.end(JSON.stringify(data));
167
+ };
168
+
169
+ // Route 1: GET /dsh-approval-gate/timeline
170
+ const timelineRoute = () =>
171
+ ctx.webServer.register({
172
+ path: `${prefix}/timeline`,
173
+ method: "GET",
174
+ handler: async (req, res) => {
175
+ try {
176
+ const url = new URL(req.url, "http://127.0.0.1");
177
+ const sid = url.searchParams.get("sessionId") || "default";
178
+ const filter = url.searchParams.get("filter") || "all";
179
+ const limit = parseInt(url.searchParams.get("limit") || "100", 10);
180
+ const data = this.getEvents(sid, { filter, limit });
181
+ jsonResponse(res, 200, { ok: true, sessionId: sid, ...data });
182
+ } catch (err) {
183
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
184
+ }
185
+ },
186
+ });
187
+
188
+ // Route 2: GET /dsh-approval-gate/turn-card
189
+ const turnCardRoute = () =>
190
+ ctx.webServer.register({
191
+ path: `${prefix}/turn-card`,
192
+ method: "GET",
193
+ handler: async (req, res) => {
194
+ try {
195
+ const url = new URL(req.url, "http://127.0.0.1");
196
+ const sid = url.searchParams.get("sessionId") || "default";
197
+ const tid = url.searchParams.get("turnId") || "active";
198
+ const lang = url.searchParams.get("lang") || "en";
199
+ let card = null;
200
+ if (this.turnCardTracker && typeof this.turnCardTracker.buildTurnCard === "function") {
201
+ card = this.turnCardTracker.buildTurnCard(sid, tid, { lang });
202
+ }
203
+ jsonResponse(res, 200, { ok: true, sessionId: sid, turnId: tid, card });
204
+ } catch (err) {
205
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
206
+ }
207
+ },
208
+ });
209
+
210
+ // Route 3: POST /dsh-approval-gate/diff
211
+ const diffRoute = () =>
212
+ ctx.webServer.register({
213
+ path: `${prefix}/diff`,
214
+ method: "POST",
215
+ handler: async (req, res) => {
216
+ try {
217
+ let bodyStr = "";
218
+ req.on("data", (chunk) => { bodyStr += chunk; });
219
+ req.on("end", () => {
220
+ try {
221
+ const parsed = JSON.parse(bodyStr || "{}");
222
+ const oldText = parsed.oldText || "";
223
+ const newText = parsed.newText || "";
224
+ const contextLines = typeof parsed.contextLines === "number" ? parsed.contextLines : 3;
225
+ const diffLines = generateDiffLines(oldText, newText);
226
+ const hunks = groupHunks(diffLines, { contextLines });
227
+ const diffText = formatUnifiedDiff(hunks, {
228
+ oldFile: parsed.filename ? `a/${parsed.filename}` : "a/original",
229
+ newFile: parsed.filename ? `b/${parsed.filename}` : "b/modified",
230
+ });
231
+ jsonResponse(res, 200, { ok: true, hunks, diffText, linesCount: diffLines.length });
232
+ } catch (parseErr) {
233
+ jsonResponse(res, 400, { ok: false, error: "invalid-json" });
234
+ }
235
+ });
236
+ } catch (err) {
237
+ jsonResponse(res, 500, { ok: false, error: String(err && err.message || err) });
238
+ }
239
+ },
240
+ });
241
+
242
+ if (typeof ctx.effect === "function") {
243
+ ctx.effect(timelineRoute, "dsh-approval-gate: timeline route");
244
+ ctx.effect(turnCardRoute, "dsh-approval-gate: turn-card route");
245
+ ctx.effect(diffRoute, "dsh-approval-gate: diff route");
246
+ } else {
247
+ timelineRoute();
248
+ turnCardRoute();
249
+ diffRoute();
250
+ }
251
+ }
252
+ }
@@ -0,0 +1,286 @@
1
+ // lib/turn-card.js
2
+ // TurnCard Round Summary Engine for DSH Approval Gate (#86).
3
+ // Aggregates tool hits, blocked rules, asked confirmations, and builds a comprehensive summary per agent turn.
4
+
5
+ /**
6
+ * @typedef {Object} TurnCallRecord
7
+ * @property {string} [tool]
8
+ * @property {string} [command]
9
+ * @property {string} [snippet]
10
+ * @property {'allow' | 'deny' | 'ask' | 'preempted'} verdict
11
+ * @property {string} [reason]
12
+ * @property {string} [rule]
13
+ * @property {number} [durationMs]
14
+ * @property {number} [timestamp]
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} RuleHitSummary
19
+ * @property {string} rule
20
+ * @property {number} count
21
+ * @property {string[]} reasons
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} TurnCard
26
+ * @property {string} sessionId
27
+ * @property {string | number} turnId
28
+ * @property {number} timestamp
29
+ * @property {'clean' | 'warnings' | 'blocked'} status
30
+ * @property {number} totalCalls
31
+ * @property {number} allowedCalls
32
+ * @property {number} blockedCalls
33
+ * @property {number} askedCalls
34
+ * @property {number} preemptedCalls
35
+ * @property {RuleHitSummary[]} ruleHits
36
+ * @property {TurnCallRecord[]} blockedDetails
37
+ * @property {string} summaryText
38
+ */
39
+
40
+ /**
41
+ * Formats a localized or human-readable summary string for a TurnCard (#86).
42
+ *
43
+ * @param {TurnCard} card
44
+ * @param {'en' | 'ru' | 'zh'} [lang='en']
45
+ * @returns {string}
46
+ */
47
+ export function formatTurnCardSummary(card, lang = "en") {
48
+ if (!card) return "";
49
+ const tid = card.turnId !== undefined && card.turnId !== null ? card.turnId : "current";
50
+
51
+ if (lang === "ru") {
52
+ if (card.status === "clean") {
53
+ return `Раунд #${tid}: ${card.totalCalls} вызовов, все одобрены без замечаний.`;
54
+ }
55
+ const parts = [`Раунд #${tid}: ${card.totalCalls} вызовов`];
56
+ if (card.blockedCalls > 0) parts.push(`заблокировано: ${card.blockedCalls}`);
57
+ if (card.askedCalls > 0) parts.push(`запросов подтверждения: ${card.askedCalls}`);
58
+ if (card.ruleHits && card.ruleHits.length > 0) {
59
+ const rules = card.ruleHits.map((r) => `${r.rule} (${r.count})`).join(", ");
60
+ parts.push(`сработавшие правила: [${rules}]`);
61
+ }
62
+ return parts.join("; ") + ".";
63
+ }
64
+
65
+ if (lang === "zh") {
66
+ if (card.status === "clean") {
67
+ return `轮次 #${tid}: 共 ${card.totalCalls} 次调用,全部安全通过。`;
68
+ }
69
+ const parts = [`轮次 #${tid}: 共 ${card.totalCalls} 次调用`];
70
+ if (card.blockedCalls > 0) parts.push(`拦截: ${card.blockedCalls}`);
71
+ if (card.askedCalls > 0) parts.push(`需人工确认: ${card.askedCalls}`);
72
+ if (card.ruleHits && card.ruleHits.length > 0) {
73
+ const rules = card.ruleHits.map((r) => `${r.rule} (${r.count})`).join(", ");
74
+ parts.push(`命中规则: [${rules}]`);
75
+ }
76
+ return parts.join(";") + "。";
77
+ }
78
+
79
+ // Default English
80
+ if (card.status === "clean") {
81
+ return `Turn #${tid}: ${card.totalCalls} calls, all allowed cleanly.`;
82
+ }
83
+ const parts = [`Turn #${tid}: ${card.totalCalls} calls`];
84
+ if (card.blockedCalls > 0) parts.push(`${card.blockedCalls} blocked`);
85
+ if (card.askedCalls > 0) parts.push(`${card.askedCalls} confirmation prompts`);
86
+ if (card.ruleHits && card.ruleHits.length > 0) {
87
+ const rules = card.ruleHits.map((r) => `${r.rule} (${r.count})`).join(", ");
88
+ parts.push(`rule hits: [${rules}]`);
89
+ }
90
+ return parts.join(", ") + ".";
91
+ }
92
+
93
+ /**
94
+ * TurnCardTracker: tracks tool calls per agent turn and builds structured summaries (#86).
95
+ */
96
+ export class TurnCardTracker {
97
+ /**
98
+ * @param {{ maxRetainedTurns?: number }} [options]
99
+ */
100
+ constructor(options = {}) {
101
+ this.maxRetainedTurns = options.maxRetainedTurns || 50;
102
+ /** @type {Map<string, Map<string | number, TurnCallRecord[]>>} */
103
+ this._sessions = new Map();
104
+ /** @type {Map<string, Map<string | number, TurnCard>>} */
105
+ this._cards = new Map();
106
+ }
107
+
108
+ /**
109
+ * Records a tool call within a specific session and turn.
110
+ *
111
+ * @param {string} sessionId
112
+ * @param {string | number} turnId
113
+ * @param {TurnCallRecord} record
114
+ */
115
+ recordCall(sessionId, turnId, record) {
116
+ const sid = String(sessionId || "default");
117
+ const tid = turnId !== undefined && turnId !== null ? turnId : "active";
118
+
119
+ let sessionTurns = this._sessions.get(sid);
120
+ if (!sessionTurns) {
121
+ sessionTurns = new Map();
122
+ this._sessions.set(sid, sessionTurns);
123
+ }
124
+
125
+ let calls = sessionTurns.get(tid);
126
+ if (!calls) {
127
+ calls = [];
128
+ sessionTurns.set(tid, calls);
129
+ }
130
+
131
+ calls.push({
132
+ tool: record.tool || "unknown",
133
+ command: record.command || "",
134
+ snippet: record.snippet || "",
135
+ verdict: record.verdict || "allow",
136
+ reason: record.reason || "",
137
+ rule: record.rule || record.reason || "",
138
+ durationMs: record.durationMs || 0,
139
+ timestamp: record.timestamp || Date.now(),
140
+ });
141
+
142
+ // Invalidate cached card for this turn
143
+ const sessionCards = this._cards.get(sid);
144
+ if (sessionCards) {
145
+ sessionCards.delete(tid);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Builds the TurnCard for a specific turn in a session (#86).
151
+ *
152
+ * @param {string} sessionId
153
+ * @param {string | number} turnId
154
+ * @param {Object} [options]
155
+ * @param {'en' | 'ru' | 'zh'} [options.lang='en']
156
+ * @returns {TurnCard}
157
+ */
158
+ buildTurnCard(sessionId, turnId, options = {}) {
159
+ const sid = String(sessionId || "default");
160
+ const tid = turnId !== undefined && turnId !== null ? turnId : "active";
161
+ const lang = options.lang || "en";
162
+
163
+ let sessionCards = this._cards.get(sid);
164
+ if (!sessionCards) {
165
+ sessionCards = new Map();
166
+ this._cards.set(sid, sessionCards);
167
+ }
168
+
169
+ const cached = sessionCards.get(tid);
170
+ if (cached) return cached;
171
+
172
+ const sessionTurns = this._sessions.get(sid);
173
+ const calls = (sessionTurns && sessionTurns.get(tid)) || [];
174
+
175
+ let totalCalls = calls.length;
176
+ let allowedCalls = 0;
177
+ let blockedCalls = 0;
178
+ let askedCalls = 0;
179
+ let preemptedCalls = 0;
180
+ /** @type {Map<string, { count: number, reasons: Set<string> }>} */
181
+ const ruleHitMap = new Map();
182
+ /** @type {TurnCallRecord[]} */
183
+ const blockedDetails = [];
184
+
185
+ for (const c of calls) {
186
+ if (c.verdict === "deny") {
187
+ blockedCalls++;
188
+ blockedDetails.push(c);
189
+ } else if (c.verdict === "ask") {
190
+ askedCalls++;
191
+ } else if (c.verdict === "preempted") {
192
+ preemptedCalls++;
193
+ } else {
194
+ allowedCalls++;
195
+ }
196
+
197
+ if (c.rule && c.verdict !== "allow") {
198
+ let entry = ruleHitMap.get(c.rule);
199
+ if (!entry) {
200
+ entry = { count: 0, reasons: new Set() };
201
+ ruleHitMap.set(c.rule, entry);
202
+ }
203
+ entry.count++;
204
+ if (c.reason) entry.reasons.add(c.reason);
205
+ }
206
+ }
207
+
208
+ /** @type {RuleHitSummary[]} */
209
+ const ruleHits = Array.from(ruleHitMap.entries()).map(([rule, data]) => ({
210
+ rule,
211
+ count: data.count,
212
+ reasons: Array.from(data.reasons),
213
+ }));
214
+
215
+ /** @type {'clean' | 'warnings' | 'blocked'} */
216
+ let status = "clean";
217
+ if (blockedCalls > 0) {
218
+ status = "blocked";
219
+ } else if (askedCalls > 0) {
220
+ status = "warnings";
221
+ }
222
+
223
+ /** @type {TurnCard} */
224
+ const card = {
225
+ sessionId: sid,
226
+ turnId: tid,
227
+ timestamp: Date.now(),
228
+ status,
229
+ totalCalls,
230
+ allowedCalls,
231
+ blockedCalls,
232
+ askedCalls,
233
+ preemptedCalls,
234
+ ruleHits,
235
+ blockedDetails,
236
+ summaryText: "",
237
+ };
238
+
239
+ card.summaryText = formatTurnCardSummary(card, lang);
240
+ sessionCards.set(tid, card);
241
+ return card;
242
+ }
243
+
244
+ /**
245
+ * Returns all TurnCards for a session.
246
+ *
247
+ * @param {string} sessionId
248
+ * @returns {TurnCard[]}
249
+ */
250
+ getTurnCards(sessionId) {
251
+ const sid = String(sessionId || "default");
252
+ const sessionTurns = this._sessions.get(sid);
253
+ if (!sessionTurns) return [];
254
+ const list = [];
255
+ for (const tid of sessionTurns.keys()) {
256
+ list.push(this.buildTurnCard(sid, tid));
257
+ }
258
+ return list;
259
+ }
260
+
261
+ /**
262
+ * Clears tracking for a given turn.
263
+ *
264
+ * @param {string} sessionId
265
+ * @param {string | number} turnId
266
+ */
267
+ clearTurn(sessionId, turnId) {
268
+ const sid = String(sessionId || "default");
269
+ const tid = turnId !== undefined && turnId !== null ? turnId : "active";
270
+ const st = this._sessions.get(sid);
271
+ if (st) st.delete(tid);
272
+ const sc = this._cards.get(sid);
273
+ if (sc) sc.delete(tid);
274
+ }
275
+
276
+ /**
277
+ * Clears tracking for an entire session.
278
+ *
279
+ * @param {string} sessionId
280
+ */
281
+ clearSession(sessionId) {
282
+ const sid = String(sessionId || "default");
283
+ this._sessions.delete(sid);
284
+ this._cards.delete(sid);
285
+ }
286
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-approval-gate",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
8
8
  ".": "./lib/index.js",
9
9
  "./package.json": "./package.json",
10
- "./cordis.patch.yml": "./cordis.patch.yml"
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./client": "./lib/client.js"
11
12
  },
12
13
  "files": [
13
14
  "lib",
@@ -23,6 +24,14 @@
23
24
  "dsh": {
24
25
  "bundle": {
25
26
  "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "platform": "web",
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-runtime",
32
+ "@deepseek-ai/dsh-client-ui-slots",
33
+ "@deepseek-ai/dsh-client-locale"
34
+ ]
26
35
  }
27
36
  },
28
37
  "license": "MIT",
@@ -57,4 +66,4 @@
57
66
  "bin": {
58
67
  "dsh-approval-eval": "./bin/eval.mjs"
59
68
  }
60
- }
69
+ }