pi-smart-compact 8.0.8 → 9.1.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 (88) hide show
  1. package/ARCHITECTURE.md +90 -70
  2. package/CHANGELOG.md +45 -2
  3. package/README.md +52 -30
  4. package/SECURITY.md +1 -1
  5. package/dist/app/native-continuity-bridge.d.ts.map +1 -1
  6. package/dist/app/pending-slot.d.ts.map +1 -1
  7. package/dist/app/preflight.d.ts +2 -1
  8. package/dist/app/preflight.d.ts.map +1 -1
  9. package/dist/app/run-context.d.ts +10 -10
  10. package/dist/app/run-context.d.ts.map +1 -1
  11. package/dist/app/run-smart-compact.d.ts +18 -2
  12. package/dist/app/run-smart-compact.d.ts.map +1 -1
  13. package/dist/app/session-run-lock.d.ts.map +1 -1
  14. package/dist/app/smart-compact-input.d.ts +23 -0
  15. package/dist/app/smart-compact-input.d.ts.map +1 -0
  16. package/dist/app/steps/extract.d.ts.map +1 -1
  17. package/dist/app/steps/metrics.d.ts +4 -4
  18. package/dist/app/steps/metrics.d.ts.map +1 -1
  19. package/dist/app/steps/persist.d.ts +2 -3
  20. package/dist/app/steps/persist.d.ts.map +1 -1
  21. package/dist/app/steps/prepare.d.ts +4 -6
  22. package/dist/app/steps/prepare.d.ts.map +1 -1
  23. package/dist/app/steps/recover.d.ts +1 -1
  24. package/dist/app/steps/recover.d.ts.map +1 -1
  25. package/dist/app/steps/state.d.ts.map +1 -1
  26. package/dist/app/steps/synthesize.d.ts.map +1 -1
  27. package/dist/app/steps/verify.d.ts.map +1 -1
  28. package/dist/app/steps/window.d.ts +5 -1
  29. package/dist/app/steps/window.d.ts.map +1 -1
  30. package/dist/constants.d.ts +25 -4
  31. package/dist/constants.d.ts.map +1 -1
  32. package/dist/domain/scrub.d.ts.map +1 -1
  33. package/dist/domain/summary-parse.d.ts +1 -1
  34. package/dist/domain/summary-parse.d.ts.map +1 -1
  35. package/dist/domain/summary-schema.d.ts +1 -1
  36. package/dist/domain/summary-schema.d.ts.map +1 -1
  37. package/dist/domain/tool-semantics.d.ts +15 -9
  38. package/dist/domain/tool-semantics.d.ts.map +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +3105 -1389
  41. package/dist/infra/ai-messages.d.ts +6 -0
  42. package/dist/infra/ai-messages.d.ts.map +1 -1
  43. package/dist/infra/context-graph.d.ts.map +1 -1
  44. package/dist/infra/fs.d.ts +13 -15
  45. package/dist/infra/fs.d.ts.map +1 -1
  46. package/dist/infra/git.d.ts +5 -11
  47. package/dist/infra/git.d.ts.map +1 -1
  48. package/dist/infra/llm-client.d.ts +2 -0
  49. package/dist/infra/llm-client.d.ts.map +1 -1
  50. package/dist/infra/paths.d.ts +4 -2
  51. package/dist/infra/paths.d.ts.map +1 -1
  52. package/dist/infra/session-identity.d.ts +11 -0
  53. package/dist/infra/session-identity.d.ts.map +1 -1
  54. package/dist/infra/synthesis-cache.d.ts.map +1 -1
  55. package/dist/phases/explore.d.ts +2 -1
  56. package/dist/phases/explore.d.ts.map +1 -1
  57. package/dist/phases/synthesize.d.ts +4 -1
  58. package/dist/phases/synthesize.d.ts.map +1 -1
  59. package/dist/phases/verify.d.ts +10 -3
  60. package/dist/phases/verify.d.ts.map +1 -1
  61. package/dist/provider-eval.js +278 -27
  62. package/dist/provider-scenario-eval.js +422 -49
  63. package/dist/telemetry-report.js +278 -27
  64. package/dist/types.d.ts +51 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/ui/overlays.d.ts +2 -1
  67. package/dist/ui/overlays.d.ts.map +1 -1
  68. package/dist/utils/backups.d.ts +31 -0
  69. package/dist/utils/backups.d.ts.map +1 -0
  70. package/dist/utils/cache.d.ts +3 -2
  71. package/dist/utils/cache.d.ts.map +1 -1
  72. package/dist/utils/damage.d.ts.map +1 -1
  73. package/dist/utils/extraction.d.ts +8 -1
  74. package/dist/utils/extraction.d.ts.map +1 -1
  75. package/dist/utils/fingerprint.d.ts +5 -1
  76. package/dist/utils/fingerprint.d.ts.map +1 -1
  77. package/dist/utils/helpers.d.ts +15 -37
  78. package/dist/utils/helpers.d.ts.map +1 -1
  79. package/dist/utils/pruning.d.ts.map +1 -1
  80. package/dist/utils/session-log.d.ts +10 -18
  81. package/dist/utils/session-log.d.ts.map +1 -1
  82. package/dist/utils/state.d.ts +2 -2
  83. package/dist/utils/state.d.ts.map +1 -1
  84. package/dist/utils/tokens.d.ts +2 -0
  85. package/dist/utils/tokens.d.ts.map +1 -1
  86. package/dist/utils/type-guards.d.ts +2 -0
  87. package/dist/utils/type-guards.d.ts.map +1 -1
  88. package/package.json +2 -2
@@ -82,6 +82,35 @@ function assertSuccessful(message) {
82
82
  }
83
83
  return message;
84
84
  }
85
+ async function withProviderDeadline(opts, invoke) {
86
+ if (opts.signal?.aborted)
87
+ throw new Error("LLM request aborted before dispatch");
88
+ const controller = new AbortController;
89
+ const watchdogMs = resolveCodexWatchdogMs(opts.maxTokens, opts.codexWatchdogMs);
90
+ const abort = Promise.withResolvers();
91
+ const abortFromCaller = () => {
92
+ controller.abort(opts.signal?.reason);
93
+ abort.reject(new Error("LLM request aborted by caller"));
94
+ };
95
+ opts.signal?.addEventListener("abort", abortFromCaller, { once: true });
96
+ const timeout = Promise.withResolvers();
97
+ const timer = setTimeout(() => {
98
+ controller.abort("provider-watchdog");
99
+ timeout.reject(new Error("Provider watchdog stopped generation after " + watchdogMs + "ms"));
100
+ }, watchdogMs);
101
+ if (typeof timer === "object" && "unref" in timer)
102
+ timer.unref();
103
+ try {
104
+ return await Promise.race([
105
+ invoke({ ...opts, signal: controller.signal }),
106
+ abort.promise,
107
+ timeout.promise
108
+ ]);
109
+ } finally {
110
+ clearTimeout(timer);
111
+ opts.signal?.removeEventListener("abort", abortFromCaller);
112
+ }
113
+ }
85
114
  async function completeChatGptCodex(model, body, opts) {
86
115
  const controller = new AbortController;
87
116
  const watchdogMs = resolveCodexWatchdogMs(opts.maxTokens, opts.codexWatchdogMs);
@@ -125,10 +154,12 @@ async function completeChatGptCodex(model, body, opts) {
125
154
  var rawLlmClient = {
126
155
  complete: async (model, body, originalOpts) => {
127
156
  const opts = withCodexWireLimit(model, originalOpts);
128
- if (isChatGptCodex(model))
129
- return completeChatGptCodex(model, body, opts);
130
- const response = opts.reasoning === undefined ? await (await resolveComplete())(model, body, opts) : await (await resolveCompleteSimple())(model, body, opts);
131
- return assertSuccessful(response);
157
+ return withProviderDeadline(opts, async (bounded) => {
158
+ if (isChatGptCodex(model))
159
+ return completeChatGptCodex(model, body, bounded);
160
+ const response = bounded.reasoning === undefined ? await (await resolveComplete())(model, body, bounded) : await (await resolveCompleteSimple())(model, body, bounded);
161
+ return assertSuccessful(response);
162
+ });
132
163
  }
133
164
  };
134
165
  var defaultLlmClient = rawLlmClient;
@@ -138,7 +169,7 @@ function getLlmClient() {
138
169
  }
139
170
 
140
171
  // src/constants.ts
141
- var VERSION = "8.0.8";
172
+ var VERSION = "9.1.0";
142
173
  var COMPACT_SYSTEM_PREFIX = "You are an expert conversation summarizer for a coding agent. " + "Produce structured markdown summaries. " + "Follow output format exactly. " + "Use EXACT names \u2014 never paraphrase code identifiers. " + "Trust deterministic extraction data over intuition.";
143
174
  var PROFILES = {
144
175
  light: {
@@ -180,7 +211,7 @@ var DEFAULT_CONFIG = {
180
211
  backupEnabled: true,
181
212
  backupDir: "",
182
213
  minContextPercent: 60,
183
- requireApproval: false,
214
+ requireApproval: true,
184
215
  scrubSecrets: true,
185
216
  scrubPii: false,
186
217
  maxLlmCalls: 8,
@@ -220,6 +251,8 @@ Output EXACTLY this format:
220
251
  - **[Decision]**: [Rationale]
221
252
  ` + `## Files Modified
222
253
  - [Verified list from deterministic extraction]
254
+ ` + `## Files Deleted
255
+ - [Verified deleted paths from deterministic extraction]
223
256
  ` + `## Files Read
224
257
  - [Verified list from deterministic extraction]
225
258
  ` + `## Next Steps
@@ -243,6 +276,7 @@ Rules for Accuracy:
243
276
  ` + `**Summary**: [2-4 sentences: what happened, errors, code changes with paths]
244
277
  ` + `**Decisions**: [comma-separated, or "None"]
245
278
  ` + `**Modified**: [comma-separated paths, or "None"]
279
+ ` + `**Deleted**: [comma-separated paths, or "None"]
246
280
  ` + `**Read**: [comma-separated paths, or "None"]
247
281
  `;
248
282
  var ASSEMBLY_PROMPT_PREFIX = `Merge these topic summaries into ONE coherent summary.
@@ -274,6 +308,8 @@ var ASSEMBLY_PROMPT_PREFIX = `Merge these topic summaries into ONE coherent summ
274
308
  - **[Decision]**: [Rationale]
275
309
  ` + `## Files Modified
276
310
  - [Verified deterministic list]
311
+ ` + `## Files Deleted
312
+ - [Verified deterministic deleted paths]
277
313
  ` + `## Files Read
278
314
  - [Verified deterministic list]
279
315
  ` + `## Next Steps
@@ -289,6 +325,14 @@ var FIVE_MINUTES_MS = 5 * 60 * 1000;
289
325
  var ONE_HOUR_MS = 60 * 60 * 1000;
290
326
  var SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
291
327
  var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
328
+ var ID_PREFIX = {
329
+ PROJECT: "proj-",
330
+ COMPACT_SESSION: "sc-",
331
+ MULTI_TOOL_USE_SYNTHETIC: "mtu_",
332
+ OPEN_LOOP: "loop-",
333
+ DECISION: "decision-",
334
+ ERROR: "error-"
335
+ };
292
336
  var TUNING = {
293
337
  EMA_PREV: 0.7,
294
338
  EMA_SAMPLE: 0.3,
@@ -328,6 +372,7 @@ var TRUNC = {
328
372
  BACKUP_PREVIEW_LINES: 5,
329
373
  FINGERPRINT_SEG: 2
330
374
  };
375
+ var LIKELY_ERROR_RE = /(?:command not found|no such file|permission denied|syntax error|cannot find|module not found|compilation error|build failed|test failed|^FAIL\b|ERROR:)/i;
331
376
  var METRICS_BUFFER_MAX = 200;
332
377
  var RUNTIME_LOG_MAX_BYTES = 5 * 1024 * 1024;
333
378
  var EXPLORER_SYSTEM_PROMPT = `You are a conversation analyst. You have deterministic extraction data and can query the raw conversation using tools.
@@ -406,6 +451,15 @@ function calibrationKey(provider, model) {
406
451
  }
407
452
 
408
453
  // src/utils/type-guards.ts
454
+ function isRecord(value) {
455
+ return typeof value === "object" && value !== null;
456
+ }
457
+ function isTextBlock(c) {
458
+ return isRecord(c) && c.type === "text" && typeof c.text === "string";
459
+ }
460
+ function isToolCallBlock(c) {
461
+ return isRecord(c) && c.type === "toolCall" && typeof c.name === "string" && isRecord(c.arguments);
462
+ }
409
463
  var KNOWN_METHODS = new Set(["eesv", "single-pass", "heuristic"]);
410
464
  var KNOWN_PROFILES = new Set(["light", "balanced", "aggressive"]);
411
465
  var KNOWN_MODES = new Set(["balanced", "aggressive", "fast", "thorough"]);
@@ -464,6 +518,74 @@ function isKnownPathReference(ref, knownPaths) {
464
518
  });
465
519
  }
466
520
 
521
+ // src/domain/tool-semantics.ts
522
+ var PATH_KEYS = [
523
+ "path",
524
+ "file_path",
525
+ "filePath",
526
+ "filename",
527
+ "file",
528
+ "target_file",
529
+ "file_uri",
530
+ "absolute_path"
531
+ ];
532
+ var PAYLOAD_KEYS = [
533
+ "content",
534
+ "newText",
535
+ "oldText",
536
+ "new_str",
537
+ "old_str",
538
+ "new_string",
539
+ "old_string",
540
+ "edits",
541
+ "patch",
542
+ "replacement"
543
+ ];
544
+ var COMMAND_KEYS = ["command", "cmd", "script"];
545
+ function hasPresent(args, keys) {
546
+ return keys.some((k) => args[k] != null);
547
+ }
548
+ function extractToolPath(args) {
549
+ if (!args || typeof args !== "object")
550
+ return;
551
+ const a = args;
552
+ for (const k of PATH_KEYS) {
553
+ const v = a[k];
554
+ if (typeof v === "string" && v.length > 0)
555
+ return v;
556
+ }
557
+ return;
558
+ }
559
+ function normalizeToolName(name) {
560
+ if (typeof name !== "string")
561
+ return "";
562
+ return name.replace(/^functions[.:/_-]+/i, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
563
+ }
564
+ function nameHas(name, hints) {
565
+ const words = name.split("_");
566
+ return hints.some((hint) => words.includes(hint));
567
+ }
568
+ function classifyToolOperation(args, toolName) {
569
+ const a = args && typeof args === "object" ? args : {};
570
+ const name = normalizeToolName(toolName);
571
+ const hasPath = extractToolPath(a) !== undefined;
572
+ if (hasPath && hasPresent(a, PAYLOAD_KEYS))
573
+ return "mutate";
574
+ if (hasPresent(a, COMMAND_KEYS))
575
+ return "execute";
576
+ if (hasPath && hasPresent(a, ["text"]) && nameHas(name, ["write", "edit", "patch", "replace", "append", "create", "update", "insert"]))
577
+ return "mutate";
578
+ if (hasPath && nameHas(name, ["delete", "remove", "unlink"]))
579
+ return "delete";
580
+ if (hasPresent(a, ["pattern", "query", "glob"]) || nameHas(name, ["grep", "search", "find", "glob", "rg"]))
581
+ return "search";
582
+ if (nameHas(name, ["list", "ls", "tree"]))
583
+ return "list";
584
+ if (hasPath || nameHas(name, ["read"]))
585
+ return "read";
586
+ return "unknown";
587
+ }
588
+
467
589
  // src/utils/file-ref-detect.ts
468
590
  var CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs|rs|py|go|java|rb|cs|cpp|c|h|hpp|swift|kt|scala|php|css|scss|html|json|yaml|yml|toml|md|mdx|sh|sql|tf|ini|env|lock|gradle|xml)$/i;
469
591
  var VERSION_RE = /^v?\d+(?:\.\d+)+(?:[-+][\w.-]+)?$/i;
@@ -499,6 +621,8 @@ function classifyHeading(raw) {
499
621
  return "files-modified";
500
622
  if (text.includes("file") && (text.includes("read") || text.includes("viewed")))
501
623
  return "files-read";
624
+ if (text.includes("file") && (text.includes("delet") || text.includes("remov")))
625
+ return "files-deleted";
502
626
  if (text.includes("next step") || text === "next actions")
503
627
  return "next-steps";
504
628
  if (text.includes("critical context") || text === "important context")
@@ -532,6 +656,7 @@ function parseSummary(markdown) {
532
656
  let currentHeading = "";
533
657
  let currentKind = "unknown";
534
658
  let bodyLines = [];
659
+ let fence = null;
535
660
  let started = false;
536
661
  const flush = () => {
537
662
  if (!started)
@@ -545,16 +670,31 @@ function parseSummary(markdown) {
545
670
  sections.push({ kind: currentKind, heading: currentHeading.trim(), body });
546
671
  };
547
672
  for (const line of lines) {
548
- const m = line.match(HEADING_RE);
549
- if (m) {
550
- const kind = classifyHeading(m[2]);
551
- if (m[1].length <= 2 || kind !== "unknown") {
552
- flush();
553
- currentHeading = "## " + m[2].trim();
554
- currentKind = kind;
555
- bodyLines = [];
556
- started = true;
557
- continue;
673
+ const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
674
+ if (fenceMatch) {
675
+ const marker = fenceMatch[1][0];
676
+ const markerLength = fenceMatch[1].length;
677
+ if (!fence) {
678
+ fence = { marker, length: markerLength };
679
+ } else if (marker === fence.marker && markerLength >= fence.length && !fenceMatch[2].trim()) {
680
+ fence = null;
681
+ }
682
+ if (started)
683
+ bodyLines.push(line);
684
+ continue;
685
+ }
686
+ if (!fence) {
687
+ const heading = line.match(HEADING_RE);
688
+ if (heading) {
689
+ const kind = classifyHeading(heading[2]);
690
+ if (heading[1].length <= 2 || kind !== "unknown") {
691
+ flush();
692
+ currentHeading = "## " + heading[2].trim();
693
+ currentKind = kind;
694
+ bodyLines = [];
695
+ started = true;
696
+ continue;
697
+ }
558
698
  }
559
699
  }
560
700
  if (started)
@@ -569,12 +709,68 @@ function findSection(summary, kind) {
569
709
  }
570
710
 
571
711
  // src/utils/extraction.ts
712
+ function nestedToolCallId(wrapperId, messageIndex, toolIndex, nestedId) {
713
+ return typeof nestedId === "string" ? nestedId : wrapperId ? wrapperId + "_" + toolIndex : ID_PREFIX.MULTI_TOOL_USE_SYNTHETIC + messageIndex + "_" + toolIndex;
714
+ }
715
+ function flattenToolCallBlock(b) {
716
+ if (!isToolCallBlock(b))
717
+ return [];
718
+ if (b.name === "multi_tool_use.parallel" && Array.isArray(b.arguments?.tool_uses)) {
719
+ return b.arguments.tool_uses.map((u) => {
720
+ const recipient = u?.recipient_name ?? "";
721
+ return {
722
+ name: recipient.replace(/^functions\./, ""),
723
+ id: u?.id ?? undefined,
724
+ arguments: u?.parameters ?? {}
725
+ };
726
+ });
727
+ }
728
+ return [{ name: b.name, id: b.id, arguments: b.arguments }];
729
+ }
730
+ function extractText(content) {
731
+ if (typeof content === "string")
732
+ return content;
733
+ if (!Array.isArray(content))
734
+ return "";
735
+ return content.map((b) => {
736
+ if (typeof b === "string")
737
+ return b;
738
+ if (isTextBlock(b))
739
+ return b.text;
740
+ return "";
741
+ }).join("");
742
+ }
743
+ function buildToolCallIndex(msgs) {
744
+ const idx = new Map;
745
+ for (let i = 0;i < msgs.length; i++) {
746
+ const m = msgs[i];
747
+ if (m.role !== "assistant")
748
+ continue;
749
+ const blocks = Array.isArray(m.content) ? m.content : [];
750
+ for (const b of blocks) {
751
+ if (!isToolCallBlock(b))
752
+ continue;
753
+ if (b.id) {
754
+ idx.set(b.id, { name: b.name, arguments: b.arguments, msgIndex: i });
755
+ }
756
+ if (b.name === "multi_tool_use.parallel" && Array.isArray(b.arguments?.tool_uses)) {
757
+ const nested = flattenToolCallBlock(b);
758
+ for (let t = 0;t < nested.length; t++) {
759
+ const tool = nested[t];
760
+ const id = nestedToolCallId(b.id, i, t, tool.id);
761
+ idx.set(id, { name: tool.name, arguments: tool.arguments, msgIndex: i });
762
+ }
763
+ }
764
+ }
765
+ }
766
+ return idx;
767
+ }
572
768
  var CONSTRAINT_PATTERNS = [
573
769
  { re: /\b(?:must|need|require|has to|important)\b.*\b(?:be|use|have|include|support)\b/i, cat: "requirement", conf: TUNING.CONFIDENCE_HIGH },
574
770
  { re: /\b(?:don't|never|avoid|shouldn't|must not|do not|no\s+(?:need|want))\b/i, cat: "prohibition", conf: TUNING.CONFIDENCE_MEDIUM },
575
771
  { re: /\b(?:prefer|like|want|would rather|should)\b.*\b(?:use|be|have|with)\b/i, cat: "preference", conf: TUNING.CONFIDENCE_LOW },
576
- { re: /(?<![A-Za-z0-9_])(?:kritik|kritikal|\u00F6nemli|onemli|\u015Fart|sart|zorunlu|\u015Fart ko\u015Ful|\u00F6nemli \u015Fart|kesinlikle|kesinlikle \u015Fart|asla|sak\u0131n|sak\u0131nha|bunu yapma|b\u00F6yle olsun|b\u00F6yle yap\u0131n|\u015F\u00F6yle olsun|\u015F\u00F6yle yap\u0131n)(?![A-Za-z0-9_])/iu, cat: "requirement", conf: TUNING.CONFIDENCE_MEDIUM },
577
- { re: /(?<![A-Za-z0-9_])(?:yapma|kullanma|sak\u0131n|asla\s+(?:kullanma|yapma|getirme))(?![A-Za-z0-9_])/iu, cat: "prohibition", conf: TUNING.CONFIDENCE_MEDIUM },
772
+ { re: /(?<![A-Za-z0-9_])(?:yapma|kullanma|sak\u0131n|sak\u0131nha|asla(?:\s+(?:kullanma|yapma|getirme))?|bunu yapma)(?![A-Za-z0-9_])/iu, cat: "prohibition", conf: TUNING.CONFIDENCE_MEDIUM },
773
+ { re: /(?<![A-Za-z0-9_])(?:kritik|kritikal|\u00F6nemli|onemli|\u015Fart|sart|zorunlu|\u015Fart ko\u015Ful|\u00F6nemli \u015Fart|kesinlikle|kesinlikle \u015Fart|b\u00F6yle olsun|b\u00F6yle yap\u0131n|\u015F\u00F6yle olsun|\u015F\u00F6yle yap\u0131n)(?![A-Za-z0-9_])/iu, cat: "requirement", conf: TUNING.CONFIDENCE_MEDIUM },
578
774
  { re: /(?<![A-Za-z0-9_])(?:tercih|isterim|olsun|kullanal\u0131m|yapal\u0131m|istiyorum)(?![A-Za-z0-9_])/iu, cat: "preference", conf: TUNING.CONFIDENCE_LOW }
579
775
  ];
580
776
  function isDiagnosticConstraintText(text) {
@@ -641,7 +837,6 @@ function readJsonlTail(target, limit, maxBytes = 512 * 1024) {
641
837
  }
642
838
  return values.slice(-limit);
643
839
  }
644
- var scheduledTailTrims = new Set;
645
840
 
646
841
  // src/infra/clock.ts
647
842
  var systemClock = {
@@ -655,14 +850,23 @@ import crypto from "crypto";
655
850
  var SECRET_PATTERNS = [
656
851
  { kind: "private-key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
657
852
  { kind: "aws-access-key", regex: /\bAKIA[0-9A-Z]{16}\b/g },
658
- { kind: "github-token", regex: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
853
+ { kind: "google-api-key", regex: /\bAIza[0-9A-Za-z_-]{30,}\b/g },
854
+ { kind: "stripe-key", regex: /\b[rs]k_(?:live|test)_[0-9A-Za-z]{16,}\b/g },
855
+ { kind: "gitlab-token", regex: /\bglpat-[0-9A-Za-z_-]{20,}\b/g },
856
+ { kind: "npm-token", regex: /\bnpm_[0-9A-Za-z]{30,}\b/g },
857
+ { kind: "github-token", regex: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
659
858
  { kind: "api-key", regex: /\bsk-(?:ant-)?[A-Za-z0-9_-]{20,}\b/g },
660
- { kind: "slack-token", regex: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g },
859
+ { kind: "slack-token", regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
661
860
  { kind: "jwt", regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
662
- { kind: "bearer-token", regex: /\bBearer\s+[A-Za-z0-9._~+\/-]{24,}/gi, replacement: () => "Bearer [REDACTED:bearer-token]" },
861
+ { kind: "bearer-token", regex: /\bBearer\s+[A-Za-z0-9._~+\/-]{12,}=*/gi, replacement: () => "Bearer [REDACTED:bearer-token]" },
862
+ {
863
+ kind: "connection-password",
864
+ regex: /\b([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)[^@\s/]+(@)/gi,
865
+ replacement: (prefix, suffix) => prefix + "[REDACTED:password]" + suffix
866
+ },
663
867
  {
664
868
  kind: "credential",
665
- regex: /\b(api[_-]?key|access[_-]?token|auth[_-]?token|token|password|passwd|secret)\s*([:=])\s*["']?([A-Za-z0-9._~+\/-]{16,})["']?/gi,
869
+ regex: /\b((?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|password|passwd|secret(?:[_-]?(?:access)?[_-]?key)?|client[_-]?secret)(?:[_-][A-Za-z0-9]+)*)\s*([:=])\s*["']?([^\s"']{16,})["']?/gi,
666
870
  replacement: (name, separator) => name + separator + "[REDACTED:credential]"
667
871
  }
668
872
  ];
@@ -690,6 +894,42 @@ function mergeFindings(target, findings) {
690
894
  for (const finding of findings)
691
895
  target.set(finding.kind, (target.get(finding.kind) ?? 0) + finding.count);
692
896
  }
897
+ var SECRET_KEY_NAMES = {
898
+ api_key: true,
899
+ apikey: true,
900
+ access_token: true,
901
+ auth_token: true,
902
+ authorization: true,
903
+ password: true,
904
+ passwd: true,
905
+ secret: true,
906
+ secret_key: true,
907
+ secret_access_key: true,
908
+ client_secret: true,
909
+ private_key: true,
910
+ database_url: true,
911
+ connection_string: true,
912
+ token: true,
913
+ refresh_token: true,
914
+ session_token: true,
915
+ credential: true,
916
+ credentials: true,
917
+ cookie: true,
918
+ set_cookie: true,
919
+ otp: true,
920
+ one_time_password: true,
921
+ pin: true,
922
+ passcode: true
923
+ };
924
+ function normalizeObjectKey(key) {
925
+ return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
926
+ }
927
+ function isSecretBearingKey(key) {
928
+ const normalized = normalizeObjectKey(key);
929
+ if (SECRET_KEY_NAMES[normalized])
930
+ return true;
931
+ return /(?:^|_)(?:api_key|access_token|auth_token|password|passwd|secret_access_key|client_secret|private_key|refresh_token|session_token|one_time_password|passcode)(?:_|$)/.test(normalized);
932
+ }
693
933
 
694
934
  class SecretScrubber {
695
935
  secretsEnabled;
@@ -719,6 +959,10 @@ class SecretScrubber {
719
959
  scrubValue(input) {
720
960
  const findings = new Map;
721
961
  const seen = new WeakMap;
962
+ const recordCredential = () => {
963
+ findings.set("credential", (findings.get("credential") ?? 0) + 1);
964
+ this.total++;
965
+ };
722
966
  const visit = (value2) => {
723
967
  if (typeof value2 === "string") {
724
968
  const result = this.scrubText(value2);
@@ -739,8 +983,15 @@ class SecretScrubber {
739
983
  }
740
984
  const output = {};
741
985
  seen.set(value2, output);
742
- for (const [key, item] of Object.entries(value2))
743
- output[key] = visit(item);
986
+ for (const [key, item] of Object.entries(value2)) {
987
+ const carriesSecret = typeof item === "string" ? item.length > 0 : item != null;
988
+ if (this.secretsEnabled && isSecretBearingKey(key) && carriesSecret) {
989
+ output[key] = "[REDACTED:credential]";
990
+ recordCredential();
991
+ } else {
992
+ output[key] = visit(item);
993
+ }
994
+ }
744
995
  return output;
745
996
  };
746
997
  const value = visit(input);
@@ -999,8 +1250,8 @@ function readMetricsLog(limit = 100) {
999
1250
  const fd = fs2.openSync(logPath, "r");
1000
1251
  try {
1001
1252
  const buf = Buffer.alloc(wantBytes);
1002
- fs2.readSync(fd, buf, 0, wantBytes, startPos);
1003
- let text = buf.toString("utf8");
1253
+ const bytesRead = fs2.readSync(fd, buf, 0, wantBytes, startPos);
1254
+ let text = buf.subarray(0, bytesRead).toString("utf8");
1004
1255
  if (startPos > 0) {
1005
1256
  const nl = text.indexOf(`
1006
1257
  `);
@@ -1028,6 +1279,79 @@ function readMetricsLog(limit = 100) {
1028
1279
  }
1029
1280
 
1030
1281
  // src/phases/verify.ts
1282
+ var HIGH_RISK_OUTCOME_RE = /(?:\ball\s+tests?\s+(?:pass|passed|passing)\b|\btests?\s+(?:pass|passed|passing)\b|\b(?:build|deployment|migration)\s+(?:completed|succeeded|passed|successful)\b|\b(?:deployed|published|released)\b|\b(?:bug|issue|error)\s+(?:fixed|resolved)\b|\bno\s+(?:errors?|failures?)\b|\bcompleted successfully\b|\btestler?\s+(?:ge\u00E7ti|ba\u015Far\u0131l\u0131)\b|\bba\u015Far\u0131yla\s+(?:tamamland\u0131|da\u011F\u0131t\u0131ld\u0131|yay\u0131nland\u0131)\b|\b(?:deploy edildi|yay\u0131nland\u0131|hata yok)\b)/iu;
1283
+ var NEGATED_OUTCOME_RE = /\b(?:not|never|pending|failed|failing|unresolved|hen\u00FCz|de\u011Fil|ba\u015Far\u0131s\u0131z)\b/iu;
1284
+ function outcomeClaims(summary) {
1285
+ return Array.from(new Set(summary.split(/\r?\n/).map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").replace(/^\[[ x]\]\s+/i, "").trim()).filter((line) => line.length > 0 && !line.startsWith("#")).filter((line) => HIGH_RISK_OUTCOME_RE.test(line)).filter((line) => /\bno\s+(?:errors?|failures?)\b/i.test(line) || !NEGATED_OUTCOME_RE.test(line)))).slice(0, 12);
1286
+ }
1287
+ function classifyOutcomeClaim(claim) {
1288
+ const lower = claim.toLowerCase();
1289
+ if (/\btests?\b|\btestler?\b/.test(lower))
1290
+ return "test";
1291
+ if (/\bbuild\b|\bcompil(?:e|ed|ation)\b|\btypecheck\b/.test(lower))
1292
+ return "build";
1293
+ if (/\bdeploy(?:ed|ment)?\b|\bpublish(?:ed)?\b|\breleas(?:e|ed)\b/.test(lower))
1294
+ return "release";
1295
+ if (/\bbug\b|\bissue\b|\berror\b|\bfail(?:ed|ure)?\b|\bhata\b/.test(lower))
1296
+ return "error";
1297
+ if (/\bfile\b|\bdosya\b/.test(lower))
1298
+ return "file";
1299
+ return "generic";
1300
+ }
1301
+ var successfulToolEvidenceCache = new WeakMap;
1302
+ function successfulToolEvidence(messages) {
1303
+ const cached = successfulToolEvidenceCache.get(messages);
1304
+ if (cached)
1305
+ return cached;
1306
+ const toolCalls = buildToolCallIndex(messages);
1307
+ const evidence = [];
1308
+ for (const message of messages) {
1309
+ if (message.role !== "toolResult" || message.isError)
1310
+ continue;
1311
+ const call = toolCalls.get(message.toolCallId ?? "");
1312
+ if (!call)
1313
+ continue;
1314
+ const result = extractText(message.content).slice(0, 8000);
1315
+ if (!result.trim() || LIKELY_ERROR_RE.test(result))
1316
+ continue;
1317
+ const command = [call.arguments.command, call.arguments.cmd, call.arguments.script].find((value) => typeof value === "string") ?? "";
1318
+ evidence.push({
1319
+ name: normalizeToolName(call.name),
1320
+ operation: classifyToolOperation(call.arguments, call.name),
1321
+ command,
1322
+ path: extractToolPath(call.arguments),
1323
+ result
1324
+ });
1325
+ }
1326
+ successfulToolEvidenceCache.set(messages, evidence);
1327
+ return evidence;
1328
+ }
1329
+ function successfulToolSupportsClaim(claim, tools, extraction) {
1330
+ const shape = semanticShape(claim);
1331
+ const category = classifyOutcomeClaim(claim);
1332
+ if (category === "error" && extraction.errors.some((error) => error.resolved && hasSemanticEvidence(claim, error.message)))
1333
+ return true;
1334
+ if (category === "file" && extraction.modifiedFiles.some((file) => claim.toLowerCase().includes(file.path.toLowerCase())))
1335
+ return true;
1336
+ for (const tool of tools) {
1337
+ const operationText = tool.name + " " + tool.command;
1338
+ const operationSupports = category === "test" ? /\b(?:test|tests|pytest|jest|vitest|mocha|rspec)\b/i.test(operationText) : category === "build" ? /\b(?:build|compile|typecheck|tsc|check)\b/i.test(operationText) : category === "release" ? /\b(?:deploy|publish|release)\b/i.test(operationText) : category === "file" ? tool.operation === "mutate" || tool.operation === "delete" : category === "error" ? tool.operation === "execute" || tool.operation === "mutate" || tool.operation === "delete" : tool.operation !== "read" && tool.operation !== "search" && tool.operation !== "list";
1339
+ if (!operationSupports)
1340
+ continue;
1341
+ if (hasSemanticEvidence(claim, tool.result))
1342
+ return true;
1343
+ const lower = tool.result.toLowerCase();
1344
+ if (category === "test" && /\b\d+\s+(?:tests?\s+)?pass(?:ed)?\b/.test(lower) && !/\b(?:fail(?:ed|ures?)?|errors?)\s*[:=]?\s*[1-9]\d*\b/.test(lower))
1345
+ return true;
1346
+ if (category === "build" && /\b(?:succeeded|successful|passed|exit(?:ed)?\s+(?:code\s+)?0)\b/.test(lower))
1347
+ return true;
1348
+ if (category === "release" && /\b(?:succeeded|successful|completed|published|deployed|released)\b/.test(lower))
1349
+ return true;
1350
+ if (category === "error" && shape.concepts.length > 0 && /\b(?:fixed|resolved|passed|succeeded|successful)\b/.test(lower) && hasSemanticEvidence(claim, tool.result))
1351
+ return true;
1352
+ }
1353
+ return false;
1354
+ }
1031
1355
  var NEGATION_MARKERS = new Set([
1032
1356
  "no",
1033
1357
  "not",
@@ -1108,9 +1432,15 @@ function stemToken(token) {
1108
1432
  function semanticTokens(text) {
1109
1433
  return (text.normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) ?? []).map(stemToken).filter((token) => token.length > 2);
1110
1434
  }
1111
- function evidenceFragments(text) {
1112
- const fragments = text.split(/\r?\n/).flatMap((line) => [line, ...line.split(/[.;]/)]).map((part) => part.replace(/^\s*[-*\d.)]+\s*/, "").trim()).filter(Boolean);
1113
- return Array.from(new Set(fragments));
1435
+ var semanticShapeCache = new Map;
1436
+ var semanticFragmentCache = new Map;
1437
+ function semanticFragments(text) {
1438
+ const cached = lruGet(semanticFragmentCache, text);
1439
+ if (cached)
1440
+ return cached;
1441
+ const fragments = Array.from(new Set(text.split(/\r?\n/).flatMap((line) => [line, ...line.split(/[.;]/)]).map((part) => part.replace(/^\s*[-*\d.)]+\s*/, "").trim()).filter(Boolean))).map(semanticTokens);
1442
+ lruSet(semanticFragmentCache, text, fragments, 256);
1443
+ return fragments;
1114
1444
  }
1115
1445
  function hasNearbyMarker(tokens, anchor, markers) {
1116
1446
  return tokens.some((token, index) => token === anchor && tokens.slice(Math.max(0, index - 2), index + 3).some((near) => markers.has(near)));
@@ -1134,20 +1464,24 @@ function hasEffectiveTargetNegation(tokens, anchor) {
1134
1464
  });
1135
1465
  }
1136
1466
  function semanticShape(source) {
1467
+ const cached = lruGet(semanticShapeCache, source);
1468
+ if (cached)
1469
+ return cached;
1137
1470
  const sourceTokens = semanticTokens(source);
1138
1471
  const concepts = Array.from(new Set(sourceTokens.filter((token) => !/^\d+$/.test(token) && !SEMANTIC_STOP.has(token) && !NEGATION_MARKERS.has(token) && !CONDITION_MARKERS.has(token))));
1139
1472
  const negative = sourceTokens.some((token) => NEGATION_MARKERS.has(token));
1140
1473
  const conditional = sourceTokens.some((token) => CONDITION_MARKERS.has(token));
1141
1474
  const anchor = concepts.find((concept) => hasNearbyMarker(sourceTokens, concept, NEGATION_MARKERS)) ?? concepts[0] ?? "";
1142
- return { sourceTokens, concepts, anchor, negative, conditional };
1475
+ const shape = { sourceTokens, concepts, anchor, negative, conditional };
1476
+ lruSet(semanticShapeCache, source, shape, 512);
1477
+ return shape;
1143
1478
  }
1144
1479
  function hasSemanticEvidence(source, target) {
1145
1480
  const { sourceTokens, concepts, anchor, negative, conditional } = semanticShape(source);
1146
1481
  if (!concepts.length)
1147
1482
  return true;
1148
1483
  const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
1149
- return evidenceFragments(target).some((fragment) => {
1150
- const tokens = semanticTokens(fragment);
1484
+ return semanticFragments(target).some((tokens) => {
1151
1485
  const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
1152
1486
  if (overlap < required)
1153
1487
  return false;
@@ -1169,8 +1503,7 @@ function hasSemanticContradiction(source, target) {
1169
1503
  if (!anchor)
1170
1504
  return false;
1171
1505
  const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
1172
- return evidenceFragments(target).some((fragment) => {
1173
- const tokens = semanticTokens(fragment);
1506
+ return semanticFragments(target).some((tokens) => {
1174
1507
  if (!tokens.includes(anchor))
1175
1508
  return false;
1176
1509
  const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
@@ -1186,7 +1519,7 @@ function hasSemanticContradiction(source, target) {
1186
1519
  return conditional && !negative && !tokens.some((token) => CONDITION_MARKERS.has(token));
1187
1520
  });
1188
1521
  }
1189
- function verifySummary(summary, extraction, continuity = null) {
1522
+ function verifySummary(summary, extraction, continuity = null, evidence = {}) {
1190
1523
  const parsed = parseSummary(summary);
1191
1524
  const gaps = [];
1192
1525
  const lower = summary.toLowerCase().replace(/\\/g, "/");
@@ -1206,9 +1539,18 @@ function verifySummary(summary, extraction, continuity = null) {
1206
1539
  ...extraction.errors.filter((error) => !error.resolved).map((error) => ({ message: error.message })),
1207
1540
  ...(continuity?.unresolvedErrors ?? []).map((error) => ({ message: error.message }))
1208
1541
  ], (item) => item.message);
1542
+ const resolvedEvidence = uniqueByText([
1543
+ ...extraction.errors.filter((error) => error.resolved).map((error) => ({ message: error.message })),
1544
+ ...(continuity?.resolvedErrors ?? []).map((error) => ({ message: error.message }))
1545
+ ], (item) => item.message).slice(-5);
1546
+ const steeringConstraints = [
1547
+ evidence.steering?.focus ? { text: "Preserve detail about: " + evidence.steering.focus } : null,
1548
+ evidence.steering?.note ? { text: evidence.steering.note } : null
1549
+ ].filter((item) => Boolean(item?.text.trim()));
1209
1550
  const constraintEvidence = uniqueByText([
1210
1551
  ...extraction.constraints.filter((item) => item.confidence >= 0.8).map((item) => ({ text: item.text })),
1211
- ...(continuity?.constraints ?? []).filter((item) => item.confidence >= 0.8).map((item) => ({ text: item.text }))
1552
+ ...(continuity?.constraints ?? []).filter((item) => item.confidence >= 0.8).map((item) => ({ text: item.text })),
1553
+ ...steeringConstraints
1212
1554
  ], (item) => item.text).filter((item) => !isDiagnosticConstraintText(item.text));
1213
1555
  const decisionEvidence = uniqueByText([
1214
1556
  ...extraction.decisions.filter((item) => item.type === "explicit").map((item) => ({ summary: item.summary })),
@@ -1226,16 +1568,29 @@ function verifySummary(summary, extraction, continuity = null) {
1226
1568
  score -= req.penalty;
1227
1569
  }
1228
1570
  }
1571
+ const listedPaths = (kind) => new Set((findSection(parsed, kind)?.body ?? "").split(`
1572
+ `).map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").replace(/^\[[ x]\]\s+/i, "").trim()).map((line) => line.startsWith("`") && line.endsWith("`") ? line.slice(1, -1) : line).filter((line) => line.length > 0 && !/^none(?: recorded)?[.!]?$/i.test(line)).map(normalizePath));
1229
1573
  const modifiedPaths = extraction.modifiedFiles.map((file) => file.path);
1230
- const listedModifiedPaths = new Set((findSection(parsed, "files-modified")?.body ?? "").split(`
1231
- `).map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").replace(/^\[[ x]\]\s+/i, "").trim()).map((line) => line.startsWith("`") && line.endsWith("`") ? line.slice(1, -1) : line).filter(Boolean).map(normalizePath));
1232
- for (const file of extraction.modifiedFiles) {
1233
- const needles = buildUniquePathNeedles(file.path, modifiedPaths);
1234
- if (!listedModifiedPaths.has(normalizePath(file.path)) && !needles.some((needle) => lower.includes(needle))) {
1235
- gaps.push({ kind: "missing-file", path: file.path });
1236
- score -= 5;
1237
- }
1574
+ const modifiedListed = listedPaths("files-modified");
1575
+ const readListed = listedPaths("files-read");
1576
+ const deletedListed = listedPaths("files-deleted");
1577
+ for (const file of modifiedPaths) {
1578
+ if (!modifiedListed.has(normalizePath(file)))
1579
+ gaps.push({ kind: "missing-file", path: file });
1580
+ }
1581
+ for (const file of extraction.readFiles) {
1582
+ if (!readListed.has(normalizePath(file)))
1583
+ gaps.push({ kind: "missing-read-file", path: file });
1584
+ }
1585
+ const deletedEvidence = Array.from(new Set([
1586
+ ...extraction.deletedFiles,
1587
+ ...continuity?.deletedFiles ?? []
1588
+ ]));
1589
+ for (const file of deletedEvidence) {
1590
+ if (!deletedListed.has(normalizePath(file)))
1591
+ gaps.push({ kind: "missing-deleted-file", path: file });
1238
1592
  }
1593
+ score -= gaps.filter((gap) => gap.kind === "missing-file" || gap.kind === "missing-read-file" || gap.kind === "missing-deleted-file").length * 5;
1239
1594
  for (const error of unresolvedEvidence) {
1240
1595
  const snippet = summaryEvidenceLine(error.message, TRUNC.ERROR_SNIPPET).toLowerCase();
1241
1596
  if (snippet.length > 5 && !normalizedSummary.includes(snippet)) {
@@ -1243,6 +1598,13 @@ function verifySummary(summary, extraction, continuity = null) {
1243
1598
  score -= 5;
1244
1599
  }
1245
1600
  }
1601
+ for (const error of resolvedEvidence) {
1602
+ const snippet = summaryEvidenceLine(error.message, TRUNC.ERROR_SNIPPET).toLowerCase();
1603
+ if (snippet.length > 5 && !normalizedSummary.includes(snippet)) {
1604
+ gaps.push({ kind: "missing-error", message: error.message, resolved: true });
1605
+ score -= 2;
1606
+ }
1607
+ }
1246
1608
  const constraintTarget = [
1247
1609
  findSection(parsed, "constraints")?.body ?? "",
1248
1610
  findSection(parsed, "critical-context")?.body ?? ""
@@ -1303,16 +1665,18 @@ function verifySummary(summary, extraction, continuity = null) {
1303
1665
  gaps.push({ kind: "inconsistency", detail: "blocked-none: Blocked says none despite unresolved errors" });
1304
1666
  score -= 12;
1305
1667
  }
1668
+ const doneRefs = new Set(extractFileRefs(doneSection).map(normalizePath));
1306
1669
  for (const file of extraction.modifiedFiles) {
1307
- const basename = file.path.split("/").pop() ?? "";
1308
- if (!doneSection.toLowerCase().includes(basename.toLowerCase()))
1670
+ const uniqueNeedles = buildUniquePathNeedles(file.path, modifiedPaths);
1671
+ if (!uniqueNeedles.some((needle) => doneRefs.has(normalizePath(needle))))
1309
1672
  continue;
1310
1673
  const unresolved = unresolvedEvidence.find((error) => {
1311
1674
  const firstLine = error.message.split(/\r?\n/, 1)[0] ?? "";
1312
- return extractFileRefs(firstLine).some((ref) => isKnownPathReference(ref, [file.path]));
1675
+ const errorRefs = extractFileRefs(firstLine).map(normalizePath);
1676
+ return uniqueNeedles.some((needle) => errorRefs.includes(normalizePath(needle)));
1313
1677
  });
1314
1678
  if (unresolved) {
1315
- gaps.push({ kind: "inconsistency", detail: basename + " marked Done but has unresolved error" });
1679
+ gaps.push({ kind: "inconsistency", detail: file.path + " marked Done but has unresolved error" });
1316
1680
  score -= 5;
1317
1681
  }
1318
1682
  }
@@ -1333,6 +1697,15 @@ function verifySummary(summary, extraction, continuity = null) {
1333
1697
  gaps.push({ kind: "missing-open-loops", unresolvedCount });
1334
1698
  score -= 5;
1335
1699
  }
1700
+ if (evidence.sourceMessages) {
1701
+ const tools = successfulToolEvidence(evidence.sourceMessages);
1702
+ for (const claim of outcomeClaims(summary)) {
1703
+ if (!successfulToolSupportsClaim(claim, tools, extraction)) {
1704
+ gaps.push({ kind: "unsupported-claim", claim });
1705
+ score -= 20;
1706
+ }
1707
+ }
1708
+ }
1336
1709
  const finalScore = Math.max(0, score);
1337
1710
  return { ok: gaps.length === 0 && finalScore >= 85, gaps, score: finalScore };
1338
1711
  }