@lsctech/polaris 0.2.2 → 0.3.1

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.
@@ -8,6 +8,7 @@ exports.SKILL_ROLE_MAP = {
8
8
  run: "Foreman",
9
9
  ingest: "Librarian",
10
10
  promote: "Librarian",
11
+ triage: "Librarian",
11
12
  };
12
13
  const ROLE_SUMMARIES = {
13
14
  Analyst: "The Analyst gathers evidence, assesses feasibility, and produces implementation-ready plans. The Analyst shapes work but never executes it.",
@@ -187,6 +188,39 @@ function buildPromotePacket() {
187
188
  ],
188
189
  };
189
190
  }
191
+ function buildTriagePacket() {
192
+ return {
193
+ authority_boundaries: [
194
+ "Read smartdocs/doctrine/active/ to load canonicals for comparison",
195
+ "Read smartdocs/doctrine/candidate/ to load candidates for triage",
196
+ "Call polaris docs triage [--dry-run] [--batch-size N] [--resume] to run the triage pipeline",
197
+ "Read smartdocs/raw/_triage-queue.json and _triage-report.md produced by triage",
198
+ "Emit telemetry events",
199
+ ],
200
+ prohibited_actions: [
201
+ "Move, promote, or delete any document — triage flags only, never decides",
202
+ "Mutate source files (src/, tests, config)",
203
+ "Call polaris loop continue or polaris finalize",
204
+ "Suppress or ignore detected flags",
205
+ "Auto-approve or auto-reject any triage flag without user review",
206
+ ],
207
+ allowed_outputs: [
208
+ "smartdocs/raw/_triage-queue.json — machine-readable flag list for polaris docs review",
209
+ "smartdocs/raw/_triage-report.md — human-readable summary",
210
+ "Telemetry events",
211
+ ],
212
+ deliverables: [
213
+ "_triage-queue.json written with all flags from Phase 1 (doc-vs-doc) and Phase 2 (doc-vs-code)",
214
+ "_triage-report.md written summarising flag counts by type",
215
+ "Checkpoint deleted on successful completion",
216
+ ],
217
+ stop_conditions: [
218
+ "Triage pipeline completes and outputs written",
219
+ "API key not available — report and wait for instruction",
220
+ "Graph coverage below threshold — Phase 2 skipped with warning",
221
+ ],
222
+ };
223
+ }
190
224
  function generateSkillPacket(skillName, config) {
191
225
  const active_role = exports.SKILL_ROLE_MAP[skillName];
192
226
  const role_summary = ROLE_SUMMARIES[active_role];
@@ -211,6 +245,9 @@ function generateSkillPacket(skillName, config) {
211
245
  case "promote":
212
246
  body = buildPromotePacket();
213
247
  break;
248
+ case "triage":
249
+ body = buildTriagePacket();
250
+ break;
214
251
  }
215
252
  return {
216
253
  packet_id,
@@ -222,4 +259,4 @@ function generateSkillPacket(skillName, config) {
222
259
  generated_at,
223
260
  };
224
261
  }
225
- exports.SUPPORTED_SKILLS = ["analyze", "run", "ingest", "promote"];
262
+ exports.SUPPORTED_SKILLS = ["analyze", "run", "ingest", "promote", "triage"];
@@ -12,6 +12,7 @@ const seed_instructions_js_1 = require("./seed-instructions.js");
12
12
  const validate_instructions_js_1 = require("./validate-instructions.js");
13
13
  const doctrine_js_1 = require("./doctrine.js");
14
14
  const audit_js_1 = require("./audit.js");
15
+ const triage_js_1 = require("./triage.js");
15
16
  /**
16
17
  * Build and return the top-level "docs" Commander command group for Polaris docs lifecycle workflows.
17
18
  *
@@ -151,6 +152,27 @@ function createDocsCommand(options = {}) {
151
152
  process.exit(1);
152
153
  }
153
154
  });
155
+ docs
156
+ .command("triage")
157
+ .description("Detect contradictions, duplicates, and stale code references among candidate docs")
158
+ .option("-r, --repo-root <path>", "Repository root", process.cwd())
159
+ .option("--batch-size <n>", "Docs per LLM call", "10")
160
+ .option("--resume", "Resume from last checkpoint (auto-detected by default)")
161
+ .option("--dry-run", "Plan batches and print cost estimate without calling the LLM")
162
+ .action(async (options) => {
163
+ try {
164
+ await (0, triage_js_1.runTriage)({
165
+ repoRoot: options.repoRoot,
166
+ batchSize: parseInt(options.batchSize, 10) || 10,
167
+ resume: options.resume,
168
+ dryRun: options.dryRun,
169
+ });
170
+ }
171
+ catch (err) {
172
+ console.error(`polaris docs triage: ${err instanceof Error ? err.message : String(err)}`);
173
+ process.exit(1);
174
+ }
175
+ });
154
176
  docs
155
177
  .command("migrate")
156
178
  .description("Find scattered markdown files, move them to smartdocs/raw/, and produce an ingest cluster list")
@@ -0,0 +1,494 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clusterCandidates = clusterCandidates;
4
+ exports.extractSymbols = extractSymbols;
5
+ exports.loadDocMeta = loadDocMeta;
6
+ exports.loadAllDocMeta = loadAllDocMeta;
7
+ exports.readTriageCheckpoint = readTriageCheckpoint;
8
+ exports.writeTriageCheckpoint = writeTriageCheckpoint;
9
+ exports.deleteTriageCheckpoint = deleteTriageCheckpoint;
10
+ exports.writeTriageQueue = writeTriageQueue;
11
+ exports.resolveTriageModel = resolveTriageModel;
12
+ exports.runBatchComparison = runBatchComparison;
13
+ exports.runGraphCheck = runGraphCheck;
14
+ exports.runTriage = runTriage;
15
+ const node_fs_1 = require("node:fs");
16
+ const node_path_1 = require("node:path");
17
+ // ---------------------------------------------------------------------------
18
+ // clusterCandidates
19
+ // ---------------------------------------------------------------------------
20
+ /**
21
+ * Groups candidate docs into named clusters using metadata overlap with canonicals.
22
+ * Candidates with no signal match go into the "general" bucket.
23
+ */
24
+ function clusterCandidates(candidates, canonicals) {
25
+ const clusters = {};
26
+ // Build cluster names from canonicals
27
+ for (const canonical of canonicals) {
28
+ const names = clusterNamesFor(canonical);
29
+ for (const name of names) {
30
+ if (!clusters[name]) {
31
+ clusters[name] = { candidates: [], canonicals: [] };
32
+ }
33
+ if (!clusters[name].canonicals.includes(canonical)) {
34
+ clusters[name].canonicals.push(canonical);
35
+ }
36
+ }
37
+ }
38
+ // Assign each candidate to its best cluster
39
+ for (const candidate of candidates) {
40
+ const candidateNames = clusterNamesFor(candidate);
41
+ let assigned = false;
42
+ for (const name of candidateNames) {
43
+ if (clusters[name]) {
44
+ clusters[name].candidates.push(candidate);
45
+ assigned = true;
46
+ break; // assign to first matching cluster only — multi-cluster candidates would inflate batch sizes
47
+ }
48
+ }
49
+ if (!assigned) {
50
+ if (!clusters["general"]) {
51
+ clusters["general"] = { candidates: [], canonicals: [] };
52
+ }
53
+ clusters["general"].candidates.push(candidate);
54
+ }
55
+ }
56
+ // Ensure general bucket exists
57
+ if (!clusters["general"]) {
58
+ clusters["general"] = { candidates: [], canonicals: [] };
59
+ }
60
+ return clusters;
61
+ }
62
+ function clusterNamesFor(doc) {
63
+ const names = [];
64
+ // Tags take priority
65
+ for (const tag of doc.tags) {
66
+ if (tag.trim())
67
+ names.push(tag.toLowerCase().trim());
68
+ }
69
+ // Cluster membership
70
+ for (const c of doc.clusterMembership) {
71
+ if (c.trim())
72
+ names.push(c.toLowerCase().trim());
73
+ }
74
+ // Filename prefix (e.g. "ADR", "EVOlearn")
75
+ for (const p of doc.filenamePrefixes) {
76
+ if (p.trim())
77
+ names.push(p.toLowerCase().trim());
78
+ }
79
+ // Type as fallback
80
+ if (doc.type.trim())
81
+ names.push(doc.type.toLowerCase().trim());
82
+ return names;
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // extractSymbols
86
+ // ---------------------------------------------------------------------------
87
+ const BACKTICK_RE = /`([A-Za-z_][A-Za-z0-9_]{3,})`/g;
88
+ const CAMEL_PASCAL_RE = /(?<![`\w])([A-Z][a-z]+(?:[A-Z][a-z]+)+|[a-z][a-z]+(?:[A-Z][a-z0-9]+)+)(?![`\w])/g;
89
+ /**
90
+ * Extracts likely code symbol names from markdown text.
91
+ * Returns deduplicated symbol names of 4+ characters.
92
+ */
93
+ function extractSymbols(text) {
94
+ const found = new Set();
95
+ let m;
96
+ BACKTICK_RE.lastIndex = 0;
97
+ while ((m = BACKTICK_RE.exec(text)) !== null) {
98
+ found.add(m[1]);
99
+ }
100
+ CAMEL_PASCAL_RE.lastIndex = 0;
101
+ while ((m = CAMEL_PASCAL_RE.exec(text)) !== null) {
102
+ found.add(m[1]);
103
+ }
104
+ return Array.from(found);
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // DocMeta loader
108
+ // ---------------------------------------------------------------------------
109
+ /**
110
+ * Parses YAML frontmatter from a markdown file and returns DocMeta.
111
+ * Does not throw — returns empty arrays on any parse failure.
112
+ */
113
+ function loadDocMeta(filePath) {
114
+ let content = "";
115
+ try {
116
+ content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
117
+ }
118
+ catch {
119
+ return emptyMeta(filePath);
120
+ }
121
+ const frontmatter = parseFrontmatter(content);
122
+ const filenamePrefixes = extractFilenamePrefixes((0, node_path_1.basename)(filePath, (0, node_path_1.extname)(filePath)));
123
+ return {
124
+ path: filePath,
125
+ tags: toStringArray(frontmatter["Tags"] ?? frontmatter["tags"]),
126
+ type: String(frontmatter["Type"] ?? frontmatter["type"] ?? "").trim(),
127
+ clusterMembership: toStringArray(frontmatter["Member Of Concept Cluster"]),
128
+ relatedNotes: toStringArray(frontmatter["Related Notes"]),
129
+ filenamePrefixes,
130
+ };
131
+ }
132
+ /**
133
+ * Reads all .md files from a directory and returns their DocMeta.
134
+ */
135
+ function loadAllDocMeta(dir) {
136
+ let entries = [];
137
+ try {
138
+ entries = (0, node_fs_1.readdirSync)(dir).filter((f) => f.endsWith(".md"));
139
+ }
140
+ catch {
141
+ return [];
142
+ }
143
+ return entries.map((f) => loadDocMeta((0, node_path_1.join)(dir, f)));
144
+ }
145
+ function emptyMeta(filePath) {
146
+ return {
147
+ path: filePath,
148
+ tags: [],
149
+ type: "",
150
+ clusterMembership: [],
151
+ relatedNotes: [],
152
+ filenamePrefixes: extractFilenamePrefixes((0, node_path_1.basename)(filePath, (0, node_path_1.extname)(filePath))),
153
+ };
154
+ }
155
+ function parseFrontmatter(content) {
156
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
157
+ if (!match)
158
+ return {};
159
+ const result = {};
160
+ const lines = match[1].split("\n");
161
+ let currentKey = null;
162
+ const listBuffer = [];
163
+ const flushList = () => {
164
+ if (currentKey && listBuffer.length > 0) {
165
+ result[currentKey] = [...listBuffer];
166
+ listBuffer.length = 0;
167
+ }
168
+ };
169
+ for (const line of lines) {
170
+ // Inline array: Tags: [a, b]
171
+ const inlineMatch = line.match(/^([^:]+):\s*\[(.*)\]$/);
172
+ if (inlineMatch) {
173
+ flushList();
174
+ currentKey = null;
175
+ const key = inlineMatch[1].trim();
176
+ result[key] = inlineMatch[2]
177
+ .split(",")
178
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
179
+ .filter(Boolean);
180
+ continue;
181
+ }
182
+ // Key with value: Type: Decision
183
+ const kvMatch = line.match(/^([^:]+):\s*(.+)$/);
184
+ if (kvMatch && !line.startsWith(" ")) {
185
+ flushList();
186
+ currentKey = kvMatch[1].trim();
187
+ const val = kvMatch[2].trim();
188
+ if (val !== "") {
189
+ result[currentKey] = val.replace(/^["']|["']$/g, "");
190
+ currentKey = null;
191
+ }
192
+ continue;
193
+ }
194
+ // Key with no inline value (list follows)
195
+ const keyOnlyMatch = line.match(/^([^:]+):\s*$/);
196
+ if (keyOnlyMatch && !line.startsWith(" ")) {
197
+ flushList();
198
+ currentKey = keyOnlyMatch[1].trim();
199
+ continue;
200
+ }
201
+ // List item — wikilink style: - "[[ADR-002|ADR-002]]"
202
+ const listItemMatch = line.match(/^\s+-\s+"?\[\[([^\]|]+)(?:\|[^\]]+)?\]\]"?$/);
203
+ if (listItemMatch && currentKey) {
204
+ listBuffer.push(listItemMatch[1].trim());
205
+ continue;
206
+ }
207
+ // List item — plain
208
+ const simpleItemMatch = line.match(/^\s+-\s+(.+)$/);
209
+ if (simpleItemMatch && currentKey) {
210
+ listBuffer.push(simpleItemMatch[1].trim().replace(/^["']|["']$/g, ""));
211
+ }
212
+ }
213
+ flushList();
214
+ return result;
215
+ }
216
+ function toStringArray(value) {
217
+ if (!value)
218
+ return [];
219
+ if (Array.isArray(value))
220
+ return value.map(String).filter(Boolean);
221
+ if (typeof value === "string" && value.trim())
222
+ return [value.trim()];
223
+ return [];
224
+ }
225
+ function extractFilenamePrefixes(stem) {
226
+ // "ADR-001 - Some Title" → ["ADR"]
227
+ // "EVOlearn_Governance" → ["EVOlearn"]
228
+ const parts = stem.split(/[-_ ]/);
229
+ return parts.slice(0, 2).filter((p) => p.length >= 3 && /[A-Za-z]/.test(p));
230
+ }
231
+ const CHECKPOINT_FILENAME = "_triage-checkpoint.json";
232
+ function readTriageCheckpoint(outputDir) {
233
+ const p = (0, node_path_1.join)(outputDir, CHECKPOINT_FILENAME);
234
+ if (!(0, node_fs_1.existsSync)(p))
235
+ return null;
236
+ try {
237
+ return JSON.parse((0, node_fs_1.readFileSync)(p, "utf-8"));
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ }
243
+ function writeTriageCheckpoint(checkpoint, outputDir) {
244
+ (0, node_fs_1.mkdirSync)(outputDir, { recursive: true });
245
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, CHECKPOINT_FILENAME), JSON.stringify(checkpoint, null, 2), "utf-8");
246
+ }
247
+ function deleteTriageCheckpoint(outputDir) {
248
+ const p = (0, node_path_1.join)(outputDir, CHECKPOINT_FILENAME);
249
+ if ((0, node_fs_1.existsSync)(p))
250
+ (0, node_fs_1.unlinkSync)(p);
251
+ }
252
+ // ---------------------------------------------------------------------------
253
+ // Triage queue writer
254
+ // ---------------------------------------------------------------------------
255
+ function writeTriageQueue(packets, outputDir) {
256
+ (0, node_fs_1.mkdirSync)(outputDir, { recursive: true });
257
+ const queueFile = {
258
+ generated_at: new Date().toISOString(),
259
+ run_id: `triage-${Date.now()}`,
260
+ packets,
261
+ };
262
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, "_triage-queue.json"), JSON.stringify(queueFile, null, 2), "utf-8");
263
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outputDir, "_triage-report.md"), renderTriageReport(packets), "utf-8");
264
+ }
265
+ function renderTriageReport(packets) {
266
+ const contradictions = packets.filter((p) => p.triageFlag === "contradiction");
267
+ const duplicates = packets.filter((p) => p.triageFlag === "duplicate");
268
+ const stale = packets.filter((p) => p.triageFlag === "stale-reference");
269
+ const lines = [
270
+ "# Polaris Docs Triage Report",
271
+ "",
272
+ `Generated: ${new Date().toISOString()}`,
273
+ "",
274
+ "## Summary",
275
+ "",
276
+ `| Flag | Count |`,
277
+ `|------|-------|`,
278
+ `| contradiction | ${contradictions.length} |`,
279
+ `| duplicate | ${duplicates.length} |`,
280
+ `| stale-reference | ${stale.length} |`,
281
+ `| **total** | **${packets.length}** |`,
282
+ "",
283
+ "## Flagged Documents",
284
+ "",
285
+ "This file is display-only. Edit `_triage-queue.json` to record decisions,",
286
+ "or run `polaris docs review` to walk through them interactively.",
287
+ "",
288
+ ];
289
+ for (const p of packets) {
290
+ lines.push(`### ${p.triageFlag.toUpperCase()} · ${p.sourcePath}`);
291
+ lines.push("");
292
+ lines.push(`**Reason:** ${p.outcomeReason}`);
293
+ if (p.relatedCanonical) {
294
+ lines.push(`**Conflicts with:** ${p.relatedCanonical}`);
295
+ }
296
+ if (p.staleSymbols && p.staleSymbols.length > 0) {
297
+ lines.push(`**Stale symbols:** ${p.staleSymbols.join(", ")}`);
298
+ }
299
+ lines.push(`**Review decision:** ${p.reviewDecision ?? "← pending"}`);
300
+ lines.push("");
301
+ }
302
+ return lines.join("\n");
303
+ }
304
+ function resolveTriageModel(configured) {
305
+ return (process.env["POLARIS_TRIAGE_MODEL"] ??
306
+ configured ??
307
+ "claude-haiku-4-5-20251001");
308
+ }
309
+ async function runBatchComparison(candidates, canonicals, options) {
310
+ const client = options.llmClient ?? (await buildDefaultLlmClient());
311
+ for (let attempt = 0; attempt < 2; attempt++) {
312
+ try {
313
+ return await client.compare(candidates, canonicals, options.model);
314
+ }
315
+ catch (err) {
316
+ process.stderr.write(`[triage] LLM batch failed (attempt ${attempt + 1})${attempt === 0 ? ", retrying" : ", skipping batch"}: ${err instanceof Error ? err.message : String(err)}\n`);
317
+ }
318
+ }
319
+ return [];
320
+ }
321
+ async function buildDefaultLlmClient() {
322
+ const apiKey = process.env["ANTHROPIC_API_KEY"] ?? "";
323
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
+ // @ts-ignore: @anthropic-ai/sdk may not be installed as a dependency
325
+ const mod = await import("@anthropic-ai/sdk");
326
+ const Anthropic = mod.default ?? mod.Anthropic;
327
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
328
+ const sdkClient = new Anthropic({ apiKey });
329
+ return {
330
+ async compare(candidates, canonicals, model) {
331
+ const candidateSummaries = candidates
332
+ .map((c, i) => `[${i}] ${(0, node_path_1.basename)(c.path)} (${c.type || "unknown type"}) tags: ${c.tags.join(", ") || "none"}`)
333
+ .join("\n");
334
+ const canonicalSummaries = canonicals
335
+ .map((c) => `- ${(0, node_path_1.basename)(c.path)} (${c.type || "unknown type"}) tags: ${c.tags.join(", ") || "none"}`)
336
+ .join("\n");
337
+ const prompt = [
338
+ "You are comparing candidate documentation files against canonical documentation.",
339
+ "Identify any candidates that CONTRADICT or DUPLICATE a canonical.",
340
+ 'Return ONLY a JSON array. If no issues found, return [].',
341
+ 'Each item must be: { "candidatePath": string, "flagType": "contradiction" | "duplicate", "canonicalPath"?: string, "reason": string }',
342
+ "",
343
+ "CANDIDATES:",
344
+ candidateSummaries,
345
+ "",
346
+ "CANONICALS:",
347
+ canonicalSummaries,
348
+ "",
349
+ "Respond with JSON only. No prose.",
350
+ ].join("\n");
351
+ const response = await sdkClient.messages.create({
352
+ model,
353
+ max_tokens: 1024,
354
+ messages: [{ role: "user", content: prompt }],
355
+ });
356
+ const text = response.content
357
+ .filter((b) => b.type === "text")
358
+ .map((b) => b.text)
359
+ .join("");
360
+ const cleaned = text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "").trim();
361
+ return JSON.parse(cleaned);
362
+ },
363
+ };
364
+ }
365
+ const STALE_SYMBOL_THRESHOLD = 2;
366
+ const GRAPH_SYMBOL_MINIMUM = 1000;
367
+ function runGraphCheck(candidates, options) {
368
+ const stats = options.graphStats();
369
+ if (stats.symbolCount < GRAPH_SYMBOL_MINIMUM) {
370
+ process.stderr.write(`[triage] Graph coverage too low for doc-vs-code check (${stats.symbolCount} symbols indexed).\n` +
371
+ ` Run: polaris-cli graph build\n`);
372
+ return [];
373
+ }
374
+ const flags = [];
375
+ for (const candidate of candidates) {
376
+ let content = "";
377
+ try {
378
+ content = options.getContent(candidate.path);
379
+ }
380
+ catch {
381
+ continue;
382
+ }
383
+ const symbols = extractSymbols(content);
384
+ const missing = symbols.filter((s) => !options.symbolLookup(s));
385
+ if (missing.length >= STALE_SYMBOL_THRESHOLD) {
386
+ flags.push({
387
+ candidatePath: candidate.path,
388
+ flagType: "stale-reference",
389
+ staleSymbols: missing,
390
+ });
391
+ }
392
+ }
393
+ return flags;
394
+ }
395
+ async function runTriage(options) {
396
+ const { repoRoot, batchSize = 10, dryRun = false, output = (m) => process.stdout.write(m + "\n"), llmClient, symbolLookup, graphStats, } = options;
397
+ const activeDir = (0, node_path_1.join)(repoRoot, "smartdocs", "doctrine", "active");
398
+ const candidateDir = (0, node_path_1.join)(repoRoot, "smartdocs", "doctrine", "candidate");
399
+ const outputDir = (0, node_path_1.join)(repoRoot, "smartdocs", "raw");
400
+ output(`Loading canonical docs from ${activeDir}...`);
401
+ const canonicals = loadAllDocMeta(activeDir);
402
+ output(`Loading candidate docs from ${candidateDir}...`);
403
+ const candidates = loadAllDocMeta(candidateDir);
404
+ output(`Clustering ${candidates.length} candidates against ${canonicals.length} canonicals...`);
405
+ const clusterMap = clusterCandidates(candidates, canonicals);
406
+ const clusterNames = Object.keys(clusterMap).filter((k) => k !== "general");
407
+ if (clusterMap["general"])
408
+ clusterNames.push("general");
409
+ if (dryRun) {
410
+ const batchCount = clusterNames.reduce((sum, name) => {
411
+ return sum + Math.ceil((clusterMap[name].candidates.length || 0) / batchSize);
412
+ }, 0);
413
+ output(` ${clusterNames.length} clusters identified`);
414
+ output(` Estimated batches: ${batchCount}`);
415
+ output(` Estimated tokens: ~${batchCount * 3000} input / ~${batchCount * 200} output`);
416
+ output(` Model: ${resolveTriageModel(options.model)} (configured)`);
417
+ output(`\nRun without --dry-run to execute.`);
418
+ return { flagCount: 0, outputDir };
419
+ }
420
+ let checkpoint = readTriageCheckpoint(outputDir);
421
+ const completedClusters = new Set(checkpoint?.completedClusters ?? []);
422
+ const accumulatedFlags = [...(checkpoint?.flags ?? [])];
423
+ if (completedClusters.size > 0) {
424
+ output(`Resuming triage from checkpoint (${completedClusters.size}/${clusterNames.length} clusters complete)...`);
425
+ }
426
+ const model = resolveTriageModel(options.model);
427
+ // Phase 1: doc-vs-doc
428
+ for (const clusterName of clusterNames) {
429
+ if (completedClusters.has(clusterName))
430
+ continue;
431
+ const cluster = clusterMap[clusterName];
432
+ if (cluster.candidates.length === 0) {
433
+ completedClusters.add(clusterName);
434
+ continue;
435
+ }
436
+ output(` Comparing cluster "${clusterName}" (${cluster.candidates.length} candidates, ${cluster.canonicals.length} canonicals)...`);
437
+ for (let i = 0; i < cluster.candidates.length; i += batchSize) {
438
+ const batch = cluster.candidates.slice(i, i + batchSize);
439
+ const flags = await runBatchComparison(batch, cluster.canonicals, { model, llmClient });
440
+ accumulatedFlags.push(...flags);
441
+ }
442
+ completedClusters.add(clusterName);
443
+ writeTriageCheckpoint({ completedClusters: Array.from(completedClusters), flags: accumulatedFlags }, outputDir);
444
+ }
445
+ // Phase 2: doc-vs-code
446
+ output(`Running doc-vs-code graph check...`);
447
+ const graphCheckOptions = {
448
+ getContent: (p) => (0, node_fs_1.readFileSync)(p, "utf-8"),
449
+ symbolLookup: symbolLookup ?? ((name) => {
450
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
451
+ const { lookupSymbol } = require("../graph/query/index.js");
452
+ return lookupSymbol(name) !== null;
453
+ }),
454
+ graphStats: graphStats ?? (() => {
455
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
456
+ const { getGraphStats } = require("../graph/query/index.js");
457
+ return getGraphStats();
458
+ }),
459
+ };
460
+ const graphFlags = runGraphCheck(candidates, graphCheckOptions);
461
+ const allPackets = [
462
+ ...accumulatedFlags.map((f) => ({
463
+ sourcePath: f.candidatePath,
464
+ proposedDestination: f.candidatePath,
465
+ classificationConfidence: 0,
466
+ destinationCertainty: 0,
467
+ authorityRisk: "medium",
468
+ reasoning: [f.reason],
469
+ conflicts: f.canonicalPath ? [f.canonicalPath] : [],
470
+ recommendation: "defer",
471
+ outcomeReason: f.reason,
472
+ triageFlag: f.flagType,
473
+ relatedCanonical: f.canonicalPath,
474
+ })),
475
+ ...graphFlags.map((f) => ({
476
+ sourcePath: f.candidatePath,
477
+ proposedDestination: f.candidatePath,
478
+ classificationConfidence: 0,
479
+ destinationCertainty: 0,
480
+ authorityRisk: "low",
481
+ reasoning: [`Stale symbol references: ${f.staleSymbols.join(", ")}`],
482
+ conflicts: [],
483
+ recommendation: "defer",
484
+ outcomeReason: `${f.staleSymbols.length} code symbols not found in graph: ${f.staleSymbols.join(", ")}`,
485
+ triageFlag: "stale-reference",
486
+ staleSymbols: f.staleSymbols,
487
+ })),
488
+ ];
489
+ writeTriageQueue(allPackets, outputDir);
490
+ deleteTriageCheckpoint(outputDir);
491
+ output(`\nTriage complete: ${allPackets.length} flags written to ${outputDir}/_triage-queue.json`);
492
+ output(`Run \`polaris docs review\` to walk through decisions.`);
493
+ return { flagCount: allPackets.length, outputDir };
494
+ }
@@ -33,6 +33,8 @@ message whose primary instruction is to invoke a named Polaris skill.
33
33
  | `run polaris-status` | polaris-tools | `.polaris/skills/polaris-tools/` |
34
34
  | `docs-ingest` | docs-ingest | `.polaris/skills/docs-ingest/` |
35
35
  | `run docs-ingest` | docs-ingest | `.polaris/skills/docs-ingest/` |
36
+ | `docs-triage` | docs-triage | `.polaris/skills/docs-triage/` |
37
+ | `run docs-triage` | docs-triage | `.polaris/skills/docs-triage/` |
36
38
  | `polaris-reconcile <CLUSTER-ID>` | polaris-reconcile | `.polaris/skills/polaris-reconcile/` |
37
39
  | `run polaris-reconcile on [issue] <CLUSTER-ID>` | polaris-reconcile | `.polaris/skills/polaris-reconcile/` |
38
40
  | `polaris-catalog <CLUSTER-ID>` | polaris-catalog | `.polaris/skills/polaris-catalog/` |
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: docs-triage
3
+ description: Detect contradictions, duplicates, and stale code references among candidate docs by comparing them against canonicals via LLM batch calls and graph symbol lookup. Writes a triage queue for human review via polaris docs review.
4
+ role: librarian
5
+ role_file: .polaris/roles/librarian.md
6
+ ---
7
+
8
+ ## Polaris Skill Bootloader
9
+
10
+ **Before proceeding, you must obtain a skill packet from the Polaris runtime.**
11
+
12
+ Run the following command:
13
+
14
+ ```
15
+ npm run polaris -- skill packet triage
16
+ ```
17
+
18
+ - Do not begin work until a packet is returned.
19
+ - Treat the packet as your authoritative instruction source.
20
+ - The packet defines your active role, authority boundaries, prohibited actions, deliverables, and stop conditions.
21
+ - If no packet is produced, stop and report: **Polaris could not authorize this run.**
22
+
23
+ ---
24
+
25
+ # docs-triage
26
+
27
+ Use this skill when candidate docs need to be screened for conflicts, duplicates, and stale symbol references before human review.
28
+
29
+ ## When to use
30
+
31
+ - "Run docs triage"
32
+ - "Triage the candidate docs"
33
+ - "Find contradictions among the candidates"
34
+ - "Check for stale code references in candidates"
35
+ - "Run a dry-run triage to see the estimate"
36
+
37
+ ## How to execute
38
+
39
+ 1. Read `chain.md` — step order, traversal rules, output contract.
40
+ 2. Execute steps in the order `chain.md` defines. Do not skip steps.
41
+ 3. After triage completes, hand off to `polaris docs review` for human decisions.
42
+
43
+ ## Hard rules — what docs-triage may do
44
+
45
+ - Read canonicals from `smartdocs/doctrine/active/`
46
+ - Read candidates from `smartdocs/doctrine/candidate/`
47
+ - Call `polaris docs triage [--dry-run] [--batch-size N] [--resume]`
48
+ - Read `smartdocs/raw/_triage-queue.json` and `_triage-report.md`
49
+ - Emit telemetry events
50
+
51
+ ## Hard rules — what docs-triage must NOT do
52
+
53
+ - Move, promote, or delete any document — triage produces flags only
54
+ - Mutate source files (`src/`, tests, config)
55
+ - Call `polaris loop continue` or `polaris finalize`
56
+ - Auto-approve or auto-reject any triage flag without human review
57
+
58
+ **Docs-triage flags. It does not decide.**
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: docs-triage-chain
3
+ description: Route map for docs-triage — step order, stop conditions, output contract, and CLI reference.
4
+ ---
5
+
6
+ # docs-triage chain
7
+
8
+ ## Authority
9
+
10
+ **Polaris runtime state is authoritative. Chat reasoning is not authoritative.**
11
+
12
+ Query runtime state before acting. Do not infer triage scope or prior progress from conversation context.
13
+
14
+ ## CLI
15
+
16
+ Always use the repo-local Polaris CLI:
17
+
18
+ ```
19
+ npm run polaris -- docs triage [--dry-run] [--batch-size <n>] [--resume] [--repo-root <path>]
20
+ ```
21
+
22
+ - `--dry-run`: prints cluster count, estimated batches, estimated tokens, and model — no LLM calls
23
+ - `--batch-size <n>`: docs per LLM call (default: 10)
24
+ - `--resume`: resume from last checkpoint (auto-detected if checkpoint exists)
25
+
26
+ Never assume a globally linked `polaris` command exists.
27
+
28
+ ## Output contract
29
+
30
+ All outputs written to `smartdocs/raw/`:
31
+
32
+ | File | Purpose |
33
+ |------|---------|
34
+ | `_triage-queue.json` | Machine-readable flag list — fed directly into `polaris docs review` |
35
+ | `_triage-report.md` | Human-readable summary — display only, never parsed |
36
+ | `_triage-checkpoint.json` | Resume state — deleted automatically on completion |
37
+
38
+ ## Step traversal order
39
+
40
+ ```text
41
+ 01-orient-triage ← dry-run first to confirm cluster count and cost estimate; emit telemetry
42
+ 02-run-triage ← polaris docs triage (with --resume if checkpoint exists)
43
+ 03-verify-outputs ← confirm _triage-queue.json and _triage-report.md written; read summary
44
+ 04-hand-off ← report flag counts by type; instruct user to run polaris docs review
45
+ ```
46
+
47
+ ## Stop conditions
48
+
49
+ **Any step:**
50
+ - `ANTHROPIC_API_KEY` not available → halt, report: "Set ANTHROPIC_API_KEY and re-run"
51
+ - `smartdocs/doctrine/active/` or `smartdocs/doctrine/candidate/` not found → halt, report
52
+ - `_triage-queue.json` not written after pipeline completes → halt, report
53
+
54
+ **Step 01 (dry-run):**
55
+ - Estimated batches > 500 → pause and surface cost estimate to user before proceeding
56
+
57
+ **Step 02 (run-triage):**
58
+ - Checkpoint detected → pass `--resume` automatically
59
+ - Graph symbol count < 1000 → Phase 2 skipped (this is expected — log and continue)
60
+
61
+ ## Run ID format
62
+
63
+ Format: `docs-triage-<date>-<seq>`
64
+ - `<date>`: `YYYY-MM-DD`
65
+ - `<seq>`: zero-padded sequential number per day (`001`, `002`, …)
66
+
67
+ Example: `docs-triage-2026-06-14-001`
68
+
69
+ ## Telemetry
70
+
71
+ Telemetry file: `.taskchain_artifacts/docs-triage/runs/<run-id>/telemetry.jsonl` (append-only).
72
+
73
+ | Event | Trigger |
74
+ |-------|---------|
75
+ | `run-start` | Begin step 01 |
76
+ | `step-complete` | End of every step |
77
+ | `triage-dry-run-complete` | Dry-run summary printed |
78
+ | `triage-complete` | Pipeline finished, outputs written |
79
+
80
+ Required fields on every event: `event`, `run_id`, `timestamp`.
81
+
82
+ ## Execution reporting
83
+
84
+ After each completed step, emit a checkpoint:
85
+
86
+ ```text
87
+ **[step-name]** done | blocked | needs-input
88
+ Changed: <outputs written> or none
89
+ Validated: <checks passed> or none
90
+ Blockers: none | <explicit blocker>
91
+ ```
92
+
93
+ ### Never compressed
94
+
95
+ Always write in full:
96
+ - Flag count summary (contradictions / duplicates / stale-references)
97
+ - Any batches skipped due to API errors
98
+ - Phase 2 skip warning if graph coverage is low
99
+ - Hand-off instructions for `polaris docs review`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lsctech/polaris",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Polaris — standalone taskchain orchestration framework for governed AI agent workflows",
5
5
  "bin": {
6
6
  "polaris-cli": "dist/cli/index.js"
@@ -71,5 +71,8 @@
71
71
  "@types/node": "^25.9.1",
72
72
  "typescript": "^5.4.2",
73
73
  "vitest": "^3.0.5"
74
+ },
75
+ "optionalDependencies": {
76
+ "@anthropic-ai/sdk": "^0.104.1"
74
77
  }
75
78
  }