@lsctech/polaris 0.4.7 → 0.4.9

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.
@@ -4,6 +4,8 @@ exports.CANDIDATE_MARKER = void 0;
4
4
  exports.parseFrontMatter = parseFrontMatter;
5
5
  exports.addCandidateGovernanceMetadata = addCandidateGovernanceMetadata;
6
6
  exports.stampIngestFrontMatter = stampIngestFrontMatter;
7
+ exports.deriveTypeFromDirectory = deriveTypeFromDirectory;
8
+ exports.backfillOkfType = backfillOkfType;
7
9
  exports.doctrineDraft = doctrineDraft;
8
10
  exports.doctrinePromote = doctrinePromote;
9
11
  exports.doctrineDeprecate = doctrineDeprecate;
@@ -90,6 +92,10 @@ function parseFrontMatter(content) {
90
92
  */
91
93
  function addCandidateGovernanceMetadata(content, docType) {
92
94
  const govDefaults = {
95
+ // OKF-conformant type field. Mirrors doc-type's value; added alongside
96
+ // (not instead of) doc-type since ingest.ts and requiredFields still
97
+ // read doc-type for internal classification/validation.
98
+ "type": docType,
93
99
  "doc-type": docType,
94
100
  "confidence": "0.0",
95
101
  "recommended-action": "hold",
@@ -184,6 +190,180 @@ function stampIngestFrontMatter(content, stamp) {
184
190
  const block = Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join("\n");
185
191
  return `---\n${block}\n---\n\n${normalized}`;
186
192
  }
193
+ /**
194
+ * Insert a single missing frontmatter field into markdown content, preserving
195
+ * an existing frontmatter block if present or creating a new one if not.
196
+ * No-op (returns content unchanged) if the key is already present.
197
+ */
198
+ function addMissingFrontMatterField(content, key, value) {
199
+ const normalized = content.replace(/\r\n/g, "\n");
200
+ if (normalized.startsWith("---\n")) {
201
+ const end = normalized.indexOf("\n---", 4);
202
+ if (end !== -1) {
203
+ const frontMatter = normalized.slice(4, end);
204
+ const afterFrontMatter = normalized.slice(end + 4);
205
+ const hasKey = frontMatter
206
+ .split("\n")
207
+ .filter((l) => l.includes(":"))
208
+ .some((l) => l.slice(0, l.indexOf(":")).trim().toLowerCase() === key.toLowerCase());
209
+ if (hasKey)
210
+ return normalized;
211
+ return `---\n${frontMatter}\n${key}: ${value}\n---${afterFrontMatter}`;
212
+ }
213
+ }
214
+ return `---\n${key}: ${value}\n---\n\n${normalized}`;
215
+ }
216
+ /**
217
+ * Sets a frontmatter field to a new value, replacing it if present or adding
218
+ * it if absent. Unlike `addMissingFrontMatterField`, this always applies the
219
+ * given value — used when a lifecycle transition (promote/deprecate) must
220
+ * make `type` agree with the doc's new directory tier, overriding whatever
221
+ * value it carried in its previous tier.
222
+ */
223
+ function setFrontMatterField(content, key, value) {
224
+ const normalized = content.replace(/\r\n/g, "\n");
225
+ if (normalized.startsWith("---\n")) {
226
+ const end = normalized.indexOf("\n---", 4);
227
+ if (end !== -1) {
228
+ const frontMatter = normalized.slice(4, end);
229
+ const afterFrontMatter = normalized.slice(end + 4);
230
+ const lowerKey = key.toLowerCase();
231
+ let found = false;
232
+ const newLines = frontMatter.split("\n").map((l) => {
233
+ const colonIdx = l.indexOf(":");
234
+ if (colonIdx !== -1 && l.slice(0, colonIdx).trim().toLowerCase() === lowerKey) {
235
+ found = true;
236
+ return `${key}: ${value}`;
237
+ }
238
+ return l;
239
+ });
240
+ if (found) {
241
+ return `---\n${newLines.join("\n")}\n---${afterFrontMatter}`;
242
+ }
243
+ return `---\n${frontMatter}\n${key}: ${value}\n---${afterFrontMatter}`;
244
+ }
245
+ }
246
+ return `---\n${key}: ${value}\n---\n\n${normalized}`;
247
+ }
248
+ /**
249
+ * Directory-tier defaults for `type`, keyed by the first two path segments
250
+ * relative to `smartdocs/`. Mirrors the lifecycle-tier vocabulary already
251
+ * used by `ingest.ts`'s `DocsClassification`, since directory position — not
252
+ * frontmatter — is the authoritative signal for a doc's lifecycle state
253
+ * (docs-authority-model.md "Key rule"). Falling back to a literal `"raw"`
254
+ * for every file regardless of directory (the prior behavior) let a doc
255
+ * sitting in `doctrine/active/` end up with `type: raw`, contradicting its
256
+ * own directory-encoded authority tier.
257
+ */
258
+ const DIRECTORY_TYPE_DEFAULTS = {
259
+ "raw": "raw",
260
+ "doctrine/active": "doctrine",
261
+ "doctrine/candidate": "doctrine-candidate",
262
+ "doctrine/deprecated": "doctrine-deprecated",
263
+ "doctrine": "doctrine",
264
+ "specs/raw": "raw",
265
+ "specs/active": "spec",
266
+ "specs/implemented": "spec-implemented",
267
+ "specs/superseded": "spec-superseded",
268
+ "specs/archive": "spec-archive",
269
+ "specs": "spec",
270
+ "audits/findings": "audit-finding",
271
+ "audits/resolved": "audit-resolved",
272
+ "audits": "audit",
273
+ "decisions": "decision",
274
+ "architecture": "architecture",
275
+ "integrations": "integration",
276
+ "medic": "medic",
277
+ "runtime/run-reports": "run-report",
278
+ "runtime/summaries": "runtime-summary",
279
+ "runtime": "runtime",
280
+ };
281
+ /**
282
+ * Derives a directory-tier-appropriate `type` default for a SmartDocs file
283
+ * that has neither `kind` nor `doc-type` set. `relPath` is relative to
284
+ * `smartdocs/` (forward-slash separated). Reserved front-door filenames
285
+ * (`POLARIS.md`, `SUMMARY.md`, `index.md`) get `"index"` regardless of tier,
286
+ * since they're navigational scaffolding, not tier content.
287
+ */
288
+ function deriveTypeFromDirectory(relPath) {
289
+ const name = (0, node_path_1.basename)(relPath);
290
+ if (name === "POLARIS.md" || name === "SUMMARY.md" || name === "index.md") {
291
+ return "index";
292
+ }
293
+ const segments = relPath.split("/").slice(0, -1);
294
+ if (segments.length >= 2) {
295
+ const twoLevel = `${segments[0]}/${segments[1]}`;
296
+ if (DIRECTORY_TYPE_DEFAULTS[twoLevel])
297
+ return DIRECTORY_TYPE_DEFAULTS[twoLevel];
298
+ }
299
+ if (segments.length >= 1 && DIRECTORY_TYPE_DEFAULTS[segments[0]]) {
300
+ return DIRECTORY_TYPE_DEFAULTS[segments[0]];
301
+ }
302
+ return "raw";
303
+ }
304
+ /**
305
+ * Retrofit existing SmartDocs with an OKF-conformant `type` frontmatter field.
306
+ *
307
+ * Walks every `.md` file under `smartdocs/`. For each file already missing a
308
+ * `type` key: derives the value from `kind` if present, else `doc-type` if
309
+ * present, else derives a directory-tier-appropriate default via
310
+ * `deriveTypeFromDirectory` (see that function for the tier mapping). Files
311
+ * that already declare `type` are left untouched and reported in `skipped`.
312
+ *
313
+ * This is the retroactive counterpart to `addCandidateGovernanceMetadata`,
314
+ * which stamps `type` on newly-governed candidate docs going forward — this
315
+ * function backfills docs that predate that change (or were authored by
316
+ * hand without a `type` field at all).
317
+ *
318
+ * @param repoRoot - Repository root; files are read from and written to
319
+ * `<repoRoot>/smartdocs/**\/*.md`.
320
+ * @param options.dryRun - When true, computes the result without writing
321
+ * any files.
322
+ */
323
+ function backfillOkfType(repoRoot, options = {}) {
324
+ const smartdocsDir = (0, node_path_1.join)(repoRoot, "smartdocs");
325
+ const result = { updated: [], skipped: [] };
326
+ function walk(dir) {
327
+ let entries;
328
+ try {
329
+ entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
330
+ }
331
+ catch {
332
+ return;
333
+ }
334
+ for (const entry of entries) {
335
+ const full = (0, node_path_1.join)(dir, entry.name);
336
+ if (entry.isDirectory()) {
337
+ walk(full);
338
+ }
339
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
340
+ processFile(full);
341
+ }
342
+ }
343
+ }
344
+ function processFile(filePath) {
345
+ let content;
346
+ try {
347
+ content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
348
+ }
349
+ catch {
350
+ return;
351
+ }
352
+ const fm = parseFrontMatter(content);
353
+ if (fm["type"]) {
354
+ result.skipped.push(filePath);
355
+ return;
356
+ }
357
+ const relPath = (0, node_path_1.relative)(smartdocsDir, filePath).replace(/\\/g, "/");
358
+ const derivedType = fm.kind ?? fm["doc-type"] ?? deriveTypeFromDirectory(relPath);
359
+ if (!options.dryRun) {
360
+ (0, node_fs_1.writeFileSync)(filePath, addMissingFrontMatterField(content, "type", derivedType), "utf-8");
361
+ }
362
+ result.updated.push({ path: filePath, type: derivedType });
363
+ }
364
+ walk(smartdocsDir);
365
+ return result;
366
+ }
187
367
  function appendLifecycle(lifecyclePath, event) {
188
368
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(lifecyclePath), { recursive: true });
189
369
  (0, node_fs_1.appendFileSync)(lifecyclePath, JSON.stringify(event) + "\n", "utf-8");
@@ -348,7 +528,8 @@ function doctrinePromote(path, options) {
348
528
  if ((0, node_fs_1.existsSync)(destination)) {
349
529
  throw new Error(`Destination already exists: ${destination}`);
350
530
  }
351
- const activeContent = content.replace(`${exports.CANDIDATE_MARKER}\n`, "").replace(exports.CANDIDATE_MARKER, "");
531
+ const strippedContent = content.replace(`${exports.CANDIDATE_MARKER}\n`, "").replace(exports.CANDIDATE_MARKER, "");
532
+ const activeContent = setFrontMatterField(strippedContent, "type", "doctrine");
352
533
  (0, node_fs_1.writeFileSync)(destination, activeContent, "utf-8");
353
534
  (0, node_fs_1.unlinkSync)(source);
354
535
  // Move co-located provenance sidecar if present
@@ -412,7 +593,8 @@ function doctrineDeprecate(path, options) {
412
593
  }
413
594
  const content = (0, node_fs_1.readFileSync)(source, "utf-8");
414
595
  const deprecatedAt = new Date().toISOString();
415
- const deprecatedContent = `<!-- polaris:doctrine-deprecated deprecatedAt="${deprecatedAt}" runId="${runId}" -->\n${content}`;
596
+ const retypedContent = setFrontMatterField(content, "type", "doctrine-deprecated");
597
+ const deprecatedContent = `<!-- polaris:doctrine-deprecated deprecatedAt="${deprecatedAt}" runId="${runId}" -->\n${retypedContent}`;
416
598
  (0, node_fs_1.writeFileSync)(destination, deprecatedContent, "utf-8");
417
599
  (0, node_fs_1.unlinkSync)(source);
418
600
  // Move co-located provenance sidecar if present
@@ -745,7 +927,9 @@ function specPromote(path, options) {
745
927
  if ((0, node_fs_1.existsSync)(destination)) {
746
928
  throw new Error(`Destination already exists: ${destination}`);
747
929
  }
748
- (0, node_fs_1.renameSync)(source, destination);
930
+ const retypedContent = setFrontMatterField(content, "type", "spec");
931
+ (0, node_fs_1.writeFileSync)(destination, retypedContent, "utf-8");
932
+ (0, node_fs_1.unlinkSync)(source);
749
933
  if ((0, node_fs_1.existsSync)(provenanceSrcPath)) {
750
934
  (0, node_fs_1.renameSync)(provenanceSrcPath, destination.replace(/\.md$/, ".provenance.json"));
751
935
  }
@@ -434,9 +434,29 @@ function createDocsCommand(options = {}) {
434
434
  console.log(`skipped (draft exists): ${targetPath}/index.md`);
435
435
  }
436
436
  });
437
+ docs
438
+ .command("backfill-type")
439
+ .description("Add OKF-conformant `type` frontmatter to existing smartdocs/ files that are missing it, " +
440
+ "deriving the value from `kind` or `doc-type` when present, else defaulting to \"raw\".")
441
+ .option("-r, --repo-root <path>", "Repository root", defaultRepoRoot)
442
+ .option("--dry-run", "Print what would be written without writing files")
443
+ .action((options) => {
444
+ try {
445
+ const { updated, skipped } = (0, doctrine_js_1.backfillOkfType)(options.repoRoot, { dryRun: options.dryRun });
446
+ for (const { path, type } of updated) {
447
+ console.log(`${options.dryRun ? "[dry-run] would add" : "added"} type: ${type} — ${path}`);
448
+ }
449
+ console.log(`Done. ${updated.length} updated, ${skipped.length} already had type.`);
450
+ }
451
+ catch (err) {
452
+ console.error(`polaris docs backfill-type: ${err instanceof Error ? err.message : String(err)}`);
453
+ process.exit(1);
454
+ }
455
+ });
437
456
  docs
438
457
  .command("reformat-okf")
439
- .description("Migrate existing smartdocs to OKF structure in one step: runs migrate → seed-index --all → seed-instructions --all. " +
458
+ .description("Migrate existing smartdocs to OKF structure in one step: runs migrate → seed-index --all → " +
459
+ "seed-instructions --all → backfill-type. " +
440
460
  "Agent instruction files (CLAUDE.md, AGENTS.md, etc.) are never touched. " +
441
461
  "Use --dry-run to preview changes before writing.")
442
462
  .option("--dry-run", "Preview what would change without writing any files")
@@ -446,7 +466,7 @@ function createDocsCommand(options = {}) {
446
466
  const repoRoot = options.repoRoot;
447
467
  const label = dryRun ? "[dry-run]" : "";
448
468
  // Step 1: migrate (moves scattered markdown to smartdocs/raw/)
449
- console.log(`${label ? label + " " : ""}Step 1/3: migrate`);
469
+ console.log(`${label ? label + " " : ""}Step 1/4: migrate`);
450
470
  try {
451
471
  const migrateResult = (0, migrate_js_1.migrateDocs)({ repoRoot, dryRun });
452
472
  (0, migrate_js_1.printMigrateResults)(migrateResult);
@@ -455,8 +475,8 @@ function createDocsCommand(options = {}) {
455
475
  console.error(`reformat-okf: migrate failed — ${err instanceof Error ? err.message : String(err)}`);
456
476
  process.exit(1);
457
477
  }
458
- // Step 2: seed-index --all (ensures index.md with okf_version frontmatter in every smartdocs dir)
459
- console.log(`\n${label ? label + " " : ""}Step 2/3: seed-index --all`);
478
+ // Step 2: seed-index --all (ensures index.md with okf_version + type frontmatter in every smartdocs dir)
479
+ console.log(`\n${label ? label + " " : ""}Step 2/4: seed-index --all`);
460
480
  try {
461
481
  const { written, skippedExists, skippedDraft } = (0, seed_instructions_js_1.seedIndexAll)(repoRoot, { dryRun });
462
482
  printSeedAllResult("index.md", dryRun, { written, skippedExists, skippedDraft }, { leadingBlankLine: false });
@@ -466,7 +486,7 @@ function createDocsCommand(options = {}) {
466
486
  process.exit(1);
467
487
  }
468
488
  // Step 3: seed-instructions --all (ensures POLARIS.md drafts; never touches CLAUDE.md/AGENTS.md)
469
- console.log(`\n${label ? label + " " : ""}Step 3/3: seed-instructions --all`);
489
+ console.log(`\n${label ? label + " " : ""}Step 3/4: seed-instructions --all`);
470
490
  try {
471
491
  const { written, skippedExists, skippedDraft, skippedIneligible, skippedRoot } = (0, seed_instructions_js_1.seedInstructionsAll)(repoRoot, { dryRun });
472
492
  const rootCount = skippedRoot ? 1 : 0;
@@ -476,6 +496,19 @@ function createDocsCommand(options = {}) {
476
496
  console.error(`reformat-okf: seed-instructions failed — ${err instanceof Error ? err.message : String(err)}`);
477
497
  process.exit(1);
478
498
  }
499
+ // Step 4: backfill-type (existing docs missing `type` get it derived from kind/doc-type, or "raw")
500
+ console.log(`\n${label ? label + " " : ""}Step 4/4: backfill-type`);
501
+ try {
502
+ const { updated, skipped } = (0, doctrine_js_1.backfillOkfType)(repoRoot, { dryRun });
503
+ for (const { path, type } of updated) {
504
+ console.log(`${dryRun ? "[dry-run] would add" : "added"} type: ${type} — ${path}`);
505
+ }
506
+ console.log(`Done. ${updated.length} updated, ${skipped.length} already had type.`);
507
+ }
508
+ catch (err) {
509
+ console.error(`reformat-okf: backfill-type failed — ${err instanceof Error ? err.message : String(err)}`);
510
+ process.exit(1);
511
+ }
479
512
  console.log(`\nreformat-okf complete.${dryRun ? " (dry-run — no files written)" : ""}`);
480
513
  });
481
514
  docs
@@ -456,8 +456,14 @@ function ingestDocs(files, options) {
456
456
  };
457
457
  const routingDecision = (0, index_js_1.route)(classificationResult, thresholds);
458
458
  const proposedDest = (0, node_path_1.relative)(repoRoot, (0, node_path_1.join)((0, node_path_1.resolve)(repoRoot, TARGET_DIRS[docClassification]), (0, node_path_1.basename)(absSource))).replace(/\\/g, "/");
459
+ // A prior human/agent review decision or a scoped --approve-authority call
460
+ // overrides an otherwise-review-required outcome so the placement it
461
+ // decided on actually happens.
462
+ const priorDecision = priorQueue.find((p) => p.sourcePath === relSource)?.reviewDecision;
463
+ const forcedApprove = routingDecision.outcome === "review-required" &&
464
+ (priorDecision === "approve" || Boolean(options.approveAuthority));
459
465
  // review-required: leave in raw/, emit packet, skip move
460
- if (routingDecision.outcome === "review-required") {
466
+ if (routingDecision.outcome === "review-required" && !forcedApprove) {
461
467
  const packet = {
462
468
  ...routingDecision.reviewPacket,
463
469
  sourcePath: relSource,
@@ -504,6 +510,16 @@ function ingestDocs(files, options) {
504
510
  linked_map_area: linkedMapArea,
505
511
  cluster_id: clusterId,
506
512
  });
513
+ if (forcedApprove) {
514
+ emitTelemetry(telPath, runId, {
515
+ event: "docs-ingest-approved-override",
516
+ file: relSource,
517
+ classification: docClassification,
518
+ destination: relDestination,
519
+ reason: priorDecision === "approve" ? "prior-review-decision" : "approve-authority-flag",
520
+ cluster_id: clusterId,
521
+ });
522
+ }
507
523
  if (!options.dryRun) {
508
524
  if ((0, node_path_1.resolve)(absSource) !== (0, node_path_1.resolve)(destination)) {
509
525
  (0, node_fs_1.renameSync)(absSource, destination);
@@ -583,7 +599,7 @@ function ingestDocs(files, options) {
583
599
  dryRun: Boolean(options.dryRun),
584
600
  nearestSummary,
585
601
  summaryDeltaWarranted: summaryDelta.updateWarranted,
586
- routingDecision: routingDecision.outcome,
602
+ routingDecision: forcedApprove ? "approved-override" : routingDecision.outcome,
587
603
  reviewPacket: routingDecision.reviewPacket
588
604
  ? { ...routingDecision.reviewPacket, sourcePath: relSource, proposedDestination: relDestination, conflicts: [] }
589
605
  : undefined,
@@ -11,6 +11,8 @@ const smartdoc_ignore_js_1 = require("./smartdoc-ignore.js");
11
11
  const ALLOWED_BASENAMES = new Set([
12
12
  "README.md",
13
13
  "POLARIS.md",
14
+ "POLARIS_RULES.md",
15
+ "SUMMARY.md",
14
16
  "CHANGELOG.md",
15
17
  "LICENSE.md",
16
18
  "AGENTS.md",
@@ -196,6 +196,18 @@ function listConceptFiles(dir) {
196
196
  return [];
197
197
  }
198
198
  }
199
+ /** Immediate child subdirectories eligible for their own index.md (excludes hidden dirs and raw/). */
200
+ function listChildDirs(dir) {
201
+ try {
202
+ return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
203
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "raw")
204
+ .map((e) => e.name)
205
+ .sort();
206
+ }
207
+ catch {
208
+ return [];
209
+ }
210
+ }
199
211
  function conceptLabel(filePath) {
200
212
  try {
201
213
  const content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
@@ -232,6 +244,7 @@ function generateBundleRootIndex(repoRoot, allRoutes) {
232
244
  const lines = [
233
245
  "---",
234
246
  "okf_version: \"0.1\"",
247
+ "type: index",
235
248
  "---",
236
249
  "",
237
250
  exports.DRAFT_MARKER,
@@ -289,6 +302,7 @@ function generateDirectoryIndex(targetDir, repoRoot) {
289
302
  const lines = [
290
303
  "---",
291
304
  "okf_version: \"0.1\"",
305
+ "type: index",
292
306
  "---",
293
307
  "",
294
308
  exports.DRAFT_MARKER,
@@ -309,6 +323,18 @@ function generateDirectoryIndex(targetDir, repoRoot) {
309
323
  lines.push("<!-- No concept files in this directory yet. -->");
310
324
  }
311
325
  lines.push("");
326
+ const childDirs = listChildDirs(absDir);
327
+ if (childDirs.length > 0) {
328
+ lines.push("## Subdirectories", "");
329
+ for (const child of childDirs) {
330
+ // Always link to the child's index.md rather than checking existsSync: generateDirectoryIndex
331
+ // only ever runs under smartdocs/ (enforced by seedIndex's own path guard), where every
332
+ // eligible subdirectory gets its own index.md — and seedIndexAll writes parents before
333
+ // children in the same pass, so existsSync would be write-order-dependent here.
334
+ lines.push(`- [${child}/](${child}/index.md)`);
335
+ }
336
+ lines.push("");
337
+ }
312
338
  return lines.join("\n");
313
339
  }
314
340
  function seedInstructions(targetPath, repoRoot, opts = {}) {
@@ -10,10 +10,13 @@ exports.deleteTriageCheckpoint = deleteTriageCheckpoint;
10
10
  exports.writeTriageQueue = writeTriageQueue;
11
11
  exports.resolveTriageModel = resolveTriageModel;
12
12
  exports.runBatchComparison = runBatchComparison;
13
+ exports.resolveLibrarianProviderConfig = resolveLibrarianProviderConfig;
13
14
  exports.runGraphCheck = runGraphCheck;
14
15
  exports.runTriage = runTriage;
15
16
  const node_fs_1 = require("node:fs");
17
+ const node_child_process_1 = require("node:child_process");
16
18
  const node_path_1 = require("node:path");
19
+ const index_js_1 = require("../config/index.js");
17
20
  // ---------------------------------------------------------------------------
18
21
  // clusterCandidates
19
22
  // ---------------------------------------------------------------------------
@@ -307,7 +310,7 @@ function resolveTriageModel(configured) {
307
310
  "claude-haiku-4-5-20251001");
308
311
  }
309
312
  async function runBatchComparison(candidates, canonicals, options) {
310
- const client = options.llmClient ?? (await buildDefaultLlmClient());
313
+ const client = options.llmClient ?? (await buildDefaultLlmClient(options.repoRoot ?? process.cwd()));
311
314
  for (let attempt = 0; attempt < 2; attempt++) {
312
315
  try {
313
316
  return await client.compare(candidates, canonicals, options.model);
@@ -318,8 +321,103 @@ async function runBatchComparison(candidates, canonicals, options) {
318
321
  }
319
322
  return [];
320
323
  }
321
- async function buildDefaultLlmClient() {
324
+ function buildComparisonPrompt(candidates, canonicals) {
325
+ const candidateSummaries = candidates
326
+ .map((c, i) => `[${i}] ${(0, node_path_1.basename)(c.path)} (${c.type || "unknown type"}) tags: ${c.tags.join(", ") || "none"}`)
327
+ .join("\n");
328
+ const canonicalSummaries = canonicals
329
+ .map((c) => `- ${(0, node_path_1.basename)(c.path)} (${c.type || "unknown type"}) tags: ${c.tags.join(", ") || "none"}`)
330
+ .join("\n");
331
+ return [
332
+ "You are comparing candidate documentation files against canonical documentation.",
333
+ "Identify any candidates that CONTRADICT or DUPLICATE a canonical.",
334
+ 'Return ONLY a JSON array. If no issues found, return [].',
335
+ 'Each item must be: { "candidatePath": string, "flagType": "contradiction" | "duplicate", "canonicalPath"?: string, "reason": string }',
336
+ "",
337
+ "CANDIDATES:",
338
+ candidateSummaries,
339
+ "",
340
+ "CANONICALS:",
341
+ canonicalSummaries,
342
+ "",
343
+ "Respond with JSON only. No prose.",
344
+ ].join("\n");
345
+ }
346
+ function parseFlagsFromText(text) {
347
+ const cleaned = text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "").trim();
348
+ return JSON.parse(cleaned);
349
+ }
350
+ /** Expand $VAR and ${VAR} references from process.env. */
351
+ function expandEnvVars(str) {
352
+ return str
353
+ .replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (_, name) => process.env[name] ?? "")
354
+ .replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, name) => process.env[name] ?? "");
355
+ }
356
+ /** Substitute {{key}} template variables. Unknown keys are left as-is. */
357
+ function substituteTemplates(str, vars) {
358
+ return str.replace(/\{\{([^}]+)\}\}/g, (original, key) => vars[key.trim()] ?? original);
359
+ }
360
+ /**
361
+ * Resolve the configured CLI provider for triage's librarian-role LLM calls, per
362
+ * polaris.config.json's execution.providerPolicy.librarian / execution.providers —
363
+ * the same config the terminal-cli dispatch adapter reads for Foreman/Worker
364
+ * sessions. Returns undefined if no usable provider is configured, in which case
365
+ * callers fall back to a raw API-key-based client.
366
+ */
367
+ function resolveLibrarianProviderConfig(repoRoot) {
368
+ try {
369
+ const config = (0, index_js_1.loadConfig)(repoRoot);
370
+ const providers = config.execution?.providers ?? {};
371
+ const policyNames = config.execution?.providerPolicy?.librarian?.providers ?? [];
372
+ for (const name of policyNames) {
373
+ const cfg = providers[name];
374
+ if (cfg)
375
+ return cfg;
376
+ }
377
+ // Fall back to a directly-named "claude" provider if no policy entry resolved.
378
+ return providers["claude"];
379
+ }
380
+ catch {
381
+ return undefined;
382
+ }
383
+ }
384
+ /**
385
+ * Build an LlmClient that shells out to the configured CLI provider (e.g. `claude
386
+ * --print`), the same mechanism the terminal-cli execution adapter uses for
387
+ * Foreman/Worker dispatch. This authenticates via the CLI's own session (OAuth),
388
+ * not a raw ANTHROPIC_API_KEY.
389
+ */
390
+ function buildCliLlmClient(providerCfg) {
391
+ return {
392
+ async compare(candidates, canonicals) {
393
+ const prompt = buildComparisonPrompt(candidates, canonicals);
394
+ const templateVars = { worker_prompt: prompt };
395
+ const command = substituteTemplates(expandEnvVars(providerCfg.command), templateVars);
396
+ if (!command.trim()) {
397
+ throw new Error(`Provider command "${providerCfg.command}" expanded to an empty string — likely an unset environment variable.`);
398
+ }
399
+ const args = (providerCfg.args ?? []).map((arg) => substituteTemplates(expandEnvVars(arg), templateVars));
400
+ const stdout = (0, node_child_process_1.execFileSync)(command, args, {
401
+ encoding: "utf-8",
402
+ maxBuffer: 10 * 1024 * 1024,
403
+ timeout: 300000,
404
+ });
405
+ return parseFlagsFromText(stdout);
406
+ },
407
+ };
408
+ }
409
+ async function buildDefaultLlmClient(repoRoot) {
410
+ const cliProviderCfg = resolveLibrarianProviderConfig(repoRoot);
411
+ if (cliProviderCfg) {
412
+ return buildCliLlmClient(cliProviderCfg);
413
+ }
322
414
  const apiKey = process.env["ANTHROPIC_API_KEY"] ?? "";
415
+ if (!apiKey) {
416
+ throw new Error("No CLI provider configured for the librarian role (execution.providerPolicy.librarian / " +
417
+ "execution.providers in polaris.config.json) and ANTHROPIC_API_KEY is not set. " +
418
+ "Configure a librarian provider (preferred — uses the CLI's own session auth) or set " +
419
+ "ANTHROPIC_API_KEY as a fallback.");
420
+ }
323
421
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
422
  // @ts-ignore: @anthropic-ai/sdk may not be installed as a dependency
325
423
  const mod = await import("@anthropic-ai/sdk");
@@ -328,26 +426,7 @@ async function buildDefaultLlmClient() {
328
426
  const sdkClient = new Anthropic({ apiKey });
329
427
  return {
330
428
  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");
429
+ const prompt = buildComparisonPrompt(candidates, canonicals);
351
430
  const response = await sdkClient.messages.create({
352
431
  model,
353
432
  max_tokens: 1024,
@@ -357,8 +436,7 @@ async function buildDefaultLlmClient() {
357
436
  .filter((b) => b.type === "text")
358
437
  .map((b) => b.text)
359
438
  .join("");
360
- const cleaned = text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "").trim();
361
- return JSON.parse(cleaned);
439
+ return parseFlagsFromText(text);
362
440
  },
363
441
  };
364
442
  }
@@ -436,7 +514,7 @@ async function runTriage(options) {
436
514
  output(` Comparing cluster "${clusterName}" (${cluster.candidates.length} candidates, ${cluster.canonicals.length} canonicals)...`);
437
515
  for (let i = 0; i < cluster.candidates.length; i += batchSize) {
438
516
  const batch = cluster.candidates.slice(i, i + batchSize);
439
- const flags = await runBatchComparison(batch, cluster.canonicals, { model, llmClient });
517
+ const flags = await runBatchComparison(batch, cluster.canonicals, { model, llmClient, repoRoot });
440
518
  accumulatedFlags.push(...flags);
441
519
  }
442
520
  completedClusters.add(clusterName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lsctech/polaris",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Polaris — standalone taskchain orchestration framework for governed AI agent workflows",
5
5
  "bin": {
6
6
  "polaris": "dist/cli/index.js",