opencode-workflow-guard 1.5.1 → 1.7.0

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/src/lib/types.ts CHANGED
@@ -23,8 +23,16 @@ export interface TodoSdkClient {
23
23
  export interface ProjectConfig {
24
24
  protectedBranches?: string[];
25
25
  verifyCommand?: string;
26
+ postEditValidators?: Array<{
27
+ pattern: string;
28
+ command: string;
29
+ timeoutMs?: number;
30
+ }>;
26
31
  requireReview?: boolean;
27
32
  requireDocumentation?: boolean;
33
+ recoveryCheckpoints?: boolean;
34
+ projectMemory?: boolean;
35
+ learning?: boolean;
28
36
  maxSubagentMutations?: number;
29
37
  maxLearningInterventions?: number;
30
38
  }
@@ -55,8 +63,11 @@ export interface ReviewResult {
55
63
  export interface AuditEntry {
56
64
  ts: string;
57
65
  sessionID?: string;
66
+ callID?: string;
58
67
  tool: string;
59
68
  decision: "allow" | "block";
69
+ phase?: "decision" | "outcome" | "event";
70
+ durationMs?: number;
60
71
  reason?: string;
61
72
  input?: unknown;
62
73
  evidence?: {
@@ -140,3 +151,28 @@ export interface ProjectMemoryInput {
140
151
  paths?: string[];
141
152
  supersedes?: string;
142
153
  }
154
+
155
+ export type ReviewFollowupSeverity = "P2" | "P3";
156
+
157
+ export interface ReviewFollowup {
158
+ id: string;
159
+ projectId: string;
160
+ severity: ReviewFollowupSeverity;
161
+ summary: string;
162
+ reviewer: string;
163
+ createdAt: number;
164
+ resolvedAt?: number;
165
+ sessionID?: string;
166
+ commit?: string;
167
+ paths: string[];
168
+ status: "open" | "resolved";
169
+ }
170
+
171
+ export interface ReviewFollowupInput {
172
+ severity: ReviewFollowupSeverity;
173
+ summary: string;
174
+ reviewer: string;
175
+ sessionID?: string;
176
+ commit?: string;
177
+ paths?: string[];
178
+ }
package/src/lib/utils.ts CHANGED
@@ -1,393 +1,32 @@
1
1
  import { getSdkClient } from "./state.ts";
2
2
 
3
3
  export function asRecord(value: unknown): Record<string, unknown> | undefined {
4
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
5
- return undefined;
6
- }
4
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
7
5
  return value as Record<string, unknown>;
8
6
  }
9
7
 
10
- export function extractCommands(input: unknown): string[] {
11
- if (typeof input === "string") {
12
- return [input];
13
- }
8
+ export function extractRecordTargetPath(input: unknown): string | undefined {
14
9
  const record = asRecord(input);
15
- if (!record) {
16
- return [];
17
- }
18
- const commands: string[] = [];
19
- if (typeof record.command === "string") commands.push(record.command);
20
- if (Array.isArray(record.commands)) {
21
- for (const c of record.commands) {
22
- if (typeof c === "string") commands.push(c);
23
- }
24
- }
25
- return commands;
26
- }
27
-
28
- export function normalize(cmd: string): string {
29
- return cmd.replace(/\s+/g, " ");
30
- }
31
-
32
- export function decodeShellEscapes(text: string): string {
33
- return text
34
- // ANSI-C quoting wrapper: $'...' -> ...
35
- .replace(/\$'([^']*)'/g, "$1")
36
- // Hex escapes: \xHH
37
- .replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
38
- // Unicode escapes: \uHHHH
39
- .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
40
- // Octal escapes: \0OO or \OOO (e.g. \162 -> 'r')
41
- .replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
42
- }
43
-
44
- export function shellWords(command: string): string[] {
45
- const words: string[] = [];
46
- let word = "";
47
- let quote: "'" | '"' | undefined;
48
- let escaped = false;
49
- let started = false;
50
- for (const char of command) {
51
- if (escaped) {
52
- word += char;
53
- escaped = false;
54
- started = true;
55
- continue;
56
- }
57
- if (char === "\\" && quote !== "'") {
58
- escaped = true;
59
- started = true;
60
- continue;
61
- }
62
- if (char === "'" || char === '"') {
63
- if (!quote) quote = char;
64
- else if (quote === char) quote = undefined;
65
- else word += char;
66
- started = true;
67
- continue;
68
- }
69
- if (!quote && /\s/.test(char)) {
70
- if (started) words.push(word);
71
- word = "";
72
- started = false;
73
- continue;
74
- }
75
- word += char;
76
- started = true;
77
- }
78
- if (escaped) word += "\\";
79
- if (started) words.push(word);
80
- return words;
81
- }
82
-
83
- function envSplitWords(value: string): string[] {
84
- const words: string[] = [];
85
- let word = "";
86
- let quote: "'" | '"' | undefined;
87
- let started = false;
88
- const push = () => {
89
- if (started) words.push(word);
90
- word = "";
91
- started = false;
92
- };
93
- for (let i = 0; i < value.length; i += 1) {
94
- const char = value[i]!;
95
- if (char === "'" || char === '"') {
96
- if (!quote) quote = char;
97
- else if (quote === char) quote = undefined;
98
- else word += char;
99
- started = true;
100
- continue;
101
- }
102
- if (char === "\\" && i + 1 < value.length) {
103
- const next = value[++i]!;
104
- if (quote === "'" && next !== "'" && next !== "\\") {
105
- word += `\\${next}`;
106
- started = true;
107
- continue;
108
- }
109
- if (next === "c" && !quote) break;
110
- if (next === "_" && quote !== "'") {
111
- if (quote === '"') {
112
- word += " ";
113
- started = true;
114
- } else push();
115
- continue;
116
- }
117
- const escapes: Record<string, string> = { f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "#": "#", "$": "$", '"': '"', "'": "'", "\\": "\\" };
118
- word += escapes[next] ?? next;
119
- started = true;
120
- continue;
121
- }
122
- if (!quote && /[\s\f\v]/.test(char)) {
123
- push();
124
- continue;
125
- }
126
- if (!quote && char === "#" && !started) break;
127
- if (char === "$" && quote !== "'" && value[i + 1] === "{") {
128
- const end = value.indexOf("}", i + 2);
129
- if (end >= 0) {
130
- const name = value.slice(i + 2, end);
131
- word += process.env[name] ?? "";
132
- started = true;
133
- i = end;
134
- continue;
135
- }
136
- }
137
- word += char;
138
- started = true;
139
- }
140
- push();
141
- return words;
10
+ if (typeof record?.filePath === "string") return record.filePath;
11
+ if (typeof record?.path === "string") return record.path;
12
+ return undefined;
142
13
  }
143
14
 
144
- export function splitShellSegments(command: string): string[] {
145
- const segments: string[] = [];
146
- let current = "";
147
- let quote: "'" | '"' | "ansi" | undefined;
148
- let escaped = false;
149
- for (let i = 0; i < command.length; i += 1) {
150
- const char = command[i]!;
151
- if (escaped) {
152
- current += char;
153
- escaped = false;
154
- continue;
155
- }
156
- if (!quote && char === "$" && command[i + 1] === "'") {
157
- current += "$'";
158
- quote = "ansi";
159
- i += 1;
160
- continue;
161
- }
162
- if (char === "\\" && quote !== "'") {
163
- current += char;
164
- escaped = true;
165
- continue;
166
- }
167
- if (quote === "ansi" && char === "'") {
168
- quote = undefined;
169
- current += char;
170
- continue;
171
- }
172
- if (char === "'" || char === '"') {
173
- if (!quote) quote = char;
174
- else if (quote === char) quote = undefined;
175
- current += char;
176
- continue;
177
- }
178
- if (!quote && /[\n|;&]/.test(char)) {
179
- if (current.trim()) segments.push(current.trim());
180
- current = "";
181
- continue;
182
- }
183
- current += char;
184
- }
185
- if (current.trim()) segments.push(current.trim());
186
- return segments;
15
+ export function extractTargetPath(input: unknown): string | undefined {
16
+ return extractRecordTargetPath(input) ?? (typeof input === "string" ? input : undefined);
187
17
  }
188
18
 
189
- function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: boolean } {
190
- let cleanCmd = decodeShellEscapes(command.trim());
191
- // Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
192
- while (
193
- (cleanCmd.startsWith("(") && cleanCmd.endsWith(")")) ||
194
- (cleanCmd.startsWith("{") && cleanCmd.endsWith("}"))
195
- ) {
196
- cleanCmd = cleanCmd.slice(1, -1).trim();
197
- if (cleanCmd.endsWith(";")) cleanCmd = cleanCmd.slice(0, -1).trim();
198
- }
199
-
200
- const words = shellWords(cleanCmd);
201
- let i = 0;
202
- let changesCwd = false;
203
- while (i < words.length) {
204
- const rawWord = words[i]!;
205
- const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
206
- if (!word) {
207
- i++;
208
- continue;
209
- }
210
-
211
- if (word === "command" || word === "exec" || word === "eval" || word === "nohup") {
212
- i++;
213
- continue;
214
- }
215
- if (word === "nice") {
216
- i++;
217
- if (i < words.length && (words[i] === "-n" || words[i]?.startsWith("-n"))) {
218
- if (words[i] === "-n") i += 2;
219
- else i++;
220
- }
221
- continue;
222
- }
223
- if (word === "timeout") {
224
- i++;
225
- while (i < words.length && words[i]!.startsWith("-")) {
226
- const opt = words[i++]!;
227
- if ((opt === "-k" || opt === "-s" || opt === "--signal" || opt === "--kill-after") && i < words.length) {
228
- i++;
229
- }
230
- }
231
- if (i < words.length && /^\d+[smhd]?$/.test(words[i]!)) {
232
- i++;
233
- }
234
- continue;
235
- }
236
- if (word === "stdbuf") {
237
- i++;
238
- while (i < words.length && words[i]!.startsWith("-")) {
239
- const opt = words[i++]!;
240
- if ((opt === "-i" || opt === "-o" || opt === "-e") && i < words.length) {
241
- i++;
242
- }
243
- }
244
- continue;
245
- }
246
- if (word === "time") {
247
- i++;
248
- while (i < words.length && words[i]!.startsWith("-")) {
249
- i++;
250
- }
251
- continue;
252
- }
253
- if (word === "sudo" || word === "doas") {
254
- i++;
255
- const valueOptions = new Set([
256
- "-u",
257
- "--user",
258
- "-g",
259
- "--group",
260
- "-h",
261
- "--host",
262
- "-p",
263
- "--prompt",
264
- "-C",
265
- "--close-from",
266
- "-R",
267
- "--chroot",
268
- "-D",
269
- "--chdir",
270
- ]);
271
- while (i < words.length && words[i]!.startsWith("-")) {
272
- const option = words[i++]!;
273
- if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir=")) changesCwd = true;
274
- if (valueOptions.has(option) && i < words.length) i++;
275
- }
276
- continue;
277
- }
278
- if (word === "env") {
279
- i++;
280
- const valueOptions = new Set([
281
- "-u",
282
- "--unset",
283
- "--argv0",
284
- "-C",
285
- "--chdir",
286
- "-S",
287
- "--split-string",
288
- ]);
289
- while (i < words.length) {
290
- const w = words[i]!;
291
- if (w === "--") {
292
- i++;
293
- break;
294
- }
295
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
296
- i++;
297
- continue;
298
- }
299
- let splitValue: string | undefined;
300
- let consumed = 0;
301
- const clustered = /^-[iv0]*([SC])(.*)$/.exec(w);
302
- if (clustered?.[1] === "C") {
303
- changesCwd = true;
304
- i++;
305
- if (!clustered[2] && i < words.length) i++;
306
- continue;
307
- }
308
- if (clustered?.[1] === "S") {
309
- if (clustered[2]) splitValue = clustered[2];
310
- else if (i + 1 < words.length) {
311
- splitValue = words[i + 1]!;
312
- consumed = 1;
313
- }
314
- }
315
- else if (w.startsWith("-S") && w !== "-S") splitValue = w.slice(2);
316
- else if (w.startsWith("--split-string=")) splitValue = w.slice("--split-string=".length);
317
- else if ((w === "-S" || w === "--split-string") && i + 1 < words.length) {
318
- splitValue = words[i + 1]!;
319
- consumed = 1;
320
- }
321
- if (splitValue !== undefined) {
322
- words.splice(i, consumed + 1, ...envSplitWords(splitValue));
323
- continue;
324
- }
325
- const clusteredValue = /^-[iv0]*([ua])(.*)$/.exec(w);
326
- if (clusteredValue) {
327
- i++;
328
- if (!clusteredValue[2] && i < words.length) i++;
329
- continue;
330
- }
331
- if (w.startsWith("-C") && w !== "-C") {
332
- changesCwd = true;
333
- i++;
334
- continue;
335
- }
336
- if (!w.startsWith("-")) break;
337
- i++;
338
- if (w === "-C" || w === "--chdir" || w.startsWith("--chdir=")) changesCwd = true;
339
- if (valueOptions.has(w) && i < words.length) i++;
340
- }
341
- continue;
342
- }
343
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
344
- i++;
345
- continue;
346
- }
347
- break;
348
- }
349
- return { words: words
350
- .slice(i)
351
- .map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
352
- .filter(Boolean), changesCwd };
353
- }
19
+ export const SENSITIVE_ENV_KEYS = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_KEY", "KUBECONFIG", "NPM_TOKEN", "DOCKER_AUTH", "GOOGLE_APPLICATION_CREDENTIALS", "GCLOUD_AUTH", "AZURE_CREDENTIALS", "SLACK_TOKEN"];
20
+ export const SENSITIVE_ENV_RE = /^(AWS_|KUBE|OPENAI|ANTHROPIC|GOOGLE_|GCP_|AZURE_|SLACK_|NPM_|DOCKER_|KUBECONFIG)/;
354
21
 
355
- export function unwrapShellWords(command: string): string[] {
356
- return unwrapShellWordsImpl(command).words;
22
+ export function isSensitiveEnvKey(key: string): boolean {
23
+ return SENSITIVE_ENV_KEYS.includes(key) || SENSITIVE_ENV_RE.test(key);
357
24
  }
358
25
 
359
- export function shellWrappersChangeCwd(command: string): boolean {
360
- return unwrapShellWordsImpl(command).changesCwd;
361
- }
362
-
363
- export function unwrapShellCommand(command: string): string {
364
- return unwrapShellWords(command).join(" ");
365
- }
366
-
367
- export const SENSITIVE_ENV_KEYS = [
368
- "OPENAI_API_KEY",
369
- "ANTHROPIC_API_KEY",
370
- "OPENAI_KEY",
371
- "KUBECONFIG",
372
- "NPM_TOKEN",
373
- "DOCKER_AUTH",
374
- "GOOGLE_APPLICATION_CREDENTIALS",
375
- "GCLOUD_AUTH",
376
- "AZURE_CREDENTIALS",
377
- "SLACK_TOKEN",
378
- ];
379
- export const SENSITIVE_ENV_RE =
380
- /^(AWS_|KUBE|OPENAI|ANTHROPIC|GOOGLE_|GCP_|AZURE_|SLACK_|NPM_|DOCKER_|KUBECONFIG)/;
381
-
382
26
  export function getCleanEnv(): Record<string, string> {
383
27
  const env: Record<string, string> = { ...(process.env as Record<string, string>) };
384
- for (const key of SENSITIVE_ENV_KEYS) {
385
- delete env[key];
386
- }
387
28
  for (const key of Object.keys(env)) {
388
- if (SENSITIVE_ENV_RE.test(key)) {
389
- delete env[key];
390
- }
29
+ if (isSensitiveEnvKey(key)) delete env[key];
391
30
  }
392
31
  return env;
393
32
  }
@@ -395,12 +34,6 @@ export function getCleanEnv(): Record<string, string> {
395
34
  export async function showBlockToast(message: string): Promise<void> {
396
35
  try {
397
36
  const client = getSdkClient();
398
- await client?.tui?.showToast?.({
399
- body: {
400
- title: "Workflow Guard Blocked",
401
- message: message.slice(0, 180),
402
- variant: "warning",
403
- },
404
- });
37
+ await client?.tui?.showToast?.({ body: { title: "Workflow Guard Blocked", message: message.slice(0, 180), variant: "warning" } });
405
38
  } catch {}
406
39
  }
package/src/lib/verify.ts CHANGED
@@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { readFileSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { getCleanEnv, normalize } from "./utils.ts";
5
+ import { dynamicShellSyntaxIn, normalize } from "./shell.ts";
6
+ import { getCleanEnv } from "./utils.ts";
6
7
  import { getProjectConfig } from "./state.ts";
7
8
  import { normalizeGitCommands } from "../policies/git.ts";
8
9
  import { liveMutationIn } from "../policies/destructive.ts";
@@ -163,6 +164,14 @@ export async function runVerify(
163
164
  const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
164
165
 
165
166
  if (!allowLive) {
167
+ const dynamicSyntax = dynamicShellSyntaxIn(command);
168
+ if (dynamicSyntax) {
169
+ return {
170
+ passed: false,
171
+ output: `Verification command blocked: contains ${dynamicSyntax}`,
172
+ durationMs: 0,
173
+ };
174
+ }
166
175
  const normalized = normalizeGitCommands(normalize(command));
167
176
  const liveCheck = liveMutationIn(normalized);
168
177
  if (liveCheck) {
@@ -184,11 +193,13 @@ export async function runVerify(
184
193
  return new Promise((resolve) => {
185
194
  let output = "";
186
195
  let timer: NodeJS.Timeout | undefined;
196
+ let timedOut = false;
187
197
  let child: ReturnType<typeof spawn>;
188
198
  try {
189
199
  child = spawn(command, {
190
200
  cwd: root,
191
201
  shell: true,
202
+ detached: process.platform !== "win32",
192
203
  env: getCleanEnv(),
193
204
  stdio: ["ignore", "pipe", "pipe"],
194
205
  });
@@ -201,13 +212,13 @@ export async function runVerify(
201
212
  }
202
213
 
203
214
  timer = setTimeout(() => {
204
- try {
205
- child.kill("SIGKILL");
206
- } catch {}
207
- resolve({
208
- passed: false,
209
- output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
210
- durationMs: Date.now() - start,
215
+ timedOut = true;
216
+ void terminateProcessTree(child.pid, () => child.kill("SIGKILL")).finally(() => {
217
+ resolve({
218
+ passed: false,
219
+ output: output + `\n(Verification timed out after ${Math.round(timeoutMs / 1000)}s)`,
220
+ durationMs: Date.now() - start,
221
+ });
211
222
  });
212
223
  }, timeoutMs);
213
224
 
@@ -221,11 +232,61 @@ export async function runVerify(
221
232
  });
222
233
  child.on("close", (code) => {
223
234
  if (timer) clearTimeout(timer);
235
+ if (timedOut) return;
224
236
  resolve({ passed: code === 0, output, durationMs: Date.now() - start });
225
237
  });
226
238
  child.on("error", (err) => {
227
239
  if (timer) clearTimeout(timer);
240
+ if (timedOut) return;
228
241
  resolve({ passed: false, output: `(spawn failed: ${err.message})`, durationMs: Date.now() - start });
229
242
  });
230
243
  });
231
244
  }
245
+
246
+ type WindowsTreeKiller = (pid: number) => Promise<void>;
247
+
248
+ function killWindowsProcessTree(pid: number): Promise<void> {
249
+ return new Promise((resolve, reject) => {
250
+ let settled = false;
251
+ let killer: ReturnType<typeof spawn> | undefined;
252
+ const finish = (err?: Error) => {
253
+ if (!settled) {
254
+ settled = true;
255
+ clearTimeout(cleanupTimer);
256
+ err ? reject(err) : resolve();
257
+ }
258
+ };
259
+ const cleanupTimer = setTimeout(() => {
260
+ try { killer?.kill("SIGKILL"); } catch {}
261
+ finish(new Error("taskkill timed out"));
262
+ }, 5_000);
263
+ try {
264
+ killer = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore" });
265
+ killer.once("close", (code) => finish(code === 0 ? undefined : new Error(`taskkill exited ${code}`)));
266
+ killer.once("error", (err) => finish(err));
267
+ } catch (err) {
268
+ finish(err instanceof Error ? err : new Error("taskkill failed"));
269
+ }
270
+ });
271
+ }
272
+
273
+ export async function terminateProcessTree(
274
+ pid: number | undefined,
275
+ fallbackKill: () => void,
276
+ platform: NodeJS.Platform = process.platform,
277
+ windowsTreeKiller: WindowsTreeKiller = killWindowsProcessTree,
278
+ ): Promise<void> {
279
+ try {
280
+ if (platform === "win32" && pid) {
281
+ await windowsTreeKiller(pid);
282
+ } else if (pid) {
283
+ process.kill(-pid, "SIGKILL");
284
+ } else {
285
+ fallbackKill();
286
+ }
287
+ } catch {
288
+ try {
289
+ fallbackKill();
290
+ } catch {}
291
+ }
292
+ }
@@ -7,7 +7,7 @@ import {
7
7
  getWorkspaceRootReal,
8
8
  recordMutation,
9
9
  } from "../lib/state.ts";
10
- import { shellWords, unwrapShellCommand } from "../lib/utils.ts";
10
+ import { shellWords, unwrapShellCommand } from "../lib/shell.ts";
11
11
  import { isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
12
12
  import { isSecretPath, secretIn } from "./secrets.ts";
13
13
  import { onProtectedBranch, branchGuardReason } from "./git.ts";
@@ -91,7 +91,7 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
91
91
  export function extractPatchPaths(patchText: string): string[] {
92
92
  const paths: string[] = [];
93
93
  const markerRe =
94
- /^\*\*\*\s+(?:Add File|Update File|Delete File|Move to|Move from):\s*(\S+)/gm;
94
+ /^\*\*\*\s+(?:Add File|Update File|Delete File|Move to|Move from):\s*(.+?)\s*$/gm;
95
95
  let match: RegExpExecArray | null;
96
96
  while ((match = markerRe.exec(patchText)) !== null) {
97
97
  if (match[1]) paths.push(match[1]);
@@ -384,7 +384,7 @@ export async function guardShellMutation(
384
384
  }
385
385
  }
386
386
  if (hasMutation) {
387
- recordMutation(await effectiveTodoOwnerSessionID(sessionID));
387
+ recordMutation(await effectiveTodoOwnerSessionID(sessionID), sessionID);
388
388
  }
389
389
  return undefined;
390
390
  }
@@ -2,9 +2,9 @@ import { spawnSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { isAbsolute, resolve } from "node:path";
4
4
  import { getWorkspaceRoot } from "../lib/state.ts";
5
- import { splitShellSegments, unwrapShellCommand, unwrapShellWords } from "../lib/utils.ts";
5
+ import { splitShellSegments, unwrapShellCommand, unwrapShellWords } from "../lib/shell.ts";
6
6
 
7
- export const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?changelog\s*(?::|$)/im;
7
+ export const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?(?:changelog|changes|release notes?|summary)\s*(?::\s*\S.+|:?\s*\r?\n\s*(?![\\#])\S.+)/im;
8
8
 
9
9
  export function hasPrCreateInvocation(command: string): boolean {
10
10
  return splitShellSegments(command)
@@ -74,6 +74,11 @@ export function recordUserMessage(sessionID: string, messageID?: string): void {
74
74
  state.counts.delete(sessionID);
75
75
  }
76
76
 
77
+ export function isGeneratedContinuationMessage(sessionID: string, messageID?: string): boolean {
78
+ if (!messageID) return false;
79
+ return getState()?.generatedMessageIDs.get(sessionID)?.has(messageID) === true;
80
+ }
81
+
77
82
  export function clearContinuationState(sessionID: string): void {
78
83
  const state = getState();
79
84
  if (!state) return;
@@ -1,4 +1,5 @@
1
- import { asRecord, shellWords, splitShellSegments } from "../lib/utils.ts";
1
+ import { shellWords, splitShellSegments } from "../lib/shell.ts";
2
+ import { asRecord } from "../lib/utils.ts";
2
3
 
3
4
  export interface LivePattern {
4
5
  re: RegExp;