@hegemonart/get-design-done 1.19.6 → 1.20.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.
Files changed (93) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +60 -0
  4. package/README.md +12 -0
  5. package/agents/design-reflector.md +13 -0
  6. package/connections/connections.md +3 -0
  7. package/connections/figma.md +2 -0
  8. package/connections/gdd-state.md +186 -0
  9. package/hooks/budget-enforcer.ts +716 -0
  10. package/hooks/context-exhaustion.ts +251 -0
  11. package/hooks/gdd-read-injection-scanner.ts +172 -0
  12. package/hooks/hooks.json +3 -3
  13. package/package.json +19 -6
  14. package/reference/config-schema.md +2 -2
  15. package/reference/error-recovery.md +58 -0
  16. package/reference/registry.json +7 -0
  17. package/reference/schemas/budget.schema.json +42 -0
  18. package/reference/schemas/events.schema.json +55 -0
  19. package/reference/schemas/generated.d.ts +419 -0
  20. package/reference/schemas/iteration-budget.schema.json +36 -0
  21. package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
  22. package/reference/schemas/rate-limits.schema.json +31 -0
  23. package/scripts/aggregate-agent-metrics.ts +282 -0
  24. package/scripts/codegen-schema-types.ts +149 -0
  25. package/scripts/lib/error-classifier.cjs +232 -0
  26. package/scripts/lib/error-classifier.d.cts +44 -0
  27. package/scripts/lib/event-stream/emitter.ts +88 -0
  28. package/scripts/lib/event-stream/index.ts +154 -0
  29. package/scripts/lib/event-stream/types.ts +127 -0
  30. package/scripts/lib/event-stream/writer.ts +154 -0
  31. package/scripts/lib/gdd-errors/classification.ts +124 -0
  32. package/scripts/lib/gdd-errors/index.ts +218 -0
  33. package/scripts/lib/gdd-state/gates.ts +216 -0
  34. package/scripts/lib/gdd-state/index.ts +167 -0
  35. package/scripts/lib/gdd-state/lockfile.ts +232 -0
  36. package/scripts/lib/gdd-state/mutator.ts +574 -0
  37. package/scripts/lib/gdd-state/parser.ts +523 -0
  38. package/scripts/lib/gdd-state/types.ts +179 -0
  39. package/scripts/lib/iteration-budget.cjs +205 -0
  40. package/scripts/lib/iteration-budget.d.cts +32 -0
  41. package/scripts/lib/jittered-backoff.cjs +112 -0
  42. package/scripts/lib/jittered-backoff.d.cts +38 -0
  43. package/scripts/lib/lockfile.cjs +177 -0
  44. package/scripts/lib/lockfile.d.cts +21 -0
  45. package/scripts/lib/prompt-sanitizer/index.ts +435 -0
  46. package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
  47. package/scripts/lib/rate-guard.cjs +365 -0
  48. package/scripts/lib/rate-guard.d.cts +38 -0
  49. package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
  50. package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
  51. package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
  52. package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
  53. package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
  54. package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
  55. package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
  56. package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
  57. package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
  58. package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
  59. package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
  60. package/scripts/mcp-servers/gdd-state/server.ts +288 -0
  61. package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
  62. package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
  63. package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
  64. package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
  65. package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
  66. package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
  67. package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
  68. package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
  69. package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
  70. package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
  71. package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
  72. package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
  73. package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
  74. package/scripts/validate-frontmatter.ts +114 -0
  75. package/scripts/validate-schemas.ts +401 -0
  76. package/skills/brief/SKILL.md +15 -6
  77. package/skills/design/SKILL.md +31 -13
  78. package/skills/explore/SKILL.md +41 -17
  79. package/skills/health/SKILL.md +15 -4
  80. package/skills/optimize/SKILL.md +3 -3
  81. package/skills/pause/SKILL.md +16 -10
  82. package/skills/plan/SKILL.md +33 -17
  83. package/skills/progress/SKILL.md +15 -11
  84. package/skills/resume/SKILL.md +19 -10
  85. package/skills/settings/SKILL.md +11 -3
  86. package/skills/todo/SKILL.md +12 -3
  87. package/skills/verify/SKILL.md +65 -29
  88. package/hooks/budget-enforcer.js +0 -329
  89. package/hooks/context-exhaustion.js +0 -127
  90. package/hooks/gdd-read-injection-scanner.js +0 -39
  91. package/scripts/aggregate-agent-metrics.js +0 -173
  92. package/scripts/validate-frontmatter.cjs +0 -68
  93. package/scripts/validate-schemas.cjs +0 -242
@@ -0,0 +1,435 @@
1
+ // scripts/lib/prompt-sanitizer/index.ts
2
+ //
3
+ // sanitize() — remove human-gating constructs from skill markdown bodies so
4
+ // they can execute in Phase 21's headless Anthropic Agent SDK runner.
5
+ //
6
+ // This is a TEXT TRANSFORM, not a parser. Skills are markdown; we use regex
7
+ // and light structural parsing (frontmatter/code-fence boundary detection).
8
+ // A lossy transform is the correct fidelity here — skills were authored for
9
+ // interactive CC and we are neutering the interactive constructs.
10
+ //
11
+ // Contracts (verified by tests/prompt-sanitizer.test.ts):
12
+ // - Deterministic: sanitize(x) === sanitize(x).
13
+ // - Idempotent: sanitize(sanitize(x).sanitized).sanitized === sanitize(x).sanitized.
14
+ // - Code fences are preserved byte-identical (content inside triple-backticks
15
+ // is never transformed).
16
+ // - Frontmatter (leading `---\n...\n---\n`) is preserved byte-identical.
17
+ // - Empty input returns `{ sanitized: '', applied: [], removedSections: [] }`.
18
+ //
19
+ // Consumed by: Phase 21's session-runner (scripts/session-runner or similar).
20
+
21
+ import {
22
+ PATTERNS,
23
+ HUMAN_VERIFY_HEADING,
24
+ HUMAN_VERIFY_LABEL,
25
+ ASK_USER_Q_REPLACEMENT,
26
+ type SanitizePattern,
27
+ } from './patterns.ts';
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Public API
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface SanitizeOptions {
34
+ /** When false, code-fence content is treated as regular text. Default: true. */
35
+ readonly preserveCodeFences?: boolean;
36
+ /** When false, leading frontmatter is treated as regular text. Default: true. */
37
+ readonly preserveFrontmatter?: boolean;
38
+ }
39
+
40
+ export interface SanitizeResult {
41
+ /** The transformed text. */
42
+ readonly sanitized: string;
43
+ /** Pattern names that actually fired at least once (sorted, de-duplicated). */
44
+ readonly applied: readonly string[];
45
+ /** Headings of sections removed in full (e.g. 'HUMAN VERIFY'). */
46
+ readonly removedSections: readonly string[];
47
+ }
48
+
49
+ /**
50
+ * Strip human-gating constructs from a skill body.
51
+ *
52
+ * @param raw the full SKILL.md contents (frontmatter + body).
53
+ * @param opts optional toggles; both default to true.
54
+ * @returns `{ sanitized, applied, removedSections }` — see types above.
55
+ */
56
+ export function sanitize(raw: string, opts?: SanitizeOptions): SanitizeResult {
57
+ if (raw.length === 0) {
58
+ return { sanitized: '', applied: [], removedSections: [] };
59
+ }
60
+
61
+ const preserveCodeFences: boolean = opts?.preserveCodeFences !== false;
62
+ const preserveFrontmatter: boolean = opts?.preserveFrontmatter !== false;
63
+
64
+ // 1. Split out frontmatter.
65
+ const { frontmatter, body } = preserveFrontmatter
66
+ ? splitFrontmatter(raw)
67
+ : { frontmatter: '', body: raw };
68
+
69
+ // 2. Split body into text / code-fence segments.
70
+ const segments: Segment[] = preserveCodeFences
71
+ ? splitCodeFences(body)
72
+ : [{ kind: 'text', content: body }];
73
+
74
+ // 3. Transform text segments only.
75
+ const applied: Set<string> = new Set<string>();
76
+ const removedSections: string[] = [];
77
+
78
+ const transformed: string[] = segments.map((seg: Segment): string => {
79
+ if (seg.kind === 'code') return seg.content;
80
+ return transformTextSegment(seg.content, applied, removedSections);
81
+ });
82
+
83
+ // 4. Reassemble.
84
+ const rebuiltBody: string = transformed.join('');
85
+ const sanitized: string = frontmatter + rebuiltBody;
86
+
87
+ // 5. Normalize output: collapse 3+ consecutive blank lines to 2. Applied to
88
+ // the body only so frontmatter formatting is never altered.
89
+ const normalizedBody: string = collapseBlankLines(rebuiltBody);
90
+ const out: string = frontmatter + normalizedBody;
91
+
92
+ return {
93
+ sanitized: out === sanitized ? sanitized : out,
94
+ applied: Array.from(applied).sort(),
95
+ removedSections: removedSections.slice(),
96
+ };
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Internal — segment model
101
+ // ---------------------------------------------------------------------------
102
+
103
+ interface TextSegment {
104
+ readonly kind: 'text';
105
+ readonly content: string;
106
+ }
107
+ interface CodeSegment {
108
+ readonly kind: 'code';
109
+ readonly content: string;
110
+ }
111
+ type Segment = TextSegment | CodeSegment;
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Frontmatter detection
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Detect a leading YAML-ish frontmatter block. A frontmatter block must begin
119
+ * at position 0 with `---\n` (or `---\r\n`), contain any content, then end
120
+ * with `\n---\n`. Anything else is treated as body.
121
+ */
122
+ function splitFrontmatter(raw: string): { frontmatter: string; body: string } {
123
+ // Accept CRLF or LF.
124
+ const match: RegExpExecArray | null = /^---\r?\n[\s\S]*?\r?\n---\r?\n/.exec(raw);
125
+ if (match === null) {
126
+ return { frontmatter: '', body: raw };
127
+ }
128
+ const fm: string = match[0];
129
+ return { frontmatter: fm, body: raw.slice(fm.length) };
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Code-fence splitter
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Walk the body line by line, toggling in/out of code-fence state whenever a
138
+ * line matches `^```` (three-or-more backticks, optional language tag). An
139
+ * unclosed fence at EOF keeps everything after the opening fence as `code`
140
+ * (per plan spec: "Unclosed code fence → treat everything after the opening
141
+ * fence as code").
142
+ *
143
+ * Content is preserved with original line endings. The returned segments,
144
+ * when concatenated, reproduce the input exactly.
145
+ */
146
+ function splitCodeFences(body: string): Segment[] {
147
+ const segments: Segment[] = [];
148
+ // Split keeping newlines. We build segments by iterating lines and tracking
149
+ // mode. A segment boundary coincides with a fence line.
150
+ const fenceRe: RegExp = /^ {0,3}`{3,}[^`\n]*$/;
151
+
152
+ let mode: 'text' | 'code' = 'text';
153
+ let buf: string[] = [];
154
+
155
+ // Preserve original line endings by splitting on \n and tracking whether
156
+ // each line ended with \r. We'll re-emit the exact trailing newline.
157
+ const parts: string[] = body.split('\n');
158
+ // When we split on '\n', the last element is what followed the final '\n'.
159
+ // Reassembly: parts.join('\n') === body exactly.
160
+
161
+ const flush = (): void => {
162
+ if (buf.length === 0) return;
163
+ const joined: string = buf.join('\n');
164
+ if (mode === 'text') {
165
+ segments.push({ kind: 'text', content: joined });
166
+ } else {
167
+ segments.push({ kind: 'code', content: joined });
168
+ }
169
+ buf = [];
170
+ };
171
+
172
+ for (let i = 0; i < parts.length; i++) {
173
+ const line: string = parts[i] as string;
174
+ const lineNoCr: string = line.endsWith('\r') ? line.slice(0, -1) : line;
175
+ const isFence: boolean = fenceRe.test(lineNoCr);
176
+
177
+ if (isFence) {
178
+ if (mode === 'text') {
179
+ // Close text segment; fence line starts the code segment.
180
+ flush();
181
+ mode = 'code';
182
+ buf.push(line);
183
+ } else {
184
+ // Close code segment; fence line is the last code line.
185
+ buf.push(line);
186
+ flush();
187
+ mode = 'text';
188
+ }
189
+ } else {
190
+ buf.push(line);
191
+ }
192
+ }
193
+
194
+ // Emit trailing segment. If we're still in 'code' mode (unclosed fence), it
195
+ // stays code per spec.
196
+ flush();
197
+
198
+ // Join segments back with '\n' separators — the parts array uses '\n' as
199
+ // the split delimiter, so reassembly needs the same.
200
+ // Insert '\n' between consecutive segments.
201
+ const joined: Segment[] = [];
202
+ for (let i = 0; i < segments.length; i++) {
203
+ const seg: Segment = segments[i] as Segment;
204
+ if (i === 0) {
205
+ joined.push(seg);
206
+ } else {
207
+ // Prepend '\n' onto this segment so concat(seg0.content + seg1.content + ...) === body.
208
+ const prev: Segment = joined[joined.length - 1] as Segment;
209
+ if (seg.kind === prev.kind) {
210
+ // Merge (shouldn't normally happen due to toggle logic).
211
+ joined[joined.length - 1] = { kind: prev.kind, content: prev.content + '\n' + seg.content };
212
+ } else {
213
+ joined.push({ kind: seg.kind, content: '\n' + seg.content });
214
+ }
215
+ }
216
+ }
217
+
218
+ return joined;
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Text-segment transformation
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /**
226
+ * Apply all pattern replacements to a single text segment.
227
+ *
228
+ * The order is:
229
+ * 1. Strip `## HUMAN VERIFY` sections.
230
+ * 2. Paren-balanced replacement of `AskUserQuestion(...)` calls.
231
+ * 3. Iterate `PATTERNS` (file-ref, at-prefix, slash-cmd, stop-line,
232
+ * prose-wait). We skip the `ask-user-q` regex because step 2 owns it.
233
+ *
234
+ * Mutates `applied` and `removedSections`.
235
+ */
236
+ function transformTextSegment(
237
+ segment: string,
238
+ applied: Set<string>,
239
+ removedSections: string[],
240
+ ): string {
241
+ let text: string = segment;
242
+
243
+ // 1. HUMAN VERIFY sections.
244
+ text = stripHumanVerifySections(text, removedSections);
245
+
246
+ // 2. AskUserQuestion(...) — paren-balanced.
247
+ const afterAsk: { text: string; replaced: number } = replaceAskUserQuestion(text);
248
+ if (afterAsk.replaced > 0) applied.add('ask-user-q');
249
+ text = afterAsk.text;
250
+
251
+ // 3. Regex patterns.
252
+ for (const pattern of PATTERNS) {
253
+ if (pattern.name === 'ask-user-q') continue; // owned by step 2
254
+ const before: string = text;
255
+ const replace: SanitizePattern['replace'] = pattern.replace;
256
+ text =
257
+ typeof replace === 'string'
258
+ ? text.replace(pattern.match, replace)
259
+ : text.replace(pattern.match, ((match: string, ...args: unknown[]): string =>
260
+ replace(match, ...args)) as unknown as (substring: string, ...args: unknown[]) => string);
261
+ if (text !== before) applied.add(pattern.name);
262
+ }
263
+
264
+ return text;
265
+ }
266
+
267
+ /**
268
+ * Remove `## HUMAN VERIFY` sections from start of a heading line through the
269
+ * next `## ` heading (or end-of-segment). Records each removed section in
270
+ * `removedSections`.
271
+ */
272
+ function stripHumanVerifySections(text: string, removedSections: string[]): string {
273
+ let out: string = text;
274
+ // Loop because multiple HUMAN VERIFY sections could exist in one segment.
275
+ for (;;) {
276
+ const re: RegExp = new RegExp(HUMAN_VERIFY_HEADING.source, HUMAN_VERIFY_HEADING.flags);
277
+ const m: RegExpExecArray | null = re.exec(out);
278
+ if (m === null) break;
279
+ removedSections.push(HUMAN_VERIFY_LABEL);
280
+ const before: string = out.slice(0, m.index);
281
+ const after: string = out.slice(m.index + m[0].length);
282
+ out = before + after;
283
+ }
284
+ return out;
285
+ }
286
+
287
+ /**
288
+ * Find every `AskUserQuestion(` and replace the entire balanced call (paren
289
+ * depth tracked across string/template literals) with a neutral marker.
290
+ *
291
+ * Returns the transformed text and a count of replacements.
292
+ *
293
+ * Implementation notes:
294
+ * - Tracks single-quote, double-quote, and backtick string literals.
295
+ * Template-literal `${...}` re-enters code mode for paren counting.
296
+ * - Does NOT attempt to track comments — skill bodies aren't JS, they may
297
+ * contain prose like "// foo" that coincidentally sits inside a call.
298
+ * Close parens inside string literals are still safely skipped.
299
+ * - If a match opens but never closes (malformed input), the whole tail
300
+ * from the open paren is replaced — the input is broken anyway.
301
+ */
302
+ function replaceAskUserQuestion(text: string): { text: string; replaced: number } {
303
+ const opener: RegExp = /AskUserQuestion\s*\(/g;
304
+ let result: string = '';
305
+ let cursor: number = 0;
306
+ let replaced: number = 0;
307
+
308
+ for (;;) {
309
+ opener.lastIndex = cursor;
310
+ const match: RegExpExecArray | null = opener.exec(text);
311
+ if (match === null) {
312
+ result += text.slice(cursor);
313
+ break;
314
+ }
315
+
316
+ const matchStart: number = match.index;
317
+ const openParenIdx: number = match.index + match[0].length - 1; // index of the '('
318
+
319
+ // Emit text before the match untouched.
320
+ result += text.slice(cursor, matchStart);
321
+
322
+ // Walk forward from openParenIdx, tracking paren depth + string state.
323
+ const endExclusive: number = findBalancedClose(text, openParenIdx);
324
+ // endExclusive is the index AFTER the closing ')'. If malformed, it's text.length.
325
+
326
+ result += ASK_USER_Q_REPLACEMENT;
327
+ cursor = endExclusive;
328
+ replaced += 1;
329
+ }
330
+
331
+ return { text: result, replaced };
332
+ }
333
+
334
+ /**
335
+ * Starting at index `openIdx` (which must be an opening paren), walk forward
336
+ * and return the index AFTER the matching close paren, honoring string/
337
+ * template literals and `${...}` template expressions.
338
+ *
339
+ * If the paren never closes, returns `text.length`.
340
+ */
341
+ function findBalancedClose(text: string, openIdx: number): number {
342
+ // State stack: tracks what context we're in.
343
+ // 'paren' — inside parens, counts toward depth.
344
+ // 'sq' — inside single-quoted string.
345
+ // 'dq' — inside double-quoted string.
346
+ // 'tpl' — inside template-literal (backtick) string.
347
+ // 'tpl-expr' — inside ${...} expression inside a template literal.
348
+ type State = 'paren' | 'sq' | 'dq' | 'tpl' | 'tpl-expr';
349
+ const stack: State[] = ['paren'];
350
+ let i: number = openIdx + 1;
351
+ const n: number = text.length;
352
+
353
+ while (i < n) {
354
+ const ch: string = text[i] as string;
355
+ const top: State = stack[stack.length - 1] as State;
356
+
357
+ // Escape: inside any string, a backslash consumes the next char.
358
+ if ((top === 'sq' || top === 'dq' || top === 'tpl') && ch === '\\') {
359
+ i += 2;
360
+ continue;
361
+ }
362
+
363
+ switch (top) {
364
+ case 'paren':
365
+ case 'tpl-expr': {
366
+ if (ch === '(') {
367
+ stack.push('paren');
368
+ } else if (ch === ')') {
369
+ stack.pop();
370
+ if (stack.length === 0) return i + 1;
371
+ } else if (ch === "'") {
372
+ stack.push('sq');
373
+ } else if (ch === '"') {
374
+ stack.push('dq');
375
+ } else if (ch === '`') {
376
+ stack.push('tpl');
377
+ } else if (top === 'tpl-expr' && ch === '}') {
378
+ stack.pop();
379
+ } else if (ch === '{') {
380
+ // Inside a nested expression `${...}` we count braces so we don't
381
+ // mistake inner `}` for the template-expression terminator.
382
+ // Push a sentinel paren-equivalent: reuse 'paren' so '(' / ')' logic
383
+ // also correctly counts inner calls, but we need a distinct marker
384
+ // for '}'. Simplest approach: track braces via a separate counter.
385
+ // Here we just ignore '{' because inside 'paren' context plain JS
386
+ // object literals don't affect our close-paren search (we only
387
+ // care about parens + strings). Intentional no-op.
388
+ }
389
+ break;
390
+ }
391
+ case 'sq': {
392
+ if (ch === "'") stack.pop();
393
+ break;
394
+ }
395
+ case 'dq': {
396
+ if (ch === '"') stack.pop();
397
+ break;
398
+ }
399
+ case 'tpl': {
400
+ if (ch === '`') {
401
+ stack.pop();
402
+ } else if (ch === '$' && text[i + 1] === '{') {
403
+ stack.push('tpl-expr');
404
+ i += 2;
405
+ continue;
406
+ }
407
+ break;
408
+ }
409
+ }
410
+ i += 1;
411
+ }
412
+
413
+ // Unterminated.
414
+ return n;
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Post-processing — collapse runs of blank lines
419
+ // ---------------------------------------------------------------------------
420
+
421
+ /**
422
+ * Replace any run of 3+ consecutive blank lines with exactly two blank lines
423
+ * (i.e. three consecutive '\n'). Preserves CRLF vs LF.
424
+ *
425
+ * Rationale: stripping `STOP` lines and HUMAN VERIFY sections can leave
426
+ * visually large holes in the output. Collapsing to at most 2 blank lines
427
+ * keeps the transform stable (idempotent) and readable.
428
+ */
429
+ function collapseBlankLines(text: string): string {
430
+ // Handle CRLF first so we don't double-transform.
431
+ if (text.includes('\r\n')) {
432
+ return text.replace(/(\r\n){3,}/g, '\r\n\r\n\r\n');
433
+ }
434
+ return text.replace(/\n{4,}/g, '\n\n\n');
435
+ }
@@ -0,0 +1,173 @@
1
+ // scripts/lib/prompt-sanitizer/patterns.ts
2
+ //
3
+ // Frozen pattern list used by `sanitize()` (see ./index.ts).
4
+ // Each entry is applied in order, per-segment, against the non-code-fence text
5
+ // of a skill body. Keep patterns independent — order is only relevant when two
6
+ // patterns would overlap (none do today, but add tests if you introduce one).
7
+ //
8
+ // Required by Phase 21's session-runner. Do NOT add runtime behavior here —
9
+ // this file is declarative only.
10
+
11
+ /**
12
+ * A single sanitization rule.
13
+ *
14
+ * `match` is a global regex run via `String.prototype.replace`.
15
+ * `replace` is either a static string or a function receiving the full match
16
+ * (and any captured groups, per the standard replace callback contract).
17
+ */
18
+ export interface SanitizePattern {
19
+ readonly name: string;
20
+ readonly match: RegExp;
21
+ readonly replace: string | ((substring: string, ...args: unknown[]) => string);
22
+ readonly description: string;
23
+ }
24
+
25
+ /**
26
+ * `@file:` references — the headless runner resolves paths differently from
27
+ * interactive CC, so these would leak stale context. Replace with a marker so
28
+ * downstream prompts don't silently omit the fact that something was stripped.
29
+ *
30
+ * Example: `@file:./notes.md` → `(file reference removed)`.
31
+ */
32
+ const FILE_REF: SanitizePattern = {
33
+ name: 'file-ref',
34
+ match: /@file:[^\s)]+/g,
35
+ replace: '(file reference removed)',
36
+ description: '@file:path.md style references (stripped — Claude Code resolves them at read time, headless runner does not)',
37
+ };
38
+
39
+ /**
40
+ * `@./` or `@/` path prefixes — same rationale as `file-ref` but different
41
+ * surface. Captures the leading whitespace/BOS so we don't accidentally glue
42
+ * adjacent tokens together.
43
+ *
44
+ * Example: `See @./skill.md for details` → `See (file reference removed) for details`.
45
+ *
46
+ * Carefully does NOT match `@hegemonart/npm-package` (the char after `@` must
47
+ * be `/` or `.`), or `@file-finder` (no slash at all).
48
+ */
49
+ const AT_PREFIX: SanitizePattern = {
50
+ name: 'at-prefix',
51
+ match: /(^|\s)@\.?\/[^\s)]+/g,
52
+ replace: (_m: string, ...args: unknown[]): string => {
53
+ const lead: string = typeof args[0] === 'string' ? args[0] : '';
54
+ return `${lead}(file reference removed)`;
55
+ },
56
+ description: '@./ or @/ relative path prefixes (stripped — same reason as file-ref)',
57
+ };
58
+
59
+ /**
60
+ * `/gdd:` slash-command invocations. No dispatch target exists in a headless
61
+ * session — CC's slash-command router is a CC-only construct. Trailing args
62
+ * on the same line are consumed.
63
+ *
64
+ * Example: `Run /gdd:progress` → `Run (slash command removed)`.
65
+ */
66
+ const SLASH_CMD: SanitizePattern = {
67
+ // NOTE: trailing-arg group uses [ \t]+ not \s+ to avoid eating the final
68
+ // newline on `Run /gdd:progress\n` style inputs (the \s class includes \n).
69
+ name: 'slash-cmd',
70
+ match: /\/gdd:[a-z-]+(?:[ \t]+[^\n]*)?/g,
71
+ replace: '(slash command removed)',
72
+ description: '/gdd:command invocations (stripped — no dispatch target in headless mode)',
73
+ };
74
+
75
+ /**
76
+ * `AskUserQuestion(` call-site markers. Only matches the opening token —
77
+ * paren balancing is done by the sanitizer driver in index.ts (can't express
78
+ * balanced parens in RE2-compatible regex). This entry exists so the pattern
79
+ * name is registered in the `applied` array; the actual substitution happens
80
+ * in the driver.
81
+ *
82
+ * The driver scans from each `AskUserQuestion(` forward, tracking string
83
+ * literals and paren depth, and replaces the entire call with the marker.
84
+ *
85
+ * Example: `AskUserQuestion({ title: 'x' })` → `(user question removed — proceed with default)`.
86
+ */
87
+ const ASK_USER_Q: SanitizePattern = {
88
+ name: 'ask-user-q',
89
+ // The driver in index.ts owns the paren-balanced walk. This regex is used
90
+ // only for match detection in the applied-array report, not for replacement.
91
+ match: /AskUserQuestion\s*\(/g,
92
+ replace: '(user question removed — proceed with default)',
93
+ description: 'AskUserQuestion(...) call sites (stripped with paren balancing — no user to answer in headless mode)',
94
+ };
95
+
96
+ /**
97
+ * Bare `STOP` directives on their own line — strip the entire line including
98
+ * any trailing prose on the same line. Matches `STOP`, `STOP if ...`, `STOP —
99
+ * verify X`, but NOT mid-sentence `stop` (case-sensitive, word-boundary).
100
+ *
101
+ * Example:
102
+ * Before the next step:
103
+ * STOP until the user confirms
104
+ * Then proceed.
105
+ * →
106
+ * Before the next step:
107
+ *
108
+ * Then proceed.
109
+ */
110
+ const STOP_LINE: SanitizePattern = {
111
+ // NOTE: leading-whitespace class is [ \t]* not \s* — \s includes \n which
112
+ // would cause the match to greedily consume the preceding blank line's
113
+ // newline when STOP is the last line of input, producing one-fewer newline
114
+ // than the authored separator between "resolution." and EOF.
115
+ name: 'stop-line',
116
+ match: /^[ \t]*STOP\b.*$/gm,
117
+ replace: '',
118
+ description: 'Lines starting with STOP (halt directives are interactive-only)',
119
+ };
120
+
121
+ /**
122
+ * English prose that describes waiting for a human. These phrases are
123
+ * case-insensitive (prose varies) but word-bounded so we don't match tokens
124
+ * embedded in identifiers.
125
+ *
126
+ * NOTE: keep this list narrow — overmatching risks neutering legitimate
127
+ * documentation about user-facing features. If a skill author wants to
128
+ * preserve such prose, they can quote it inside a code fence.
129
+ */
130
+ const PROSE_WAIT: SanitizePattern = {
131
+ name: 'prose-wait',
132
+ match: /\b(wait for user|ask the user|pause for|human confirmation|user approval)\b/gi,
133
+ replace: '(interactive gate removed)',
134
+ description: 'Prose interactive gates (e.g. "wait for user", "ask the user") replaced with a neutral marker',
135
+ };
136
+
137
+ /**
138
+ * The full ordered list. Exported as a `readonly` frozen array so consumers
139
+ * can enumerate but not mutate. Order matters only insofar as callers
140
+ * iterating this array expect `file-ref` and `at-prefix` to resolve before
141
+ * `slash-cmd` (distinct surfaces, no real conflict).
142
+ */
143
+ export const PATTERNS: readonly SanitizePattern[] = Object.freeze([
144
+ FILE_REF,
145
+ AT_PREFIX,
146
+ SLASH_CMD,
147
+ ASK_USER_Q,
148
+ STOP_LINE,
149
+ PROSE_WAIT,
150
+ ]);
151
+
152
+ /**
153
+ * Multi-line section stripper. Matches a `## HUMAN VERIFY` heading (with any
154
+ * trailing text on the heading line) and consumes everything up to the next
155
+ * `## ` heading at column 0 or end-of-input.
156
+ *
157
+ * Used by the sanitizer driver; not part of the `PATTERNS` list because its
158
+ * match/replace shape is different (consumes multiple lines, always strips
159
+ * to empty).
160
+ */
161
+ export const HUMAN_VERIFY_HEADING: RegExp = /^## HUMAN VERIFY[^\n]*\n[\s\S]*?(?=^## |$(?![\r\n]))/m;
162
+
163
+ /**
164
+ * Marker used when the ask-user-q paren-balancing walker collapses a call.
165
+ * Exported so tests can assert the exact substitution text.
166
+ */
167
+ export const ASK_USER_Q_REPLACEMENT: string = '(user question removed — proceed with default)';
168
+
169
+ /**
170
+ * Human-verify section removal marker stored in `removedSections`. Not written
171
+ * into the output (section is fully removed), only reported.
172
+ */
173
+ export const HUMAN_VERIFY_LABEL: string = 'HUMAN VERIFY';