@lsctech/polaris 0.4.3 → 0.4.5

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.
@@ -132,6 +132,7 @@ function gateForemanResentPacket(artifacts) {
132
132
  /** Counts per-child dispatches from all available artifact sources, or null if none are present. */
133
133
  function countChildDispatches(artifacts) {
134
134
  const counts = new Map();
135
+ // Count from open_children_meta dispatch_record
135
136
  const state = asRecord(artifacts.currentState);
136
137
  const openChildrenMeta = asRecord(state?.["open_children_meta"]);
137
138
  for (const [childId, meta] of Object.entries(openChildrenMeta ?? {})) {
@@ -142,20 +143,30 @@ function countChildDispatches(artifacts) {
142
143
  counts.set(childId, Math.max(counts.get(childId) ?? 0, dispatchCount));
143
144
  }
144
145
  }
146
+ // Count child-dispatched events from ledger (each event = evidence of one dispatch)
147
+ const ledgerCounts = new Map();
145
148
  for (const event of artifacts.ledgerEvents) {
146
149
  const rec = asRecord(event);
147
150
  if (rec?.["event"] === "child-dispatched" && typeof rec["issue_id"] === "string") {
148
151
  const childId = rec["issue_id"];
149
- counts.set(childId, (counts.get(childId) ?? 0) + 1);
152
+ ledgerCounts.set(childId, (ledgerCounts.get(childId) ?? 0) + 1);
150
153
  }
151
154
  }
155
+ for (const [childId, count] of ledgerCounts.entries()) {
156
+ counts.set(childId, Math.max(counts.get(childId) ?? 0, count));
157
+ }
158
+ // Count child-dispatched events from telemetry (each event = evidence of one dispatch)
159
+ const telemetryCounts = new Map();
152
160
  for (const event of artifacts.telemetryEvents) {
153
161
  const rec = asRecord(event);
154
162
  if (rec?.["event"] === "child-dispatched" && typeof rec["child_id"] === "string") {
155
163
  const childId = rec["child_id"];
156
- counts.set(childId, (counts.get(childId) ?? 0) + 1);
164
+ telemetryCounts.set(childId, (telemetryCounts.get(childId) ?? 0) + 1);
157
165
  }
158
166
  }
167
+ for (const [childId, count] of telemetryCounts.entries()) {
168
+ counts.set(childId, Math.max(counts.get(childId) ?? 0, count));
169
+ }
159
170
  return counts.size > 0 ? counts : null;
160
171
  }
161
172
  /**
@@ -167,6 +167,10 @@ function loadRunArtifacts(repoRoot, runId) {
167
167
  const completedChildrenResults = stateRec?.["completed_children_results"];
168
168
  if (completedChildrenResults && typeof completedChildrenResults === "object" && !Array.isArray(completedChildrenResults)) {
169
169
  workerResultContracts = Object.values(completedChildrenResults).filter((v) => v !== null && typeof v === "object" && typeof v["worker_id"] === "string");
170
+ // If filtering produced zero contracts, fall back to legacy extraction
171
+ if (workerResultContracts.length === 0) {
172
+ workerResultContracts = extractWorkerResultContracts(resultPackets);
173
+ }
170
174
  }
171
175
  else {
172
176
  // Legacy fallback: extract from result packet files
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scaffoldBundleRoot = scaffoldBundleRoot;
3
4
  exports.migrateSmartDocs = migrateSmartDocs;
4
5
  const node_child_process_1 = require("node:child_process");
5
6
  const node_fs_1 = require("node:fs");
@@ -110,6 +111,20 @@ function normalizeStatus(step, status, completedAt) {
110
111
  error: status === "completed" || status === "skipped" ? undefined : step.error,
111
112
  };
112
113
  }
114
+ const SMARTDOCS_BUNDLE_INDEX_CONTENT = `---\nokf_version: "0.1"\n---\n\n# SmartDocs — Polaris Cognition Bundle\n\n# Governance\n\n- [Docs authority model](specs/active/docs-authority-model.md)\n- [Doctrine](doctrine/active/)\n\n# Routes\n\n<!-- Routes placeholder: dynamic file-routes.json generation deferred. -->\n`;
115
+ const SMARTDOCS_BUNDLE_LOG_CONTENT = "# SmartDocs — Change Log\n";
116
+ function scaffoldBundleRoot(repoRoot) {
117
+ const smartdocsDir = (0, node_path_1.join)(repoRoot, "smartdocs");
118
+ (0, node_fs_1.mkdirSync)(smartdocsDir, { recursive: true });
119
+ const indexPath = (0, node_path_1.join)(smartdocsDir, "index.md");
120
+ if (!(0, node_fs_1.existsSync)(indexPath)) {
121
+ (0, node_fs_1.writeFileSync)(indexPath, SMARTDOCS_BUNDLE_INDEX_CONTENT, "utf-8");
122
+ }
123
+ const logPath = (0, node_path_1.join)(smartdocsDir, "log.md");
124
+ if (!(0, node_fs_1.existsSync)(logPath)) {
125
+ (0, node_fs_1.writeFileSync)(logPath, SMARTDOCS_BUNDLE_LOG_CONTENT, "utf-8");
126
+ }
127
+ }
113
128
  function migrateSmartDocs(plan, repoRoot = (0, node_path_1.resolve)(process.cwd())) {
114
129
  const effectivePlan = loadPlan(repoRoot, plan);
115
130
  const provenanceRecords = [];
@@ -194,6 +209,7 @@ function migrateSmartDocs(plan, repoRoot = (0, node_path_1.resolve)(process.cwd(
194
209
  seenRecords.add(recordKey(record));
195
210
  }
196
211
  }
212
+ scaffoldBundleRoot(repoRoot);
197
213
  savePlan(repoRoot, effectivePlan);
198
214
  saveProvenance(repoRoot, provenanceRecords);
199
215
  Object.assign(plan, effectivePlan);
@@ -90,6 +90,7 @@ function generateLibrarianPacket(options) {
90
90
  // Prohibited: anything that is not documentation or cognition
91
91
  const prohibitedWritePaths = [
92
92
  stateFile,
93
+ node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId, "cluster-state.json"),
93
94
  node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId, "state.json"),
94
95
  node_path_1.default.join(repoRoot, ".taskchain_artifacts"),
95
96
  node_path_1.default.join(repoRoot, ".polaris", "runs"),
@@ -143,16 +144,24 @@ function generateLibrarianPacket(options) {
143
144
  }
144
145
  // ── Helpers ───────────────────────────────────────────────────────────────────
145
146
  function resolveStateFile(repoRoot, clusterId) {
146
- const canonical = node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId, "state.json");
147
- if (node_fs_1.default.existsSync(canonical))
148
- return canonical;
147
+ // Check current convention: cluster-state.json
148
+ const clusterState = node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId, "cluster-state.json");
149
+ if (node_fs_1.default.existsSync(clusterState))
150
+ return clusterState;
151
+ // Check legacy convention: state.json
152
+ const legacyState = node_path_1.default.join(repoRoot, ".polaris", "clusters", clusterId, "state.json");
153
+ if (node_fs_1.default.existsSync(legacyState))
154
+ return legacyState;
155
+ // Check taskchain artifacts fallback
149
156
  const taskchain = node_path_1.default.join(repoRoot, ".taskchain_artifacts", "polaris-run", "current-state.json");
150
157
  if (node_fs_1.default.existsSync(taskchain))
151
158
  return taskchain;
152
- const legacy = node_path_1.default.join(repoRoot, ".polaris", "runs", "current-state.json");
153
- if (node_fs_1.default.existsSync(legacy))
154
- return legacy;
155
- return canonical;
159
+ // Check legacy runs fallback
160
+ const legacyRuns = node_path_1.default.join(repoRoot, ".polaris", "runs", "current-state.json");
161
+ if (node_fs_1.default.existsSync(legacyRuns))
162
+ return legacyRuns;
163
+ // Return current convention path as default
164
+ return clusterState;
156
165
  }
157
166
  function findResultFile(resultsDir, childId) {
158
167
  try {
@@ -65,6 +65,7 @@ function writeSealedResultFromSummary(packet, parsedSummary) {
65
65
  const validation = parsedSummary["validation"] ?? parsedSummary["validation_summary"];
66
66
  const sealedResult = {
67
67
  run_id: packet.run_id,
68
+ cluster_id: packet.cluster_id,
68
69
  child_id: packet.active_child,
69
70
  status: normalizeSealedStatus(parsedSummary["status"]),
70
71
  };
@@ -320,6 +320,7 @@ class TerminalCliAdapter {
320
320
  const sealedResult = {
321
321
  ...normalized,
322
322
  run_id: packet.run_id,
323
+ cluster_id: packet.cluster_id,
323
324
  child_id: String(normalized["child_id"] ?? packet.active_child),
324
325
  status: effectiveStatus,
325
326
  commit: typeof normalized["commit"] === "string"
@@ -123,6 +123,10 @@ function parseSections(body) {
123
123
  /**
124
124
  * Extracts bullet list items from the given text and returns them as trimmed strings.
125
125
  *
126
+ * This is a generic list parser used by all section types (Validation, Acceptance
127
+ * Criteria, Ordering, Non-goals, etc.). It does NOT strip parenthetical annotations
128
+ * — that logic is scope-specific and handled separately.
129
+ *
126
130
  * @param text - Section content to scan for bullet list lines (lines starting with `- ` or `* `)
127
131
  * @returns The extracted list item strings, trimmed of whitespace; empty items are omitted.
128
132
  */
@@ -133,9 +137,25 @@ function parseListItems(text) {
133
137
  .map((line) => line.replace(/^\s*[-*]\s+/, '').trim())
134
138
  .filter((s) => s.length > 0);
135
139
  }
140
+ /**
141
+ * Removes trailing parenthetical annotations from a string.
142
+ *
143
+ * Used exclusively for Scope section items to strip annotations like "(new)" or
144
+ * "(thread flag through...)" so that allowed_scope entries in worker packets
145
+ * contain only bare file paths or valid glob patterns.
146
+ *
147
+ * @param s - The string to process
148
+ * @returns The input string with trailing `(...)` removed, re-trimmed
149
+ */
150
+ function stripTrailingParenthetical(s) {
151
+ return s.replace(/\s*\([^)]*\)\s*$/, '').trim();
152
+ }
136
153
  /**
137
154
  * Extracts list items from the first section whose header matches a provided set.
138
155
  *
156
+ * This is a generic section parser that does NOT apply any parenthetical stripping.
157
+ * For Scope sections, use `findScopeSection` instead.
158
+ *
139
159
  * @param sections - Map of normalized header names to their section content; iteration follows the map's order
140
160
  * @param headers - Set of normalized header names to match against section headers
141
161
  * @returns An array of parsed list-item strings from the first matching section, or an empty array if no match is found
@@ -148,6 +168,25 @@ function findSection(sections, headers) {
148
168
  }
149
169
  return [];
150
170
  }
171
+ /**
172
+ * Extracts list items from the Scope section, stripping trailing parenthetical annotations.
173
+ *
174
+ * Scope section items often include human-readable annotations like "(new)" or
175
+ * "(thread flag through...)". These are stripped so that allowed_scope entries in
176
+ * worker packets contain only bare file paths or valid glob patterns.
177
+ *
178
+ * @param sections - Map of normalized header names to their section content
179
+ * @param headers - Set of normalized Scope header variants (e.g., SCOPE_HEADERS)
180
+ * @returns An array of scope paths with parentheticals removed, or an empty array if no Scope section is found
181
+ */
182
+ function findScopeSection(sections, headers) {
183
+ for (const [header, content] of sections) {
184
+ if (headers.has(header)) {
185
+ return parseListItems(content).map(stripTrailingParenthetical).filter((s) => s.length > 0);
186
+ }
187
+ }
188
+ return [];
189
+ }
151
190
  /**
152
191
  * Returns trimmed prose text from the first section matching a header set.
153
192
  * Used for single-paragraph sections (Objective, Context, Goal).
@@ -189,7 +228,7 @@ function parseIssueBody(body) {
189
228
  };
190
229
  }
191
230
  const sections = parseSections(body);
192
- const rawScope = findSection(sections, SCOPE_HEADERS);
231
+ const rawScope = findScopeSection(sections, SCOPE_HEADERS);
193
232
  const scopeBlocked = rawScope.length > 0 && rawScope.every((item) => TBD_BLOCKED_RE.test(item));
194
233
  const filteredScope = rawScope.filter((item) => !TBD_BLOCKED_RE.test(item));
195
234
  return {
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runCanonCheck = runCanonCheck;
4
+ exports.runSmartDocsLinkCheck = runSmartDocsLinkCheck;
4
5
  const node_fs_1 = require("node:fs");
5
6
  const node_path_1 = require("node:path");
7
+ const doctrine_js_1 = require("./doctrine.js");
6
8
  // Modal verbs that indicate behavioral assertions in doctrine/spec files
7
9
  const MODAL_VERBS = /\b(must|never|always|should|required|requires|shall)\b/i;
8
10
  // Domains matched by filename keywords
@@ -338,3 +340,81 @@ function writeCandidateDraftDocs(conflicts, repoRoot, childId) {
338
340
  // Non-fatal
339
341
  }
340
342
  }
343
+ /** Subdirectories of smartdocs/ that are subject to strict link checking */
344
+ const STRICT_SMARTDOCS_DIRS = [
345
+ (0, node_path_1.join)("smartdocs", "doctrine", "candidate"),
346
+ (0, node_path_1.join)("smartdocs", "doctrine", "active"),
347
+ (0, node_path_1.join)("smartdocs", "specs", "candidate"),
348
+ (0, node_path_1.join)("smartdocs", "specs", "active"),
349
+ ];
350
+ /**
351
+ * Recursively walk a directory and return all .md file paths.
352
+ *
353
+ * @param dir - Directory to walk
354
+ * @returns Array of absolute paths to all .md files found recursively
355
+ */
356
+ function walkMarkdownFiles(dir) {
357
+ const results = [];
358
+ let entries;
359
+ try {
360
+ entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
361
+ }
362
+ catch {
363
+ return results;
364
+ }
365
+ for (const entry of entries) {
366
+ const fullPath = (0, node_path_1.join)(dir, entry.name);
367
+ if (entry.isDirectory()) {
368
+ results.push(...walkMarkdownFiles(fullPath));
369
+ }
370
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
371
+ results.push(fullPath);
372
+ }
373
+ }
374
+ return results;
375
+ }
376
+ /**
377
+ * Walk the strict-tier smartdocs directories (candidate/ and active/ under doctrine/ and specs/)
378
+ * and check every .md file for broken cross-links into smartdocs/.
379
+ *
380
+ * Recursively traverses subdirectories to find nested documents.
381
+ * Files in raw/ are never checked (OKF §5.3 permissive default).
382
+ * Broken links are reported as SpecConflict entries with type "stale-assumption".
383
+ *
384
+ * @param options - Options including repoRoot, runId, and telemetryFile
385
+ * @returns Object with all detected conflicts and the count of files checked
386
+ */
387
+ function runSmartDocsLinkCheck(options) {
388
+ const { repoRoot, runId, telemetryFile, childId } = options;
389
+ const root = (0, node_path_1.resolve)(repoRoot);
390
+ const allConflicts = [];
391
+ let filesChecked = 0;
392
+ for (const subdir of STRICT_SMARTDOCS_DIRS) {
393
+ const dir = (0, node_path_1.join)(root, subdir);
394
+ if (!(0, node_fs_1.existsSync)(dir))
395
+ continue;
396
+ const markdownFiles = walkMarkdownFiles(dir);
397
+ for (const filePath of markdownFiles) {
398
+ let content;
399
+ try {
400
+ content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
401
+ }
402
+ catch {
403
+ continue;
404
+ }
405
+ filesChecked++;
406
+ const conflicts = (0, doctrine_js_1.checkSmartDocsLinks)(filePath, content, root);
407
+ allConflicts.push(...conflicts);
408
+ }
409
+ }
410
+ appendTelemetry(telemetryFile, {
411
+ event: "smartdocs-link-check-result",
412
+ run_id: runId,
413
+ child_id: childId ?? null,
414
+ files_checked: filesChecked,
415
+ broken_links: allConflicts.length,
416
+ conflicts: allConflicts,
417
+ timestamp: new Date().toISOString(),
418
+ });
419
+ return { conflicts: allConflicts, filesChecked };
420
+ }
@@ -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
  /**
@@ -402,6 +402,42 @@ function createDocsCommand(options = {}) {
402
402
  console.log(`skipped (draft exists): ${pathArg}/SUMMARY.md`);
403
403
  }
404
404
  });
405
+ docs
406
+ .command("seed-index [path]")
407
+ .description("Generate OKF-conformant index.md files for smartdocs/")
408
+ .option("-r, --repo-root <path>", "Repository root", defaultRepoRoot)
409
+ .option("--all", "Generate index.md for all smartdocs directories lacking one")
410
+ .option("--dry-run", "Print what would be written without writing files")
411
+ .action((pathArg, options) => {
412
+ if (options.all) {
413
+ const { written, skippedExists, skippedDraft, } = (0, seed_instructions_js_1.seedIndexAll)(options.repoRoot, {
414
+ dryRun: options.dryRun,
415
+ });
416
+ for (const dir of written) {
417
+ console.log(`${options.dryRun ? "[dry-run] would write" : "written"}: ${dir}/index.md`);
418
+ }
419
+ for (const dir of skippedExists) {
420
+ console.log(`skipped (human-edited): ${dir}/index.md`);
421
+ }
422
+ for (const dir of skippedDraft) {
423
+ console.log(`skipped (draft exists): ${dir}/index.md`);
424
+ }
425
+ console.log(`\nDone. ${written.length} written, ${skippedExists.length} skipped (exists), ${skippedDraft.length} skipped (draft).`);
426
+ return;
427
+ }
428
+ const targetPath = pathArg || "smartdocs";
429
+ const result = (0, seed_instructions_js_1.seedIndex)(targetPath, options.repoRoot, { dryRun: options.dryRun });
430
+ if (result === "written") {
431
+ const label = options.dryRun ? "[dry-run] would write" : "written";
432
+ console.log(`${label}: ${targetPath}/index.md`);
433
+ }
434
+ else if (result === "skipped-exists") {
435
+ console.warn(`warning: ${targetPath}/index.md already exists (no draft marker) — skipped`);
436
+ }
437
+ else {
438
+ console.log(`skipped (draft exists): ${targetPath}/index.md`);
439
+ }
440
+ });
405
441
  docs
406
442
  .command("audit")
407
443
  .description("Scan repo for files at risk of recursive ingestion")
@@ -464,9 +500,10 @@ function createDoctrineCommand() {
464
500
  .description("Move a doc from smartdocs/raw/ or smartdocs/doctrine/raw/ to docs/doctrine/candidate/")
465
501
  .option("-r, --repo-root <path>", "Repository root", process.cwd())
466
502
  .option("--run-id <id>", "Override the generated doctrine run ID")
503
+ .option("--reason <text>", "Reason for this draft")
467
504
  .action((path, options) => {
468
505
  try {
469
- const result = (0, doctrine_js_1.doctrineDraft)(path, { repoRoot: options.repoRoot, runId: options.runId });
506
+ const result = (0, doctrine_js_1.doctrineDraft)(path, { repoRoot: options.repoRoot, runId: options.runId, reason: options.reason });
470
507
  console.log(`drafted: ${result.destination}`);
471
508
  console.log(`provenance: ${result.lifecyclePath}`);
472
509
  }
@@ -480,11 +517,13 @@ function createDoctrineCommand() {
480
517
  .description("Move a doc from smartdocs/doctrine/candidate/ to smartdocs/doctrine/active/")
481
518
  .option("-r, --repo-root <path>", "Repository root", process.cwd())
482
519
  .option("--run-id <id>", "Override the generated doctrine run ID")
520
+ .option("--reason <text>", "Reason for this promotion")
483
521
  .action((path, options) => {
484
522
  try {
485
523
  const result = (0, doctrine_js_1.doctrinePromote)(path, {
486
524
  repoRoot: options.repoRoot,
487
- runId: options.runId
525
+ runId: options.runId,
526
+ reason: options.reason,
488
527
  });
489
528
  console.log(`promoted: ${result.destination}`);
490
529
  console.log(`provenance: ${result.lifecyclePath}`);
@@ -499,11 +538,13 @@ function createDoctrineCommand() {
499
538
  .description("Move a doc from smartdocs/doctrine/active/ to smartdocs/doctrine/deprecated/")
500
539
  .option("-r, --repo-root <path>", "Repository root", process.cwd())
501
540
  .option("--run-id <id>", "Override the generated doctrine run ID")
541
+ .option("--reason <text>", "Reason for this deprecation")
502
542
  .action((path, options) => {
503
543
  try {
504
544
  const result = (0, doctrine_js_1.doctrineDeprecate)(path, {
505
545
  repoRoot: options.repoRoot,
506
546
  runId: options.runId,
547
+ reason: options.reason,
507
548
  });
508
549
  console.log(`deprecated: ${result.destination}`);
509
550
  console.log(`provenance: ${result.lifecyclePath}`);
@@ -519,12 +560,14 @@ function createDoctrineCommand() {
519
560
  .option("-r, --repo-root <path>", "Repository root", process.cwd())
520
561
  .option("--run-id <id>", "Override the generated run ID")
521
562
  .option("--approve", "Proceed despite detected conflicts")
563
+ .option("--reason <text>", "Reason for this promotion")
522
564
  .action((path, options) => {
523
565
  try {
524
566
  const result = (0, doctrine_js_1.specPromote)(path, {
525
567
  repoRoot: options.repoRoot,
526
568
  runId: options.runId,
527
569
  approve: options.approve,
570
+ reason: options.reason,
528
571
  });
529
572
  console.log(result.report);
530
573
  if (result.halted) {
@@ -4,15 +4,20 @@ exports.DRAFT_MARKER = void 0;
4
4
  exports.hasDraftMarker = hasDraftMarker;
5
5
  exports.generateDraft = generateDraft;
6
6
  exports.generateSummaryDraft = generateSummaryDraft;
7
+ exports.generateBundleRootIndex = generateBundleRootIndex;
8
+ exports.generateDirectoryIndex = generateDirectoryIndex;
7
9
  exports.seedInstructions = seedInstructions;
8
10
  exports.seedSummary = seedSummary;
9
11
  exports.seedInstructionsAll = seedInstructionsAll;
10
12
  exports.seedSummaryAll = seedSummaryAll;
13
+ exports.seedIndex = seedIndex;
14
+ exports.seedIndexAll = seedIndexAll;
11
15
  const node_fs_1 = require("node:fs");
12
16
  const node_path_1 = require("node:path");
13
17
  const loader_js_1 = require("../config/loader.js");
14
18
  const atlas_js_1 = require("../map/atlas.js");
15
19
  const smartdoc_ignore_js_1 = require("./smartdoc-ignore.js");
20
+ const doctrine_js_1 = require("./doctrine.js");
16
21
  exports.DRAFT_MARKER = "<!-- polaris:draft -->";
17
22
  function hasDraftMarker(filePath) {
18
23
  try {
@@ -176,6 +181,132 @@ function generateSummaryDraft(targetDir, repoRoot, _allRoutes) {
176
181
  ];
177
182
  return lines.join("\n");
178
183
  }
184
+ const RESERVED_INDEX_NAMES = new Set(["index.md", "POLARIS.md", "SUMMARY.md", "log.md"]);
185
+ function isReservedIndexName(name) {
186
+ return RESERVED_INDEX_NAMES.has(name);
187
+ }
188
+ function listConceptFiles(dir) {
189
+ try {
190
+ return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })
191
+ .filter((e) => e.isFile() && e.name.endsWith(".md") && !isReservedIndexName(e.name))
192
+ .map((e) => (0, node_path_1.join)(dir, e.name))
193
+ .sort();
194
+ }
195
+ catch {
196
+ return [];
197
+ }
198
+ }
199
+ function conceptLabel(filePath) {
200
+ try {
201
+ const content = (0, node_fs_1.readFileSync)(filePath, "utf-8");
202
+ const fm = (0, doctrine_js_1.parseFrontMatter)(content);
203
+ const base = (0, node_path_1.basename)(filePath);
204
+ return fm.description || fm.title || base.replace(/\.md$/, "");
205
+ }
206
+ catch {
207
+ return (0, node_path_1.basename)(filePath).replace(/\.md$/, "");
208
+ }
209
+ }
210
+ function collectSmartDocsDirs(dir, root, result = []) {
211
+ try {
212
+ const rel = (0, node_path_1.relative)(root, dir).replace(/\\/g, "/");
213
+ if (rel.split("/").some((s) => s === "raw" || s.startsWith("."))) {
214
+ return result;
215
+ }
216
+ result.push(rel);
217
+ for (const entry of (0, node_fs_1.readdirSync)(dir, { withFileTypes: true })) {
218
+ if (!entry.isDirectory())
219
+ continue;
220
+ if (entry.name.startsWith(".") || entry.name === "raw")
221
+ continue;
222
+ const full = (0, node_path_1.join)(dir, entry.name);
223
+ collectSmartDocsDirs(full, root, result);
224
+ }
225
+ }
226
+ catch {
227
+ // ignore unreadable dirs
228
+ }
229
+ return result;
230
+ }
231
+ function generateBundleRootIndex(repoRoot, allRoutes) {
232
+ const lines = [
233
+ "---",
234
+ "okf_version: \"0.1\"",
235
+ "---",
236
+ "",
237
+ exports.DRAFT_MARKER,
238
+ "# SmartDocs — Polaris Cognition Bundle",
239
+ "",
240
+ "> Polaris draft — review and remove the `<!-- polaris:draft -->` marker to promote.",
241
+ "",
242
+ "## Governance",
243
+ "",
244
+ ];
245
+ const doctrineDir = (0, node_path_1.join)(repoRoot, "smartdocs", "doctrine", "active");
246
+ const doctrineFiles = listConceptFiles(doctrineDir);
247
+ if (doctrineFiles.length > 0) {
248
+ for (const file of doctrineFiles) {
249
+ const relPath = (0, node_path_1.relative)((0, node_path_1.join)(repoRoot, "smartdocs"), file).replace(/\\/g, "/");
250
+ lines.push(`- [${conceptLabel(file)}](${relPath})`);
251
+ }
252
+ }
253
+ else {
254
+ lines.push("- [Doctrine — Active](doctrine/active/)");
255
+ }
256
+ lines.push("");
257
+ lines.push("## Specs", "");
258
+ const specsDir = (0, node_path_1.join)(repoRoot, "smartdocs", "specs", "active");
259
+ const specsFiles = listConceptFiles(specsDir);
260
+ if (specsFiles.length > 0) {
261
+ for (const file of specsFiles) {
262
+ const relPath = (0, node_path_1.relative)((0, node_path_1.join)(repoRoot, "smartdocs"), file).replace(/\\/g, "/");
263
+ lines.push(`- [${conceptLabel(file)}](${relPath})`);
264
+ }
265
+ }
266
+ else {
267
+ lines.push("- [Specs — Active](specs/active/)");
268
+ }
269
+ lines.push("");
270
+ lines.push("## Routes", "");
271
+ const instructionFiles = [
272
+ ...new Set(Object.values(allRoutes).map((e) => e.instructionFile).filter(Boolean)),
273
+ ].sort();
274
+ if (instructionFiles.length > 0) {
275
+ for (const file of instructionFiles) {
276
+ lines.push(`- [${file}](../${file})`);
277
+ }
278
+ }
279
+ else {
280
+ lines.push("<!-- Route POLARIS.md files will be listed here from atlas signals. -->");
281
+ }
282
+ lines.push("");
283
+ return lines.join("\n");
284
+ }
285
+ function generateDirectoryIndex(targetDir, repoRoot) {
286
+ const absDir = (0, node_path_1.resolve)(repoRoot, targetDir);
287
+ const dirLabel = (0, node_path_1.basename)(absDir) || "SmartDocs";
288
+ const files = listConceptFiles(absDir);
289
+ const lines = [
290
+ exports.DRAFT_MARKER,
291
+ `# ${dirLabel}`,
292
+ "",
293
+ "> Polaris draft — review and remove the `<!-- polaris:draft -->` marker to promote.",
294
+ "",
295
+ "## Concepts",
296
+ "",
297
+ ];
298
+ if (files.length > 0) {
299
+ for (const file of files) {
300
+ const relPath = (0, node_path_1.relative)(absDir, file).replace(/\\/g, "/");
301
+ lines.push(`- [${conceptLabel(file)}](${relPath})`);
302
+ }
303
+ }
304
+ else {
305
+ lines.push("<!-- No concept files in this directory yet. -->");
306
+ }
307
+ lines.push("");
308
+ return lines.join("\n");
309
+ }
179
310
  function seedInstructions(targetPath, repoRoot, opts = {}) {
180
311
  const absTarget = (0, node_path_1.resolve)(repoRoot, targetPath);
181
312
  // Path traversal check: ensure absTarget is within repoRoot
@@ -333,3 +464,78 @@ function seedSummaryAll(repoRoot, opts = {}) {
333
464
  }
334
465
  return { written, skippedExists, skippedDraft, skippedIneligible, skippedRoot };
335
466
  }
467
+ function seedIndex(targetPath, repoRoot, opts = {}) {
468
+ const absTarget = (0, node_path_1.resolve)(repoRoot, targetPath);
469
+ const relCheck = (0, node_path_1.relative)(repoRoot, absTarget);
470
+ if (relCheck.startsWith("..") || relCheck.startsWith("/")) {
471
+ throw new Error(`Path traversal detected: target path is outside repo root`);
472
+ }
473
+ // Reject targets outside smartdocs/
474
+ const relTarget = relCheck.replace(/\\/g, "/");
475
+ if (!relTarget.startsWith("smartdocs/") && relTarget !== "smartdocs") {
476
+ throw new Error(`seedIndex only writes index.md under smartdocs/; rejecting target: ${relTarget}`);
477
+ }
478
+ const outFile = (0, node_path_1.join)(absTarget, "index.md");
479
+ if ((0, node_fs_1.existsSync)(outFile)) {
480
+ if (hasDraftMarker(outFile)) {
481
+ return "skipped-draft";
482
+ }
483
+ return "skipped-exists";
484
+ }
485
+ const config = (0, loader_js_1.loadConfig)(repoRoot);
486
+ const atlasPath = (0, node_path_1.resolve)(repoRoot, config.repo.sidecarOutputPath ?? ".polaris/map");
487
+ const allRoutes = {
488
+ ...(0, atlas_js_1.readFileRoutes)(atlasPath),
489
+ ...(0, atlas_js_1.readNeedsReview)(atlasPath),
490
+ };
491
+ const isBundleRoot = relTarget === "smartdocs";
492
+ const content = isBundleRoot
493
+ ? generateBundleRootIndex(repoRoot, allRoutes)
494
+ : generateDirectoryIndex(relTarget, repoRoot);
495
+ if (!opts.dryRun) {
496
+ (0, node_fs_1.writeFileSync)(outFile, content, "utf-8");
497
+ }
498
+ return "written";
499
+ }
500
+ function seedIndexAll(repoRoot, opts = {}) {
501
+ const config = (0, loader_js_1.loadConfig)(repoRoot);
502
+ const atlasPath = (0, node_path_1.resolve)(repoRoot, config.repo.sidecarOutputPath ?? ".polaris/map");
503
+ const allRoutes = {
504
+ ...(0, atlas_js_1.readFileRoutes)(atlasPath),
505
+ ...(0, atlas_js_1.readNeedsReview)(atlasPath),
506
+ };
507
+ const smartdocsRoot = (0, node_path_1.resolve)(repoRoot, "smartdocs");
508
+ if (!(0, node_fs_1.existsSync)(smartdocsRoot)) {
509
+ return { written: [], skippedExists: [], skippedDraft: [], skippedIneligible: [] };
510
+ }
511
+ const dirs = collectSmartDocsDirs(smartdocsRoot, repoRoot);
512
+ const smartdocsRel = "smartdocs";
513
+ if (!dirs.includes(smartdocsRel)) {
514
+ dirs.unshift(smartdocsRel);
515
+ }
516
+ const written = [];
517
+ const skippedExists = [];
518
+ const skippedDraft = [];
519
+ for (const relDir of dirs) {
520
+ const absDir = (0, node_path_1.resolve)(repoRoot, relDir);
521
+ const outFile = (0, node_path_1.join)(absDir, "index.md");
522
+ if ((0, node_fs_1.existsSync)(outFile)) {
523
+ if (hasDraftMarker(outFile)) {
524
+ skippedDraft.push(relDir);
525
+ }
526
+ else {
527
+ skippedExists.push(relDir);
528
+ }
529
+ continue;
530
+ }
531
+ const isBundleRoot = relDir === "smartdocs";
532
+ const content = isBundleRoot
533
+ ? generateBundleRootIndex(repoRoot, allRoutes)
534
+ : generateDirectoryIndex(relDir, repoRoot);
535
+ if (!opts.dryRun) {
536
+ (0, node_fs_1.writeFileSync)(outFile, content, "utf-8");
537
+ }
538
+ written.push(relDir);
539
+ }
540
+ return { written, skippedExists, skippedDraft, skippedIneligible: [] };
541
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lsctech/polaris",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Polaris — standalone taskchain orchestration framework for governed AI agent workflows",
5
5
  "bin": {
6
6
  "polaris": "dist/cli/index.js",