@lsctech/polaris 0.4.4 → 0.4.6

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.
@@ -7,6 +7,8 @@ exports.stampIngestFrontMatter = stampIngestFrontMatter;
7
7
  exports.doctrineDraft = doctrineDraft;
8
8
  exports.doctrinePromote = doctrinePromote;
9
9
  exports.doctrineDeprecate = doctrineDeprecate;
10
+ exports.detectDoctrineSupersession = detectDoctrineSupersession;
11
+ exports.checkSmartDocsLinks = checkSmartDocsLinks;
10
12
  exports.specPromote = specPromote;
11
13
  exports.migrateProvenance = migrateProvenance;
12
14
  const node_fs_1 = require("node:fs");
@@ -186,6 +188,59 @@ function appendLifecycle(lifecyclePath, event) {
186
188
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(lifecyclePath), { recursive: true });
187
189
  (0, node_fs_1.appendFileSync)(lifecyclePath, JSON.stringify(event) + "\n", "utf-8");
188
190
  }
191
+ const DIRECTORY_LOG_HEADER = "# Directory Update Log";
192
+ /**
193
+ * Append a dated prose entry to a per-directory log.md file, following the OKF convention:
194
+ * date-grouped YYYY-MM-DD headings, newest-first, with a bold verb prefix.
195
+ *
196
+ * Creates the file with the canonical header if it does not yet exist. If the
197
+ * newest heading already matches today's date, the entry is inserted under that
198
+ * heading; otherwise a new today's heading is prepended before the existing entries.
199
+ *
200
+ * This function is best-effort: failures during read/write are caught and logged
201
+ * as warnings but do not propagate to the caller, ensuring log.md update failures
202
+ * do not block directory transitions.
203
+ *
204
+ * @param directory - Directory that contains (or will contain) log.md
205
+ * @param verb - Lifecycle verb rendered as the bold prefix (Draft, Promote, Deprecate)
206
+ * @param reason - Human-readable prose entry for this change
207
+ */
208
+ function logDirectoryChange(directory, verb, reason) {
209
+ try {
210
+ const logPath = (0, node_path_1.join)(directory, "log.md");
211
+ const today = new Date().toISOString().slice(0, 10);
212
+ const entry = `**${verb}**: ${reason}`;
213
+ let content = (0, node_fs_1.existsSync)(logPath) ? (0, node_fs_1.readFileSync)(logPath, "utf-8") : "";
214
+ content = content.replace(/\r\n/g, "\n");
215
+ const todayHeading = `## ${today}`;
216
+ // Use anchored regex to match heading at start of line, not substring in prose.
217
+ const firstHeadingMatch = content.match(/^## (\d{4}-\d{2}-\d{2})/m);
218
+ if (firstHeadingMatch && firstHeadingMatch[1] === today) {
219
+ // Find the actual heading line position using anchored search.
220
+ const headingMatch = content.match(new RegExp(`^${todayHeading}`, 'm'));
221
+ if (headingMatch && headingMatch.index !== undefined) {
222
+ const headingIndex = headingMatch.index;
223
+ const afterHeading = headingIndex + todayHeading.length;
224
+ const newContent = `${content.slice(0, afterHeading)}\n${entry}${content.slice(afterHeading)}`;
225
+ (0, node_fs_1.writeFileSync)(logPath, newContent, "utf-8");
226
+ }
227
+ }
228
+ else if (content.trim().startsWith(DIRECTORY_LOG_HEADER)) {
229
+ const afterHeader = content.indexOf(DIRECTORY_LOG_HEADER) + DIRECTORY_LOG_HEADER.length;
230
+ const tail = content.slice(afterHeader).replace(/^\n*/, "\n\n");
231
+ const newContent = `${DIRECTORY_LOG_HEADER}\n\n${todayHeading}\n${entry}${tail}`;
232
+ (0, node_fs_1.writeFileSync)(logPath, newContent, "utf-8");
233
+ }
234
+ else {
235
+ const existing = content.trim() ? `${content}\n\n` : "";
236
+ (0, node_fs_1.writeFileSync)(logPath, `${DIRECTORY_LOG_HEADER}\n\n${todayHeading}\n${entry}\n${existing}`, "utf-8");
237
+ }
238
+ }
239
+ catch (err) {
240
+ // Best-effort: log warning but do not propagate failure.
241
+ console.warn(`[warn] Failed to update log.md in ${directory}: ${err instanceof Error ? err.message : String(err)}`);
242
+ }
243
+ }
189
244
  /**
190
245
  * Resolve a filesystem path against a repository root and return an absolute path.
191
246
  *
@@ -236,6 +291,8 @@ function doctrineDraft(path, options) {
236
291
  destination,
237
292
  timestamp: new Date().toISOString(),
238
293
  });
294
+ const reason = options.reason ?? `${(0, node_path_1.basename)(destination)} drafted to doctrine/candidate/`;
295
+ logDirectoryChange((0, node_path_1.dirname)(destination), "Draft", reason);
239
296
  return { source, destination, runId, lifecyclePath };
240
297
  }
241
298
  /**
@@ -322,6 +379,8 @@ function doctrinePromote(path, options) {
322
379
  destination,
323
380
  timestamp: new Date().toISOString(),
324
381
  });
382
+ const reason = options.reason ?? `${(0, node_path_1.basename)(destination)} promoted to doctrine/active/`;
383
+ logDirectoryChange((0, node_path_1.dirname)(destination), "Promote", reason);
325
384
  return { source, destination, runId, lifecyclePath };
326
385
  }
327
386
  /**
@@ -371,6 +430,8 @@ function doctrineDeprecate(path, options) {
371
430
  deprecated_at: deprecatedAt,
372
431
  timestamp: deprecatedAt,
373
432
  });
433
+ const reason = options.reason ?? `${(0, node_path_1.basename)(destination)} deprecated to doctrine/deprecated/`;
434
+ logDirectoryChange((0, node_path_1.dirname)(destination), "Deprecate", reason);
374
435
  return { source, destination, runId, lifecyclePath };
375
436
  }
376
437
  // ── Spec verb-keyword conflict detection ──────────────────────────────────────
@@ -390,6 +451,174 @@ function extractSpecKeywords(content, pattern) {
390
451
  }
391
452
  return result;
392
453
  }
454
+ /**
455
+ * Compute the Jaccard similarity between two keyword sets.
456
+ * Returns a value in [0, 1]; 0 means no overlap, 1 means identical.
457
+ */
458
+ function jaccardSimilarity(a, b) {
459
+ if (a.size === 0 && b.size === 0)
460
+ return 0;
461
+ let intersection = 0;
462
+ for (const kw of a) {
463
+ if (b.has(kw))
464
+ intersection++;
465
+ }
466
+ return intersection / (a.size + b.size - intersection);
467
+ }
468
+ /**
469
+ * Extract a combined keyword set from a document using both REQUIRES and PROHIBITS patterns.
470
+ * Reuses the existing extractSpecKeywords function — no second algorithm.
471
+ */
472
+ function extractAllKeywords(content) {
473
+ const requires = extractSpecKeywords(content, MODAL_REQUIRES);
474
+ const prohibits = extractSpecKeywords(content, MODAL_PROHIBITS);
475
+ return new Set([...requires, ...prohibits]);
476
+ }
477
+ // Jaccard threshold above which an active doc is considered a supersession candidate.
478
+ // 0.3 is deliberately permissive: better to surface an advisory than to miss it.
479
+ const SUPERSESSION_THRESHOLD = 0.3;
480
+ /**
481
+ * Detect whether an incoming doctrine-candidate document has high content overlap with
482
+ * any existing active doctrine document. When overlap exceeds the threshold, the active
483
+ * doc is returned as a suggested-supersession advisory conflict.
484
+ *
485
+ * This is report-only: it never writes to `supersedes`/`superseded_by` frontmatter.
486
+ * The caller (or the user via CLI) decides whether to act on the suggestion.
487
+ *
488
+ * @param candidatePath - Absolute or repo-relative path to the candidate document
489
+ * @param options - Doctrine options (must include `repoRoot`)
490
+ * @returns Array of SpecConflict entries with type "suggested-supersession"; empty when
491
+ * no active docs exist or no overlap exceeds the threshold
492
+ */
493
+ function detectDoctrineSupersession(candidatePath, options) {
494
+ const repoRoot = (0, node_path_1.resolve)(options.repoRoot);
495
+ const source = resolvePath(candidatePath, repoRoot);
496
+ if (!(0, node_fs_1.existsSync)(source))
497
+ return [];
498
+ let candidateContent;
499
+ try {
500
+ candidateContent = (0, node_fs_1.readFileSync)(source, "utf-8");
501
+ }
502
+ catch {
503
+ return [];
504
+ }
505
+ const candidateKeywords = extractAllKeywords(candidateContent);
506
+ // No keywords in candidate → no meaningful overlap can be computed
507
+ if (candidateKeywords.size === 0)
508
+ return [];
509
+ const activeDir = (0, node_path_1.resolve)(repoRoot, "smartdocs", "doctrine", "active");
510
+ if (!(0, node_fs_1.existsSync)(activeDir))
511
+ return [];
512
+ const conflicts = [];
513
+ let activeFiles;
514
+ try {
515
+ activeFiles = (0, node_fs_1.readdirSync)(activeDir).filter((f) => f.endsWith(".md"));
516
+ }
517
+ catch {
518
+ return [];
519
+ }
520
+ for (const file of activeFiles) {
521
+ let activeContent;
522
+ try {
523
+ activeContent = (0, node_fs_1.readFileSync)((0, node_path_1.join)(activeDir, file), "utf-8");
524
+ }
525
+ catch {
526
+ continue;
527
+ }
528
+ const activeKeywords = extractAllKeywords(activeContent);
529
+ if (activeKeywords.size === 0)
530
+ continue;
531
+ const score = jaccardSimilarity(candidateKeywords, activeKeywords);
532
+ if (score >= SUPERSESSION_THRESHOLD) {
533
+ conflicts.push({
534
+ type: "suggested-supersession",
535
+ conflictingFile: file,
536
+ detail: `candidate has ${Math.round(score * 100)}% keyword overlap with active doc "${file}" — consider adding supersedes: ${file.replace(/\.md$/, "")} to frontmatter`,
537
+ });
538
+ }
539
+ }
540
+ return conflicts;
541
+ }
542
+ // Regex to extract markdown links: [text](href)
543
+ const MD_LINK_RE = /\[([^\]]*)\]\(([^)]+)\)/g;
544
+ // Tiers that are subject to strict link checking (raw/ is permissive per OKF §5.3)
545
+ const STRICT_LINK_TIERS = ["candidate", "active"];
546
+ /**
547
+ * Determine whether a file path is in a strict-check tier (candidate/ or active/)
548
+ * under smartdocs/. Returns false for raw/ or anything outside smartdocs/.
549
+ */
550
+ function isStrictTier(filePath, repoRoot) {
551
+ const rel = (0, node_path_1.relative)((0, node_path_1.resolve)(repoRoot), (0, node_path_1.resolve)(filePath)).replace(/\\/g, "/");
552
+ if (!rel.startsWith("smartdocs/"))
553
+ return false;
554
+ return STRICT_LINK_TIERS.some((tier) => rel.includes(`/${tier}/`) || rel.includes(`/${tier}\\`));
555
+ }
556
+ /**
557
+ * Resolve a markdown link href found in `sourceFile` to an absolute filesystem path.
558
+ *
559
+ * Supports two forms:
560
+ * - Bundle-relative: `/smartdocs/...` — resolved from repoRoot
561
+ * - Relative: `./foo.md`, `../foo.md`, `foo.md` — resolved from the source file's directory
562
+ *
563
+ * Returns null if the href does not point into smartdocs/ (e.g. external URLs, src/ links).
564
+ */
565
+ function resolveSmartDocsLink(href, sourceFile, repoRoot) {
566
+ // Strip anchors
567
+ const bare = href.split("#")[0];
568
+ if (!bare.endsWith(".md"))
569
+ return null;
570
+ let abs;
571
+ if (bare.startsWith("/")) {
572
+ // Bundle-relative: treat / as repoRoot
573
+ abs = (0, node_path_1.join)((0, node_path_1.resolve)(repoRoot), bare.slice(1));
574
+ }
575
+ else if (bare.startsWith("http://") || bare.startsWith("https://")) {
576
+ return null;
577
+ }
578
+ else {
579
+ // Relative to source file's directory
580
+ abs = (0, node_path_1.join)((0, node_path_1.dirname)((0, node_path_1.resolve)(sourceFile)), bare);
581
+ }
582
+ // Only check links that land inside smartdocs/
583
+ const rel = (0, node_path_1.relative)((0, node_path_1.resolve)(repoRoot), abs).replace(/\\/g, "/");
584
+ if (!rel.startsWith("smartdocs/"))
585
+ return null;
586
+ return abs;
587
+ }
588
+ /**
589
+ * Check all markdown cross-links in a strict-tier (candidate/ or active/) SmartDoc.
590
+ *
591
+ * For each link whose href resolves into smartdocs/**, verifies the target exists.
592
+ * Missing targets are returned as SpecConflict entries with type "stale-assumption".
593
+ * Files from raw/ are never checked — pass isRaw=true or detect via filePath.
594
+ *
595
+ * @param filePath - Absolute path to the source document
596
+ * @param content - File content (already read by caller)
597
+ * @param repoRoot - Repository root directory
598
+ * @returns Array of stale-assumption conflicts for every broken smartdocs/ link
599
+ */
600
+ function checkSmartDocsLinks(filePath, content, repoRoot) {
601
+ if (!isStrictTier(filePath, repoRoot))
602
+ return [];
603
+ const conflicts = [];
604
+ const re = new RegExp(MD_LINK_RE.source, MD_LINK_RE.flags);
605
+ for (const match of content.matchAll(re)) {
606
+ const href = match[2];
607
+ if (!href)
608
+ continue;
609
+ const target = resolveSmartDocsLink(href, filePath, repoRoot);
610
+ if (target === null)
611
+ continue; // not a smartdocs/ link — skip
612
+ if (!(0, node_fs_1.existsSync)(target)) {
613
+ conflicts.push({
614
+ type: "stale-assumption",
615
+ conflictingFile: (0, node_path_1.relative)((0, node_path_1.resolve)(repoRoot), (0, node_path_1.resolve)(filePath)).replace(/\\/g, "/"),
616
+ detail: `broken link: "${href}" → target not found: ${(0, node_path_1.relative)((0, node_path_1.resolve)(repoRoot), target).replace(/\\/g, "/")}`,
617
+ });
618
+ }
619
+ }
620
+ return conflicts;
621
+ }
393
622
  /**
394
623
  * Promotes a spec file from smartdocs/raw/ into smartdocs/specs/active/.
395
624
  *
@@ -529,6 +758,8 @@ function specPromote(path, options) {
529
758
  approved: options.approve ?? false,
530
759
  timestamp: new Date().toISOString(),
531
760
  });
761
+ const reason = options.reason ?? `${(0, node_path_1.basename)(destination)} promoted to specs/active/`;
762
+ logDirectoryChange((0, node_path_1.dirname)(destination), "Promote", reason);
532
763
  return { source, destination, runId, lifecyclePath, conflicts, halted: false, report };
533
764
  }
534
765
  /**
@@ -15,6 +15,29 @@ const audit_js_1 = require("./audit.js");
15
15
  const triage_js_1 = require("./triage.js");
16
16
  const adapter_js_1 = require("../graph/store/adapter.js");
17
17
  const index_js_1 = require("../graph/query/index.js");
18
+ /**
19
+ * Prints the shared written/skipped-exists/skipped-draft report and summary line used by
20
+ * every `seed-*-all` command (seed-index, seed-instructions, seed-summary, reformat-okf).
21
+ *
22
+ * @param filename - The generated filename (e.g. "index.md") to report against each directory.
23
+ * @param dryRun - Whether this was a dry-run pass (controls the "would write" vs "written" label).
24
+ * @param result - The written/skippedExists/skippedDraft arrays from a seed-*-all call.
25
+ * @param options.leadingBlankLine - Whether to prefix the summary line with a blank line (default true).
26
+ * @param options.extraSummary - Extra summary text appended after "skipped (draft)" (e.g. root/ineligible counts).
27
+ */
28
+ function printSeedAllResult(filename, dryRun, { written, skippedExists, skippedDraft }, options = {}) {
29
+ const { leadingBlankLine = true, extraSummary = "" } = options;
30
+ for (const dir of written) {
31
+ console.log(`${dryRun ? "[dry-run] would write" : "written"}: ${dir}/${filename}`);
32
+ }
33
+ for (const dir of skippedExists) {
34
+ console.log(`skipped (human-edited): ${dir}/${filename}`);
35
+ }
36
+ for (const dir of skippedDraft) {
37
+ console.log(`skipped (draft exists): ${dir}/${filename}`);
38
+ }
39
+ console.log(`${leadingBlankLine ? "\n" : ""}Done. ${written.length} written, ${skippedExists.length} skipped (exists), ${skippedDraft.length} skipped (draft)${extraSummary}.`);
40
+ }
18
41
  /**
19
42
  * Build and return the top-level "docs" Commander command group for Polaris docs lifecycle workflows.
20
43
  *
@@ -283,15 +306,6 @@ function createDocsCommand(options = {}) {
283
306
  includeHidden: options.includeHidden,
284
307
  includeRoot: options.includeRoot,
285
308
  });
286
- for (const dir of written) {
287
- console.log(`${options.dryRun ? "[dry-run] would write" : "written"}: ${dir}/POLARIS.md`);
288
- }
289
- for (const dir of skippedExists) {
290
- console.log(`skipped (human-edited): ${dir}/POLARIS.md`);
291
- }
292
- for (const dir of skippedDraft) {
293
- console.log(`skipped (draft exists): ${dir}/POLARIS.md`);
294
- }
295
309
  if (options.dryRun) {
296
310
  if (skippedRoot) {
297
311
  console.log(`\nSkipped (root):`);
@@ -315,7 +329,7 @@ function createDocsCommand(options = {}) {
315
329
  }
316
330
  }
317
331
  const rootCount = skippedRoot ? 1 : 0;
318
- console.log(`\nDone. ${written.length} written, ${skippedExists.length} skipped (exists), ${skippedDraft.length} skipped (draft), ${rootCount} skipped (root), ${skippedIneligible.length} skipped (ineligible).`);
332
+ printSeedAllResult("POLARIS.md", options.dryRun, { written, skippedExists, skippedDraft }, { extraSummary: `, ${rootCount} skipped (root), ${skippedIneligible.length} skipped (ineligible)` });
319
333
  return;
320
334
  }
321
335
  if (!pathArg) {
@@ -351,15 +365,6 @@ function createDocsCommand(options = {}) {
351
365
  includeHidden: options.includeHidden,
352
366
  includeRoot: options.includeRoot,
353
367
  });
354
- for (const dir of written) {
355
- console.log(`${options.dryRun ? "[dry-run] would write" : "written"}: ${dir}/SUMMARY.md`);
356
- }
357
- for (const dir of skippedExists) {
358
- console.log(`skipped (human-edited): ${dir}/SUMMARY.md`);
359
- }
360
- for (const dir of skippedDraft) {
361
- console.log(`skipped (draft exists): ${dir}/SUMMARY.md`);
362
- }
363
368
  if (options.dryRun) {
364
369
  if (skippedRoot) {
365
370
  console.log(`\nSkipped (root):`);
@@ -383,7 +388,7 @@ function createDocsCommand(options = {}) {
383
388
  }
384
389
  }
385
390
  const rootCount = skippedRoot ? 1 : 0;
386
- console.log(`\nDone. ${written.length} written, ${skippedExists.length} skipped (exists), ${skippedDraft.length} skipped (draft), ${rootCount} skipped (root), ${skippedIneligible.length} skipped (ineligible).`);
391
+ printSeedAllResult("SUMMARY.md", options.dryRun, { written, skippedExists, skippedDraft }, { extraSummary: `, ${rootCount} skipped (root), ${skippedIneligible.length} skipped (ineligible)` });
387
392
  return;
388
393
  }
389
394
  if (!pathArg) {
@@ -402,6 +407,77 @@ function createDocsCommand(options = {}) {
402
407
  console.log(`skipped (draft exists): ${pathArg}/SUMMARY.md`);
403
408
  }
404
409
  });
410
+ docs
411
+ .command("seed-index [path]")
412
+ .description("Generate OKF-conformant index.md files for smartdocs/")
413
+ .option("-r, --repo-root <path>", "Repository root", defaultRepoRoot)
414
+ .option("--all", "Generate index.md for all smartdocs directories lacking one")
415
+ .option("--dry-run", "Print what would be written without writing files")
416
+ .action((pathArg, options) => {
417
+ if (options.all) {
418
+ const { written, skippedExists, skippedDraft, } = (0, seed_instructions_js_1.seedIndexAll)(options.repoRoot, {
419
+ dryRun: options.dryRun,
420
+ });
421
+ printSeedAllResult("index.md", options.dryRun, { written, skippedExists, skippedDraft });
422
+ return;
423
+ }
424
+ const targetPath = pathArg || "smartdocs";
425
+ const result = (0, seed_instructions_js_1.seedIndex)(targetPath, options.repoRoot, { dryRun: options.dryRun });
426
+ if (result === "written") {
427
+ const label = options.dryRun ? "[dry-run] would write" : "written";
428
+ console.log(`${label}: ${targetPath}/index.md`);
429
+ }
430
+ else if (result === "skipped-exists") {
431
+ console.warn(`warning: ${targetPath}/index.md already exists (no draft marker) — skipped`);
432
+ }
433
+ else {
434
+ console.log(`skipped (draft exists): ${targetPath}/index.md`);
435
+ }
436
+ });
437
+ docs
438
+ .command("reformat-okf")
439
+ .description("Migrate existing smartdocs to OKF structure in one step: runs migrate → seed-index --all → seed-instructions --all. " +
440
+ "Agent instruction files (CLAUDE.md, AGENTS.md, etc.) are never touched. " +
441
+ "Use --dry-run to preview changes before writing.")
442
+ .option("--dry-run", "Preview what would change without writing any files")
443
+ .option("-r, --repo-root <path>", "Repository root", defaultRepoRoot)
444
+ .action((options) => {
445
+ const dryRun = options.dryRun;
446
+ const repoRoot = options.repoRoot;
447
+ const label = dryRun ? "[dry-run]" : "";
448
+ // Step 1: migrate (moves scattered markdown to smartdocs/raw/)
449
+ console.log(`${label ? label + " " : ""}Step 1/3: migrate`);
450
+ try {
451
+ const migrateResult = (0, migrate_js_1.migrateDocs)({ repoRoot, dryRun });
452
+ (0, migrate_js_1.printMigrateResults)(migrateResult);
453
+ }
454
+ catch (err) {
455
+ console.error(`reformat-okf: migrate failed — ${err instanceof Error ? err.message : String(err)}`);
456
+ process.exit(1);
457
+ }
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`);
460
+ try {
461
+ const { written, skippedExists, skippedDraft } = (0, seed_instructions_js_1.seedIndexAll)(repoRoot, { dryRun });
462
+ printSeedAllResult("index.md", dryRun, { written, skippedExists, skippedDraft }, { leadingBlankLine: false });
463
+ }
464
+ catch (err) {
465
+ console.error(`reformat-okf: seed-index failed — ${err instanceof Error ? err.message : String(err)}`);
466
+ process.exit(1);
467
+ }
468
+ // 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`);
470
+ try {
471
+ const { written, skippedExists, skippedDraft, skippedIneligible, skippedRoot } = (0, seed_instructions_js_1.seedInstructionsAll)(repoRoot, { dryRun });
472
+ const rootCount = skippedRoot ? 1 : 0;
473
+ printSeedAllResult("POLARIS.md", dryRun, { written, skippedExists, skippedDraft }, { leadingBlankLine: false, extraSummary: `, ${rootCount} skipped (root), ${skippedIneligible.length} skipped (ineligible)` });
474
+ }
475
+ catch (err) {
476
+ console.error(`reformat-okf: seed-instructions failed — ${err instanceof Error ? err.message : String(err)}`);
477
+ process.exit(1);
478
+ }
479
+ console.log(`\nreformat-okf complete.${dryRun ? " (dry-run — no files written)" : ""}`);
480
+ });
405
481
  docs
406
482
  .command("audit")
407
483
  .description("Scan repo for files at risk of recursive ingestion")
@@ -464,9 +540,10 @@ function createDoctrineCommand() {
464
540
  .description("Move a doc from smartdocs/raw/ or smartdocs/doctrine/raw/ to docs/doctrine/candidate/")
465
541
  .option("-r, --repo-root <path>", "Repository root", process.cwd())
466
542
  .option("--run-id <id>", "Override the generated doctrine run ID")
543
+ .option("--reason <text>", "Reason for this draft")
467
544
  .action((path, options) => {
468
545
  try {
469
- const result = (0, doctrine_js_1.doctrineDraft)(path, { repoRoot: options.repoRoot, runId: options.runId });
546
+ const result = (0, doctrine_js_1.doctrineDraft)(path, { repoRoot: options.repoRoot, runId: options.runId, reason: options.reason });
470
547
  console.log(`drafted: ${result.destination}`);
471
548
  console.log(`provenance: ${result.lifecyclePath}`);
472
549
  }
@@ -480,11 +557,13 @@ function createDoctrineCommand() {
480
557
  .description("Move a doc from smartdocs/doctrine/candidate/ to smartdocs/doctrine/active/")
481
558
  .option("-r, --repo-root <path>", "Repository root", process.cwd())
482
559
  .option("--run-id <id>", "Override the generated doctrine run ID")
560
+ .option("--reason <text>", "Reason for this promotion")
483
561
  .action((path, options) => {
484
562
  try {
485
563
  const result = (0, doctrine_js_1.doctrinePromote)(path, {
486
564
  repoRoot: options.repoRoot,
487
- runId: options.runId
565
+ runId: options.runId,
566
+ reason: options.reason,
488
567
  });
489
568
  console.log(`promoted: ${result.destination}`);
490
569
  console.log(`provenance: ${result.lifecyclePath}`);
@@ -499,11 +578,13 @@ function createDoctrineCommand() {
499
578
  .description("Move a doc from smartdocs/doctrine/active/ to smartdocs/doctrine/deprecated/")
500
579
  .option("-r, --repo-root <path>", "Repository root", process.cwd())
501
580
  .option("--run-id <id>", "Override the generated doctrine run ID")
581
+ .option("--reason <text>", "Reason for this deprecation")
502
582
  .action((path, options) => {
503
583
  try {
504
584
  const result = (0, doctrine_js_1.doctrineDeprecate)(path, {
505
585
  repoRoot: options.repoRoot,
506
586
  runId: options.runId,
587
+ reason: options.reason,
507
588
  });
508
589
  console.log(`deprecated: ${result.destination}`);
509
590
  console.log(`provenance: ${result.lifecyclePath}`);
@@ -519,12 +600,14 @@ function createDoctrineCommand() {
519
600
  .option("-r, --repo-root <path>", "Repository root", process.cwd())
520
601
  .option("--run-id <id>", "Override the generated run ID")
521
602
  .option("--approve", "Proceed despite detected conflicts")
603
+ .option("--reason <text>", "Reason for this promotion")
522
604
  .action((path, options) => {
523
605
  try {
524
606
  const result = (0, doctrine_js_1.specPromote)(path, {
525
607
  repoRoot: options.repoRoot,
526
608
  runId: options.runId,
527
609
  approve: options.approve,
610
+ reason: options.reason,
528
611
  });
529
612
  console.log(result.report);
530
613
  if (result.halted) {