@goodandready/dsh-approval-gate 0.1.25 → 0.1.27

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.
@@ -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, learner: engine.learner, engine });
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}`);
@@ -530,12 +539,10 @@ export function apply(ctx, config) {
530
539
  return mount();
531
540
  }
532
541
 
533
- export { SecurityEngine } from "./engine.js";
534
542
  export { BandsEngine } from "./bands.js";
535
543
  export { CircuitBreaker, CIRCUIT_STATE_ACTIVE, CIRCUIT_STATE_TRIPPED, CIRCUIT_STATE_RESUMED } from "./breaker.js";
536
544
  export { SessionGrantStore, DEFAULT_GRANT_TTL_MS, DEFAULT_GRANT_MAX_USES, matchGrantCriteria } from "./grants.js";
537
545
  export { canonicalizeCall, canonicalString, hashCall, sortKeys } from "./canonical.js";
538
- export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
539
546
  export { splitSubcommands } from "./tokenizer.js";
540
547
  export { redactCredentials, redactUrl } from "./redact.js";
541
548
  export { CommandDispatcher, isReviewerAgent, extractFilePath, TOOL_KIND_SHELL, TOOL_KIND_FILE_WRITE, TOOL_KIND_FILE_READ, TOOL_KIND_NETWORK, TOOL_KIND_GENERIC } from "./dispatcher.js";
@@ -548,7 +555,7 @@ export { RetryExhaustionGuard, DEFAULT_MAX_RETRY_ATTEMPTS } from "./retry-exhaus
548
555
  export { QualityLedger, MAX_LEDGER_EVENTS_PER_SESSION, CURRENT_SCHEMA_VERSION, SUPPORTED_SCHEMA_VERSIONS, migrateVerdictRecord } from "./ledger.js";
549
556
  export { AuditLogger, DEFAULT_AUDIT_LOG_RELATIVE, DEFAULT_MAX_AUDIT_BYTES, DEFAULT_MAX_AUDIT_LINES, DEFAULT_RETAIN_LINES, auditRotateContent } from "./audit-log.js";
550
557
  export { BlockCounters, categorizeReason } from "./counters.js";
551
- export { isCriticalOperation } from "./engine.js";
558
+ export { SecurityEngine, isCriticalOperation } from "./engine.js";
552
559
  export { parseYaml, parseScalar, stripComment } from "./yaml-parser.js";
553
560
  export { matchConditionValue, evaluateWhen } from "./when-matcher.js";
554
561
  export { HotwordsMatcher, DEFAULT_HOTWORDS, extractWords } from "./hotwords.js";
@@ -556,7 +563,7 @@ export { DeclarativeRulesEngine, BUILTIN_RULES_FILE, WORKSPACE_RULES_REL, USER_R
556
563
  export { normalizeChatCompletionsUrl, withLlmRetry, sanitizeClassifierInput, truncateClassifierContext, isLowRiskExecution, isShapeRejectError, extractJsonPayload, ClassifierVerdictSchema, validateClassifierResponse, LlmClassifier } from "./classifier.js";
557
564
  export { ContractGuard } from "./contract-guard.js";
558
565
  export { OverengineeringGuard, DEFAULT_MAX_FILES_PER_SESSION } from "./overengineering.js";
559
- export { resolveTempRoot, isGlobalTempPath, checkTempIsolation } from "./paths.js";
566
+ export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath, resolveTempRoot, isGlobalTempPath, checkTempIsolation } from "./paths.js";
560
567
  export { computePayloadSize, scanPromptInjection } from "./inspect.js";
561
568
  export { RISK_BUCKETS, RISK_TAGS, categorizeRiskBucket, extractRiskTags } from "./risk.js";
562
569
  export { ClassifierLatencyTracker, DEFAULT_CLASSIFIER_SLA_MS } from "./latency.js";
@@ -582,3 +589,6 @@ export { DEFAULT_HUMAN_TIMEOUT_MS, RACE_WINNERS, RaceHumanHandle, raceHumanDecis
582
589
  export { DEFAULT_HMR_DEBOUNCE_MS, RulesHmrWatcher } from "./rules-hmr.js";
583
590
  export { NETWORK_POLICIES, SANDBOX_NETWORK_MAP, resolveNetworkPolicy, parseDestination, checkNetworkAccess } from "./network-sandbox.js";
584
591
  export { LocalEgressProxy } from "./forward-proxy.js";
592
+ export { splitLines, generateDiffLines, groupHunks, formatUnifiedDiff, parseUnifiedDiff, toggleFold } from "./diff-viewer.js";
593
+ export { TurnCardTracker, formatTurnCardSummary } from "./turn-card.js";
594
+ export { TimelineService } from "./timeline-service.js";
package/lib/learning.js CHANGED
@@ -218,6 +218,11 @@ export class ConfirmationLearner {
218
218
  return rules;
219
219
  }
220
220
 
221
+ revoke(sessionId, key) {
222
+ const sessionMap = this._getSessionMap(sessionId);
223
+ return sessionMap.delete(key);
224
+ }
225
+
221
226
  clearSession(sessionId) {
222
227
  const key = sessionId || "default";
223
228
  this.sessions.delete(key);