@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.
- package/dist/smartdocs-engine/doctrine.js +187 -3
- package/dist/smartdocs-engine/index.js +38 -5
- package/dist/smartdocs-engine/ingest.js +18 -2
- package/dist/smartdocs-engine/migrate.js +2 -0
- package/dist/smartdocs-engine/seed-instructions.js +26 -0
- package/dist/smartdocs-engine/triage.js +103 -25
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
(
|
|
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 →
|
|
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/
|
|
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/
|
|
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/
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|