docgrity 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -17,13 +17,20 @@ Part of the Docgrity family:
17
17
  ```yaml
18
18
  - uses: ujjavala/docgrity-vscode/action@main
19
19
  env:
20
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # for issue creation
21
21
  with:
22
- provider: github-models # free uses GITHUB_TOKEN, no API key
22
+ provider: anthropic # or openai / gemini / ollama
23
+ api_key: ${{ secrets.DOCGRITY_API_KEY }}
23
24
  create_issues: 'true' # opt-in; default false
24
25
  max_new_issues: 5
25
26
  ```
26
27
 
28
+ > **Note:** GitHub Models (`provider: github-models`) is being retired by GitHub —
29
+ > runs may fail with HTTP 410 (`github_models_retirement`). Use a BYO-key provider
30
+ > (`anthropic` / `openai` / `gemini`) or `ollama` instead. Copilot models are **not**
31
+ > available here: Copilot has no API outside the editor — only the VS Code extension
32
+ > can use it.
33
+
27
34
  Full example with weekly schedule, PR trigger and Pages report publishing:
28
35
  [examples/docgrity.yml](examples/docgrity.yml).
29
36
 
@@ -43,9 +50,29 @@ What it does per run:
43
50
 
44
51
  | provider | key | cost |
45
52
  |---|---|---|
46
- | `github-models` (default in CI) | none — uses `GITHUB_TOKEN` with `models: read` | free (being retired by GitHubprefer a BYO provider) |
47
- | `gemini` / `openai` / `anthropic` | `api_key` input (use a repo secret) | your key |
48
- | `ollama` | none — local or tunnelled endpoint | free, fully private |
53
+ | `anthropic` / `openai` / `gemini` | `api_key` input (use a repo secret)defaults: `claude-3-5-haiku-latest`, `gpt-4o-mini`, `gemini-flash` | your key |
54
+ | `ollama` (CLI default) | none local or tunnelled endpoint | free, fully private |
55
+ | `none` (no-agent mode) | none — zero LLM calls | free, instant, fully offline |
56
+ | `github-models` | none — uses `GITHUB_TOKEN` with `models: read` | **being retired by GitHub** (HTTP 410) — switch to a provider above |
57
+
58
+ ### No-agent mode (`provider: none`)
59
+
60
+ Pure algorithms, no model, no keys, no network:
61
+
62
+ | Check | How | Notes |
63
+ |---|---|---|
64
+ | **Duplicates** | verbatim shared-block detection + TF-IDF similarity | catches copy-paste duplication; paraphrased duplication needs AI |
65
+ | **Open questions** | explicit markers (`TODO`, `TBD`, `FIXME`, `???`, "open question"…) | deterministic — high confidence; subtle unanswered questions need AI |
66
+ | **Contradictions** | ❌ **requires AI intelligence** (semantic understanding) — skipped, and the report says so | never guessed at heuristically |
67
+
68
+ Confidence is *measured*, not guessed: duplicate confidence comes from the
69
+ actual verbatim-overlap ratio; marker-based open questions score 0.85–0.95.
70
+ Tuned for precision — paraphrased text, shared code samples and boilerplate
71
+ headings never fire. Evidence is verbatim by construction. Findings carry
72
+ `method: heuristic`, and issue drafts use a deterministic template.
73
+
74
+ Great as a zero-setup PR pre-check (heuristics on every PR, full AI scan weekly)
75
+ or when no key/model is available.
49
76
 
50
77
  ## Local CLI (read-only)
51
78
 
@@ -73,7 +100,8 @@ Usage: docgrity scan [options]
73
100
  --max-pairs <n> Max document pairs (default: 25)
74
101
  --threshold-duplicate / --threshold-contradiction / --threshold-open-question <0..1>
75
102
 
76
- --provider <p> ollama | gemini | openai | anthropic | github-models
103
+ --provider <p> none | ollama | gemini | openai | anthropic | github-models
104
+ (none = no-agent mode: algorithms only, contradictions skipped)
77
105
  --model <m> Model name
78
106
  --endpoint <url> Ollama endpoint (default http://localhost:11434)
79
107
 
@@ -82,11 +110,13 @@ Usage: docgrity scan [options]
82
110
  ```
83
111
 
84
112
  Provider auto-detection: `DOCGRITY_API_KEY` set → `gemini`; else `GITHUB_TOKEN` →
85
- `github-models`; else → `ollama` (local, fully private — nothing leaves your machine).
113
+ `github-models` (retiring — pass `--provider` explicitly); else → `ollama` (local,
114
+ fully private — nothing leaves your machine).
86
115
 
87
116
  Examples:
88
117
 
89
118
  ```bash
119
+ docgrity scan --provider none --open # no-agent: no model, no keys
90
120
  docgrity scan --checks contradictions # one check only
91
121
  docgrity scan --checks duplicates,open-questions --max-pairs 10
92
122
  docgrity scan --provider ollama --model llama3.1:8b # fully local
package/action.yml CHANGED
@@ -8,7 +8,10 @@ branding:
8
8
  color: 'blue'
9
9
  inputs:
10
10
  provider:
11
- description: 'LLM provider: github-models (default, uses GITHUB_TOKEN), gemini, openai, anthropic'
11
+ description: >-
12
+ LLM provider: anthropic, openai, gemini (api_key required), ollama, github-models
13
+ (retiring), or "none" for no-agent mode — heuristic duplicates + open questions
14
+ only; contradiction detection requires an AI model and is skipped.
12
15
  default: 'github-models'
13
16
  api_key:
14
17
  description: 'API key for gemini/openai/anthropic (pass a repo secret). Not needed for github-models.'
package/bin/action.js CHANGED
@@ -18,13 +18,19 @@ const slug = process.env.GITHUB_REPOSITORY ?? '';
18
18
  const branch = (process.env.GITHUB_REF_NAME ?? 'main').replace(/^refs\/heads\//, '');
19
19
 
20
20
  try {
21
- const client = makeClient({
22
- provider: input('provider', 'github-models'),
23
- apiKey: input('api_key'),
24
- githubToken: token,
25
- });
21
+ const provider = input('provider', 'github-models');
22
+ // provider: none → no-agent mode: heuristic duplicates + open questions,
23
+ // contradictions skipped (they need AI), template-drafted issues.
24
+ const client =
25
+ provider === 'none'
26
+ ? null
27
+ : makeClient({
28
+ provider,
29
+ apiKey: input('api_key'),
30
+ githubToken: token,
31
+ });
26
32
 
27
- console.log(`Docgrity scan on ${slug} (${branch})`);
33
+ console.log(`Docgrity scan on ${slug} (${branch})${provider === 'none' ? ' — no-agent (heuristic) mode' : ''}`);
28
34
  const { findings, stats } = await runScan(root, { client, log: (m) => console.log(m) });
29
35
 
30
36
  // Opt-in issue sync (deduped by fingerprint, capped, auto-close resolved).
package/bin/docgrity.js CHANGED
@@ -54,8 +54,12 @@ Options:
54
54
  --threshold-contradiction <0..1> Min confidence, contradictions (default: 0.7)
55
55
  --threshold-open-question <0..1> Min confidence, open questions (default: 0.6)
56
56
 
57
- --provider <p> ollama | gemini | openai | anthropic | github-models
57
+ --provider <p> none | ollama | gemini | openai | anthropic | github-models
58
58
  (default: auto — see below)
59
+ none = no-agent mode: pure algorithms, zero LLM calls.
60
+ Detects duplicates (verbatim shared blocks + TF-IDF)
61
+ and open questions (TODO/TBD/FIXME/???… markers).
62
+ Contradictions REQUIRE an AI model and are skipped.
59
63
  --model <m> Model name (provider-specific default otherwise)
60
64
  --endpoint <url> Ollama endpoint (default: http://localhost:11434,
61
65
  or DOCGRITY_OLLAMA_URL; supports tunnelled remotes)
@@ -74,6 +78,7 @@ Provider auto-detection (when --provider is omitted):
74
78
 
75
79
  Examples:
76
80
  docgrity scan --open
81
+ docgrity scan --provider none # no-agent: no model, no keys, instant
77
82
  docgrity scan --checks contradictions
78
83
  docgrity scan --checks duplicates,open-questions --max-pairs 10
79
84
  docgrity scan --provider ollama --model llama3.1:8b
@@ -148,16 +153,22 @@ const provider =
148
153
  const checks = parseChecks(args.checks);
149
154
 
150
155
  try {
151
- const client = makeClient({
152
- provider,
153
- apiKey: process.env.DOCGRITY_API_KEY,
154
- githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
155
- model: args.model,
156
- endpoint: args.endpoint,
157
- });
156
+ const client =
157
+ provider === 'none'
158
+ ? null
159
+ : makeClient({
160
+ provider,
161
+ apiKey: process.env.DOCGRITY_API_KEY,
162
+ githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
163
+ model: args.model,
164
+ endpoint: args.endpoint,
165
+ });
158
166
 
159
167
  const enabled = Object.entries(checks).filter(([, on]) => on).map(([k]) => k).join(', ');
160
- console.log(`Docgrity local scan (read-only) — provider: ${provider}; checks: ${enabled}`);
168
+ console.log(`Docgrity local scan (read-only) — provider: ${provider}${provider === 'none' ? ' (no-agent, heuristics only)' : ''}; checks: ${enabled}`);
169
+ if (provider === 'none' && checks.contradictions) {
170
+ console.log(' Note: contradiction detection needs AI intelligence — it will be skipped. Use an LLM provider to enable it.');
171
+ }
161
172
  const { findings, stats } = await runScan(root, {
162
173
  client,
163
174
  checks,
@@ -181,6 +192,9 @@ try {
181
192
  await writeFile(path.join(outDir, 'findings.json'), JSON.stringify({ findings, stats }, null, 2));
182
193
 
183
194
  console.log(`\n${findings.length} finding(s) across ${stats.docs} docs.`);
195
+ for (const f of findings) {
196
+ console.log(` [${f.severity}] ${f.type} — ${(f.confidence * 100).toFixed(0)}% confidence — ${f.files.join(' + ')}`);
197
+ }
184
198
  console.log(`Report: ${reportPath}`);
185
199
  console.log('Note: local scans are read-only — review owners and evidence in the report; issues are only raised by CI (opt-in).');
186
200
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docgrity",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Docgrity doc-integrity scans for CI and local use: contradictions, duplicates and open questions across repository markdown docs. Read-only CLI — generates an HTML report; never posts anything.",
5
5
  "type": "module",
6
6
  "bin": { "docgrity": "bin/docgrity.js" },
@@ -0,0 +1,146 @@
1
+ /**
2
+ * No-agent (heuristic) checks — pure algorithms, zero LLM calls.
3
+ *
4
+ * What works without a model:
5
+ * - duplicates: TF-IDF similarity + verbatim shared-block detection
6
+ * - open questions: explicit unresolved markers (TODO/TBD/FIXME/???/…)
7
+ * What does NOT work without a model:
8
+ * - contradictions: require semantic understanding — explicitly skipped,
9
+ * and the report says so, rather than emitting noisy guesses.
10
+ *
11
+ * Evidence is verbatim by construction (extracted from the source text), so
12
+ * the same hallucination guard in scan.js passes trivially. Confidence for
13
+ * duplicates is derived from measured overlap; for open questions the
14
+ * markers are deterministic, so confidence is fixed high per marker class.
15
+ */
16
+
17
+ const stripFences = (text) => text.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, ''));
18
+
19
+ const normLine = (s) => s.replace(/\s+/g, ' ').trim();
20
+
21
+ /** Lines substantive enough to count as shared content (not markdown noise). */
22
+ const substantive = (line) => {
23
+ const l = normLine(line);
24
+ if (l.length < 40) return false;
25
+ if (/^[#>\-*|=\s`~[\]().\d]+$/.test(l)) return false; // pure punctuation/structure
26
+ return true;
27
+ };
28
+
29
+ /**
30
+ * Find consecutive runs of lines from A that also appear (normalized) in B.
31
+ * Returns blocks sorted by length desc.
32
+ */
33
+ export function sharedBlocks(textA, textB) {
34
+ const linesA = stripFences(textA).split('\n');
35
+ const setB = new Set(stripFences(textB).split('\n').map(normLine).filter((l) => l.length >= 20));
36
+ const blocks = [];
37
+ let current = [];
38
+ for (const raw of linesA) {
39
+ const l = normLine(raw);
40
+ if (l.length >= 20 && setB.has(l)) {
41
+ current.push(raw.trim());
42
+ } else if (l.length > 0) {
43
+ if (current.length) blocks.push(current), (current = []);
44
+ }
45
+ // blank lines don't break a block
46
+ }
47
+ if (current.length) blocks.push(current);
48
+ return blocks
49
+ .map((lines) => ({ lines, text: lines.join('\n'), chars: lines.join(' ').length }))
50
+ .filter((b) => b.lines.some(substantive))
51
+ .sort((x, y) => y.chars - x.chars);
52
+ }
53
+
54
+ /**
55
+ * Heuristic duplicate assessment. `similarity` is the TF-IDF cosine from
56
+ * candidate selection. Flags only when there is *verbatim* shared content —
57
+ * vocabulary similarity alone is not enough (false-positive guard).
58
+ */
59
+ export function heuristicDuplicate(a, b, similarity = 0) {
60
+ const blocks = sharedBlocks(a.text, b.text);
61
+ const sharedChars = blocks.reduce((n, bl) => n + bl.chars, 0);
62
+ const minLen = Math.max(1, Math.min(a.text.length, b.text.length));
63
+ const sharedRatio = Math.min(1, sharedChars / minLen);
64
+
65
+ // Require a meaningful verbatim block; similarity alone never fires.
66
+ const hasBlock = blocks.length > 0 && blocks[0].chars >= 120;
67
+ const is_duplicate = hasBlock && (sharedRatio >= 0.2 || similarity >= 0.6);
68
+
69
+ // Confidence is measured, not guessed: verbatim overlap dominates.
70
+ const confidence = is_duplicate
71
+ ? Math.min(0.99, Math.round((0.55 * Math.min(1, sharedRatio * 2) + 0.45 * similarity) * 100) / 100)
72
+ : Math.round(Math.max(sharedRatio, similarity) * 100) / 100;
73
+
74
+ return {
75
+ output: {
76
+ is_duplicate,
77
+ confidence,
78
+ summary: is_duplicate
79
+ ? `Verbatim duplicated content between "${a.relPath}" and "${b.relPath}" — ${blocks.length} shared block(s), ~${Math.round(sharedRatio * 100)}% of the smaller doc (TF-IDF similarity ${similarity.toFixed(2)}).`
80
+ : 'No verbatim duplication detected.',
81
+ recommended_action: 'CONSOLIDATE',
82
+ evidence: blocks.slice(0, 3).map((bl) => ({ page: 'A', excerpt: bl.text.slice(0, 1000) })),
83
+ },
84
+ model: 'heuristic (tf-idf + shared blocks)',
85
+ promptVersion: 'heuristic-v1',
86
+ };
87
+ }
88
+
89
+ /** Explicit unresolved-marker classes — deterministic, hence high confidence. */
90
+ const OQ_MARKERS = [
91
+ { re: /\b(TODO|FIXME)\b(?!\s*:?\s*none)/, label: 'TODO/FIXME marker', confidence: 0.95, severity: 'MEDIUM' },
92
+ { re: /\b(TBD|TBC)\b|\bto be (decided|determined|confirmed|announced)\b/i, label: 'TBD marker', confidence: 0.95, severity: 'MEDIUM' },
93
+ { re: /\?{3,}|\[\?\]/, label: 'placeholder question marks', confidence: 0.9, severity: 'MEDIUM' },
94
+ // Affirmative context required ("remains unresolved"), so negations like
95
+ // "nothing unresolved" don't fire (false-positive guard).
96
+ { re: /\bopen question\b|\b(is|are|remains?|still|left)\s+(unresolved|undecided)\b|\bnot (yet )?(decided|determined|finali[sz]ed)\b/i, label: 'explicit open-question language', confidence: 0.85, severity: 'MEDIUM' },
97
+ ];
98
+
99
+ /**
100
+ * Heuristic open-question detection. Scans prose only (fenced code stripped —
101
+ * TODOs inside code samples are code concerns, not doc integrity), one
102
+ * finding per line, deduplicated.
103
+ */
104
+ export function heuristicOpenQuestions(doc) {
105
+ const lines = stripFences(doc.text).split('\n');
106
+ const questions = [];
107
+ const seen = new Set();
108
+ for (const raw of lines) {
109
+ const line = normLine(raw);
110
+ if (!line || seen.has(line)) continue;
111
+ for (const m of OQ_MARKERS) {
112
+ if (m.re.test(line)) {
113
+ seen.add(line);
114
+ questions.push({
115
+ question: `Unresolved ${m.label}: "${line.slice(0, 200)}"`,
116
+ excerpt: raw.trim().slice(0, 1000),
117
+ confidence: m.confidence,
118
+ severity: m.severity,
119
+ });
120
+ break; // one finding per line even if several markers match
121
+ }
122
+ }
123
+ }
124
+ return {
125
+ output: { questions },
126
+ model: 'heuristic (signal markers)',
127
+ promptVersion: 'heuristic-v1',
128
+ };
129
+ }
130
+
131
+ /** Template-based issue draft — used when no LLM client is available. */
132
+ export function templateIssue(finding) {
133
+ const title = `[docgrity] ${finding.type.replace('_', ' ')}: ${finding.summary.slice(0, 140)}`;
134
+ const body = [
135
+ `**Type:** ${finding.type} `,
136
+ `**Severity:** ${finding.severity} · **Confidence:** ${(finding.confidence * 100).toFixed(0)}% `,
137
+ `**Docs:** ${finding.files.map((f) => `\`${f}\``).join(', ')} `,
138
+ `**Potential owner(s):** ${finding.potentialOwners.join(', ') || 'unknown'} *(from git history — potential, not asserted)*`,
139
+ '',
140
+ '### Evidence',
141
+ ...finding.evidence.map((e) => `> ${e.excerpt.replace(/\n/g, '\n> ')}\n> — \`${e.sourceLabel}\``),
142
+ '',
143
+ `_Detected by Docgrity in no-agent (heuristic) mode — evidence is verbatim from the docs._`,
144
+ ].join('\n');
145
+ return { title: title.slice(0, 200), body: body.slice(0, 20000) };
146
+ }
package/src/issues.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * Only runs when create_issues is explicitly enabled (opt-in guard).
8
8
  */
9
9
  import { draftIssue } from './llm.js';
10
+ import { templateIssue } from './heuristics.js';
10
11
 
11
12
  const MARKER = (fp) => `<!-- docgrity:fingerprint:${fp} -->`;
12
13
  const LABEL = 'docgrity';
@@ -71,7 +72,9 @@ export async function syncIssues({ client, token, slug, findings, maxNewIssues =
71
72
  result.skipped++;
72
73
  continue;
73
74
  }
74
- const draft = await draftIssue(client, f);
75
+ // No LLM client (no-agent mode) → deterministic template draft; evidence is
76
+ // already verbatim, so nothing is lost except prose polish.
77
+ const draft = client ? await draftIssue(client, f) : templateIssue(f);
75
78
  const body = `${draft.body}
76
79
 
77
80
  ---
package/src/report.js CHANGED
@@ -154,7 +154,8 @@ export function renderReport({ findings, stats, repoSlug, branch }) {
154
154
  <div class="wrap">
155
155
  <div class="hero">
156
156
  <h1>Docgrity — documentation-integrity report</h1>
157
- <div class="meta">${esc(repoSlug ?? 'local scan')} · scanned ${esc(stats.scannedAt)} · ${stats.docs} markdown docs · ${stats.pairs} pairs assessed</div>
157
+ <div class="meta">${esc(repoSlug ?? 'local scan')} · scanned ${esc(stats.scannedAt)} · ${stats.docs} markdown docs · ${stats.pairs} pairs assessed${stats.mode === 'heuristic' ? ' · no-agent (heuristic) mode' : ''}</div>
158
+ ${(stats.notes ?? []).map((n) => `<div class="meta" style="margin-top:6px">⚠ ${esc(n)}</div>`).join('')}
158
159
  </div>
159
160
  <div class="stats">
160
161
  <button class="stat" data-target="all"><div><div class="label">Open findings</div><div class="num">${findings.length}</div><div class="sub">view all</div></div><div class="badge ${findings.length ? 'findings' : 'ok'}">${icon(findings.length ? 'findings' : 'check')}</div></button>
package/src/scan.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import crypto from 'crypto';
7
7
  import { collectCorpus, selectCandidatePairs, potentialOwner } from './corpus.js';
8
8
  import { assessContradiction, assessDuplicate, assessOpenQuestions } from './llm.js';
9
+ import { heuristicDuplicate, heuristicOpenQuestions } from './heuristics.js';
9
10
 
10
11
  const norm = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
11
12
 
@@ -37,9 +38,17 @@ export async function runScan(root, opts = {}) {
37
38
  } = opts;
38
39
 
39
40
  const docs = await collectCorpus(root, { maxFiles });
41
+ // No-agent (heuristic) mode when no LLM client is provided.
42
+ const heuristic = !client;
43
+ const notes = [];
44
+ if (heuristic && checks.contradictions) {
45
+ notes.push('Contradiction detection requires an AI model (semantic understanding) — skipped in no-agent mode.');
46
+ log('Note: contradictions need AI — skipped (no-agent mode).');
47
+ }
40
48
  // Pair selection is only needed for the pairwise checks.
41
- const pairs = checks.duplicates || checks.contradictions ? selectCandidatePairs(docs, maxPairs) : [];
42
- log(`Corpus: ${docs.length} markdown docs; assessing ${pairs.length} candidate pairs`);
49
+ const pairwise = checks.duplicates || (checks.contradictions && !heuristic);
50
+ const pairs = pairwise ? selectCandidatePairs(docs, maxPairs) : [];
51
+ log(`Corpus: ${docs.length} markdown docs; assessing ${pairs.length} candidate pairs${heuristic ? ' (heuristic, no LLM)' : ''}`);
43
52
  const findings = [];
44
53
  const now = new Date().toISOString();
45
54
 
@@ -53,10 +62,14 @@ export async function runScan(root, opts = {}) {
53
62
  };
54
63
 
55
64
  let i = 0;
56
- for (const { a, b } of pairs) {
65
+ for (const { a, b, similarity } of pairs) {
57
66
  i++;
58
67
  log(`Pair ${i}/${pairs.length}: ${a.relPath} <-> ${b.relPath}`);
59
- const dup = checks.duplicates ? await assessDuplicate(client, a, b) : null;
68
+ const dup = checks.duplicates
69
+ ? heuristic
70
+ ? heuristicDuplicate(a, b, similarity)
71
+ : await assessDuplicate(client, a, b)
72
+ : null;
60
73
  if (
61
74
  dup &&
62
75
  dup.output.is_duplicate &&
@@ -79,11 +92,12 @@ export async function runScan(root, opts = {}) {
79
92
  potentialOwners: await owners([a, b]),
80
93
  model: dup.model,
81
94
  promptVersion: dup.promptVersion,
95
+ method: heuristic ? 'heuristic' : 'llm',
82
96
  createdAt: now,
83
97
  });
84
98
  }
85
99
 
86
- const con = checks.contradictions ? await assessContradiction(client, a, b) : null;
100
+ const con = checks.contradictions && !heuristic ? await assessContradiction(client, a, b) : null;
87
101
  if (
88
102
  con &&
89
103
  con.output.is_contradiction &&
@@ -106,6 +120,7 @@ export async function runScan(root, opts = {}) {
106
120
  potentialOwners: await owners([a, b]),
107
121
  model: con.model,
108
122
  promptVersion: con.promptVersion,
123
+ method: 'llm',
109
124
  createdAt: now,
110
125
  });
111
126
  }
@@ -116,7 +131,7 @@ export async function runScan(root, opts = {}) {
116
131
  for (const doc of oqDocs) {
117
132
  j++;
118
133
  log(`Open questions ${j}/${oqDocs.length}: ${doc.relPath}`);
119
- const oq = await assessOpenQuestions(client, doc);
134
+ const oq = heuristic ? heuristicOpenQuestions(doc) : await assessOpenQuestions(client, doc);
120
135
  const kept = oq.output.questions.filter(
121
136
  (q) => q.confidence >= thresholds.openQuestion && verifyExcerpts([q], [doc])
122
137
  );
@@ -133,10 +148,20 @@ export async function runScan(root, opts = {}) {
133
148
  potentialOwners: await owners([doc]),
134
149
  model: oq.model,
135
150
  promptVersion: oq.promptVersion,
151
+ method: heuristic ? 'heuristic' : 'llm',
136
152
  createdAt: now,
137
153
  });
138
154
  }
139
155
  }
140
156
 
141
- return { findings, stats: { docs: docs.length, pairs: pairs.length, scannedAt: now } };
157
+ return {
158
+ findings,
159
+ stats: {
160
+ docs: docs.length,
161
+ pairs: pairs.length,
162
+ scannedAt: now,
163
+ mode: heuristic ? 'heuristic' : 'llm',
164
+ ...(notes.length ? { notes } : {}),
165
+ },
166
+ };
142
167
  }