@lsctech/polaris 0.4.7 → 0.4.8

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,7 @@ exports.CANDIDATE_MARKER = void 0;
4
4
  exports.parseFrontMatter = parseFrontMatter;
5
5
  exports.addCandidateGovernanceMetadata = addCandidateGovernanceMetadata;
6
6
  exports.stampIngestFrontMatter = stampIngestFrontMatter;
7
+ exports.backfillOkfType = backfillOkfType;
7
8
  exports.doctrineDraft = doctrineDraft;
8
9
  exports.doctrinePromote = doctrinePromote;
9
10
  exports.doctrineDeprecate = doctrineDeprecate;
@@ -90,6 +91,10 @@ function parseFrontMatter(content) {
90
91
  */
91
92
  function addCandidateGovernanceMetadata(content, docType) {
92
93
  const govDefaults = {
94
+ // OKF-conformant type field. Mirrors doc-type's value; added alongside
95
+ // (not instead of) doc-type since ingest.ts and requiredFields still
96
+ // read doc-type for internal classification/validation.
97
+ "type": docType,
93
98
  "doc-type": docType,
94
99
  "confidence": "0.0",
95
100
  "recommended-action": "hold",
@@ -184,6 +189,90 @@ function stampIngestFrontMatter(content, stamp) {
184
189
  const block = Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join("\n");
185
190
  return `---\n${block}\n---\n\n${normalized}`;
186
191
  }
192
+ /**
193
+ * Insert a single missing frontmatter field into markdown content, preserving
194
+ * an existing frontmatter block if present or creating a new one if not.
195
+ * No-op (returns content unchanged) if the key is already present.
196
+ */
197
+ function addMissingFrontMatterField(content, key, value) {
198
+ const normalized = content.replace(/\r\n/g, "\n");
199
+ if (normalized.startsWith("---\n")) {
200
+ const end = normalized.indexOf("\n---", 4);
201
+ if (end !== -1) {
202
+ const frontMatter = normalized.slice(4, end);
203
+ const afterFrontMatter = normalized.slice(end + 4);
204
+ const hasKey = frontMatter
205
+ .split("\n")
206
+ .filter((l) => l.includes(":"))
207
+ .some((l) => l.slice(0, l.indexOf(":")).trim().toLowerCase() === key.toLowerCase());
208
+ if (hasKey)
209
+ return normalized;
210
+ return `---\n${frontMatter}\n${key}: ${value}\n---${afterFrontMatter}`;
211
+ }
212
+ }
213
+ return `---\n${key}: ${value}\n---\n\n${normalized}`;
214
+ }
215
+ /**
216
+ * Retrofit existing SmartDocs with an OKF-conformant `type` frontmatter field.
217
+ *
218
+ * Walks every `.md` file under `smartdocs/`. For each file already missing a
219
+ * `type` key: derives the value from `kind` if present, else `doc-type` if
220
+ * present, else defaults to `"raw"`. Files that already declare `type` are
221
+ * left untouched and reported in `skipped`.
222
+ *
223
+ * This is the retroactive counterpart to `addCandidateGovernanceMetadata`,
224
+ * which stamps `type` on newly-governed candidate docs going forward — this
225
+ * function backfills docs that predate that change (or were authored by
226
+ * hand without a `type` field at all).
227
+ *
228
+ * @param repoRoot - Repository root; files are read from and written to
229
+ * `<repoRoot>/smartdocs/**\/*.md`.
230
+ * @param options.dryRun - When true, computes the result without writing
231
+ * any files.
232
+ */
233
+ function backfillOkfType(repoRoot, options = {}) {
234
+ const smartdocsDir = (0, node_path_1.join)(repoRoot, "smartdocs");
235
+ const result = { updated: [], skipped: [] };
236
+ function walk(dir) {
237
+ let entries;
238
+ try {
239
+ entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
240
+ }
241
+ catch {
242
+ return;
243
+ }
244
+ for (const entry of entries) {
245
+ const full = (0, node_path_1.join)(dir, entry.name);
246
+ if (entry.isDirectory()) {
247
+ walk(full);
248
+ }
249
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
250
+ processFile(full);
251
+ }
252
+ }
253
+ }
254
+ function processFile(filePath) {
255
+ let content;
256
+ try {
257
+ content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
258
+ }
259
+ catch {
260
+ return;
261
+ }
262
+ const fm = parseFrontMatter(content);
263
+ if (fm["type"]) {
264
+ result.skipped.push(filePath);
265
+ return;
266
+ }
267
+ const derivedType = fm.kind ?? fm["doc-type"] ?? "raw";
268
+ if (!options.dryRun) {
269
+ (0, node_fs_1.writeFileSync)(filePath, addMissingFrontMatterField(content, "type", derivedType), "utf-8");
270
+ }
271
+ result.updated.push({ path: filePath, type: derivedType });
272
+ }
273
+ walk(smartdocsDir);
274
+ return result;
275
+ }
187
276
  function appendLifecycle(lifecyclePath, event) {
188
277
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(lifecyclePath), { recursive: true });
189
278
  (0, node_fs_1.appendFileSync)(lifecyclePath, JSON.stringify(event) + "\n", "utf-8");
@@ -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
@@ -11,6 +11,7 @@ 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
+ "SUMMARY.md",
14
15
  "CHANGELOG.md",
15
16
  "LICENSE.md",
16
17
  "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.8",
4
4
  "description": "Polaris — standalone taskchain orchestration framework for governed AI agent workflows",
5
5
  "bin": {
6
6
  "polaris": "dist/cli/index.js",