@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.
@@ -140,8 +140,9 @@ async function runAdoptPhase(phase, repoRoot, options = {}) {
140
140
  if (!options.inventory || !options.plan) {
141
141
  throw new Error("consolidate requires inventory and plan");
142
142
  }
143
+ const dryRun = options.plan.dry_run ?? options.dryRun ?? false;
143
144
  await (0, adopt_smartdocs_js_1.migrateSmartDocs)(options.plan, repoRoot);
144
- await (0, adopt_genesis_js_1.reconcileAgentFiles)(repoRoot);
145
+ await (0, adopt_genesis_js_1.reconcileAgentFiles)(repoRoot, { dryRun });
145
146
  break;
146
147
  }
147
148
  case "map": {
@@ -25,7 +25,9 @@ function isAlreadyPointer(content) {
25
25
  });
26
26
  return meaningfulLines.length <= 3 && meaningfulLines.some((line) => line.includes("POLARIS_RULES.md"));
27
27
  }
28
- function appendGenesisProvenance(repoRoot, records) {
28
+ function appendGenesisProvenance(repoRoot, records, dryRun = false) {
29
+ if (dryRun)
30
+ return;
29
31
  if (records.length === 0)
30
32
  return;
31
33
  const provenancePath = (0, node_path_1.join)(repoRoot, ".polaris", "adoption-provenance.json");
@@ -57,6 +59,7 @@ async function reconcileAgentFiles(repoRoot, options) {
57
59
  const provenanceRecords = [];
58
60
  const now = options?.now ?? new Date();
59
61
  const timestamp = now.toISOString();
62
+ const dryRun = options?.dryRun ?? false;
60
63
  for (const file of AGENT_FILES) {
61
64
  const filePath = (0, node_path_1.join)(repoRoot, file);
62
65
  if (!(0, node_fs_1.existsSync)(filePath)) {
@@ -74,6 +77,11 @@ async function reconcileAgentFiles(repoRoot, options) {
74
77
  });
75
78
  continue;
76
79
  }
80
+ if (dryRun) {
81
+ process.stdout.write(`[dry-run] would prompt to compress and archive ${file} as genesis doctrine\n`);
82
+ results.push({ file, outcome: "skipped" });
83
+ continue;
84
+ }
77
85
  const rl = (0, promises_1.createInterface)({ input: process.stdin, output: process.stdout });
78
86
  let answer;
79
87
  try {
@@ -146,11 +154,13 @@ async function reconcileAgentFiles(repoRoot, options) {
146
154
  const dateStr = formatDate(now);
147
155
  const genesisPath = `smartdocs/doctrine/active/${dateStr}-genesis-agent-doctrine.md`;
148
156
  const genesisFullPath = (0, node_path_1.join)(repoRoot, genesisPath);
149
- (0, node_fs_1.mkdirSync)((0, node_path_1.join)(repoRoot, "smartdocs/doctrine/active"), { recursive: true });
150
- (0, node_fs_1.writeFileSync)(genesisFullPath, distilled, "utf8");
151
- const thinPointer = `# Agent Instructions\n\nRead [POLARIS_RULES.md](POLARIS_RULES.md) before beginning any work.\n\n` +
152
- `<!-- genesis doctrine archived: ${genesisPath} -->\n`;
153
- (0, node_fs_1.writeFileSync)(filePath, thinPointer, "utf8");
157
+ if (!dryRun) {
158
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(repoRoot, "smartdocs/doctrine/active"), { recursive: true });
159
+ (0, node_fs_1.writeFileSync)(genesisFullPath, distilled, "utf8");
160
+ const thinPointer = `# Agent Instructions\n\nRead [POLARIS_RULES.md](POLARIS_RULES.md) before beginning any work.\n\n` +
161
+ `<!-- genesis doctrine archived: ${genesisPath} -->\n`;
162
+ (0, node_fs_1.writeFileSync)(filePath, thinPointer, "utf8");
163
+ }
154
164
  results.push({ file, outcome: "compressed", genesisPath });
155
165
  provenanceRecords.push({
156
166
  source_path: file,
@@ -160,6 +170,6 @@ async function reconcileAgentFiles(repoRoot, options) {
160
170
  migration_outcome: "compressed",
161
171
  });
162
172
  }
163
- appendGenesisProvenance(repoRoot, provenanceRecords);
173
+ appendGenesisProvenance(repoRoot, provenanceRecords, dryRun);
164
174
  return results;
165
175
  }
@@ -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,64 +111,100 @@ 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, dryRun = false) {
117
+ const smartdocsDir = (0, node_path_1.join)(repoRoot, "smartdocs");
118
+ if (dryRun) {
119
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(smartdocsDir, "index.md"))) {
120
+ process.stdout.write(`[dry-run] would create smartdocs/index.md\n`);
121
+ }
122
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(smartdocsDir, "log.md"))) {
123
+ process.stdout.write(`[dry-run] would create smartdocs/log.md\n`);
124
+ }
125
+ return;
126
+ }
127
+ (0, node_fs_1.mkdirSync)(smartdocsDir, { recursive: true });
128
+ const indexPath = (0, node_path_1.join)(smartdocsDir, "index.md");
129
+ if (!(0, node_fs_1.existsSync)(indexPath)) {
130
+ (0, node_fs_1.writeFileSync)(indexPath, SMARTDOCS_BUNDLE_INDEX_CONTENT, "utf-8");
131
+ }
132
+ const logPath = (0, node_path_1.join)(smartdocsDir, "log.md");
133
+ if (!(0, node_fs_1.existsSync)(logPath)) {
134
+ (0, node_fs_1.writeFileSync)(logPath, SMARTDOCS_BUNDLE_LOG_CONTENT, "utf-8");
135
+ }
136
+ }
113
137
  function migrateSmartDocs(plan, repoRoot = (0, node_path_1.resolve)(process.cwd())) {
114
138
  const effectivePlan = loadPlan(repoRoot, plan);
115
139
  const provenanceRecords = [];
116
140
  const seenRecords = existingRecordKeys(repoRoot);
117
141
  const now = new Date().toISOString();
142
+ const dryRun = effectivePlan.dry_run ?? false;
118
143
  for (let index = 0; index < effectivePlan.steps.length; index += 1) {
119
144
  const step = effectivePlan.steps[index];
120
145
  if (step.category !== "smartdocs-migrate" || COMPLETE_STATUSES.has(step.status)) {
121
146
  continue;
122
147
  }
123
148
  if (step.routing !== undefined && step.routing !== "candidate") {
124
- effectivePlan.steps[index] = {
125
- ...step,
126
- status: "skipped",
127
- completed_at: now,
128
- error: `routing not candidate: ${step.routing}`,
129
- };
149
+ if (!dryRun) {
150
+ effectivePlan.steps[index] = {
151
+ ...step,
152
+ status: "skipped",
153
+ completed_at: now,
154
+ error: `routing not candidate: ${step.routing}`,
155
+ };
156
+ }
130
157
  continue;
131
158
  }
132
159
  const sourcePath = step.source_path ?? "";
133
160
  const destPath = step.dest_path ?? `smartdocs/raw/${(0, node_path_1.basename)(sourcePath || `step-${step.order}.md`)}`;
134
161
  if (!sourcePath) {
135
- effectivePlan.steps[index] = normalizeStatus(step, "skipped", now);
162
+ if (!dryRun)
163
+ effectivePlan.steps[index] = normalizeStatus(step, "skipped", now);
136
164
  continue;
137
165
  }
138
166
  if (isExcludedSourcePath(sourcePath)) {
139
- effectivePlan.steps[index] = normalizeStatus(step, "skipped", now);
167
+ if (!dryRun)
168
+ effectivePlan.steps[index] = normalizeStatus(step, "skipped", now);
140
169
  continue;
141
170
  }
142
171
  const sourceAbs = (0, node_path_1.join)(repoRoot, sourcePath);
143
172
  const destAbs = (0, node_path_1.join)(repoRoot, destPath);
144
- (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(destAbs), { recursive: true });
145
173
  if (!(0, node_fs_1.existsSync)(sourceAbs)) {
146
174
  if ((0, node_fs_1.existsSync)(destAbs)) {
147
- effectivePlan.steps[index] = normalizeStatus(step, "completed", step.completed_at ?? now);
148
- const record = {
149
- step_id: step.step_id,
150
- source_path: sourcePath,
151
- dest_path: destPath,
152
- migrated_at: step.completed_at ?? now,
153
- migration_run_id: effectivePlan.plan_id,
154
- transport: "reconciled",
155
- };
156
- if (!seenRecords.has(recordKey(record))) {
157
- provenanceRecords.push(record);
158
- seenRecords.add(recordKey(record));
175
+ if (!dryRun) {
176
+ effectivePlan.steps[index] = normalizeStatus(step, "completed", step.completed_at ?? now);
177
+ const record = {
178
+ step_id: step.step_id,
179
+ source_path: sourcePath,
180
+ dest_path: destPath,
181
+ migrated_at: step.completed_at ?? now,
182
+ migration_run_id: effectivePlan.plan_id,
183
+ transport: "reconciled",
184
+ };
185
+ if (!seenRecords.has(recordKey(record))) {
186
+ provenanceRecords.push(record);
187
+ seenRecords.add(recordKey(record));
188
+ }
159
189
  }
160
190
  }
161
191
  else {
162
- effectivePlan.steps[index] = {
163
- ...step,
164
- status: "skipped",
165
- completed_at: now,
166
- error: `source missing: ${sourcePath}`,
167
- };
192
+ if (!dryRun) {
193
+ effectivePlan.steps[index] = {
194
+ ...step,
195
+ status: "skipped",
196
+ completed_at: now,
197
+ error: `source missing: ${sourcePath}`,
198
+ };
199
+ }
168
200
  }
169
201
  continue;
170
202
  }
203
+ if (dryRun) {
204
+ process.stdout.write(`[dry-run] would move ${sourcePath} → ${destPath}\n`);
205
+ continue;
206
+ }
207
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(destAbs), { recursive: true });
171
208
  let transport = "git mv";
172
209
  try {
173
210
  (0, node_child_process_1.execFileSync)("git", ["mv", "--", sourcePath, destPath], {
@@ -194,8 +231,11 @@ function migrateSmartDocs(plan, repoRoot = (0, node_path_1.resolve)(process.cwd(
194
231
  seenRecords.add(recordKey(record));
195
232
  }
196
233
  }
197
- savePlan(repoRoot, effectivePlan);
198
- saveProvenance(repoRoot, provenanceRecords);
199
- Object.assign(plan, effectivePlan);
234
+ scaffoldBundleRoot(repoRoot, dryRun);
235
+ if (!dryRun) {
236
+ savePlan(repoRoot, effectivePlan);
237
+ saveProvenance(repoRoot, provenanceRecords);
238
+ Object.assign(plan, effectivePlan);
239
+ }
200
240
  return Promise.resolve();
201
241
  }
@@ -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
+ // Note: .polaris/clusters/<id>/cluster-state.json is intentionally not a candidate here.
148
+ // It holds the ClusterState schema (child_states, packet_pointers, ...), not the LoopState
149
+ // schema (completed_children, run_id, open_children_meta, ...) that readState()/this
150
+ // function require.
151
+ // Check per-cluster run-state 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 per-cluster state path as default
164
+ return legacyState;
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 {
@@ -14,6 +14,8 @@ const canon_check_js_1 = require("../smartdocs-engine/canon-check.js");
14
14
  const dispatch_boundary_js_1 = require("./dispatch-boundary.js");
15
15
  const ledger_js_1 = require("./ledger.js");
16
16
  const librarian_dispatch_js_1 = require("../cognition/librarian-dispatch.js");
17
+ const index_js_1 = require("../tracker/index.js");
18
+ const lifecycle_transition_js_1 = require("../tracker/lifecycle-transition.js");
17
19
  /** Resolves the expected sealed result file path for a child from its dispatch metadata. */
18
20
  function resolveResultFileForChild(state, childId) {
19
21
  const meta = state.open_children_meta?.[childId];
@@ -206,6 +208,10 @@ function countLoopAbortedEvents(telemetryFile, runId) {
206
208
  .filter((event) => event.event === "loop-aborted" && event.run_id === runId)
207
209
  .length;
208
210
  }
211
+ function appendTelemetryEvent(telemetryFile, event) {
212
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(telemetryFile), { recursive: true });
213
+ (0, node_fs_1.appendFileSync)(telemetryFile, JSON.stringify(event) + "\n", "utf-8");
214
+ }
209
215
  function resolveTelemetryFilePath(state, repoRoot) {
210
216
  const artifactDir = state.artifact_dir ?? (0, node_path_1.join)(repoRoot, ".taskchain_artifacts", "polaris-run");
211
217
  return (0, node_path_1.join)(artifactDir, "runs", state.run_id, "telemetry.jsonl");
@@ -430,6 +436,71 @@ function runLoopContinue(options) {
430
436
  process.exit(1);
431
437
  }
432
438
  }
439
+ // ── Apply lifecycle transition for child-validation-passed event ───────
440
+ // Fire-and-forget: tracker mutations must not block state checkpointing.
441
+ // Policy default targets "in_review" (not "done") — "done" is reserved for
442
+ // the child-merged event, applied once the delivering PR actually merges.
443
+ let transitionAdapter;
444
+ let transitionConfig;
445
+ const transitionTelemetryFile = resolveTelemetryFilePath(state, repoRoot);
446
+ try {
447
+ transitionConfig = (0, loader_js_1.loadConfig)(repoRoot);
448
+ transitionAdapter = (0, index_js_1.loadTrackerAdapter)(transitionConfig);
449
+ }
450
+ catch (err) {
451
+ appendTelemetryEvent(transitionTelemetryFile, {
452
+ event: "lifecycle-transition-error",
453
+ run_id: state.run_id,
454
+ child_id: completedChild,
455
+ transition_event: "child-validation-passed",
456
+ error: `Failed to load config or tracker adapter: ${err instanceof Error ? err.message : String(err)}`,
457
+ timestamp: new Date().toISOString(),
458
+ });
459
+ transitionAdapter = null;
460
+ transitionConfig = null;
461
+ }
462
+ // Note: transitionAdapter may legitimately be null (no tracker adapter configured) —
463
+ // that's not an error, and applyTransitionSafe/applyTransition handle a null adapter
464
+ // by returning a skip result. Only skip the attempt entirely when config loading itself
465
+ // failed (transitionConfig is null).
466
+ if (transitionConfig) {
467
+ new lifecycle_transition_js_1.LifecycleTransitionService()
468
+ .applyTransitionSafe({
469
+ adapter: transitionAdapter,
470
+ policy: transitionConfig.tracker?.lifecyclePolicy,
471
+ taskId: completedChild,
472
+ event: "child-validation-passed",
473
+ evidence: {
474
+ commit: completionCommit,
475
+ validationResults: completionValidation,
476
+ },
477
+ timestamp: new Date().toISOString(),
478
+ })
479
+ .then((result) => {
480
+ appendTelemetryEvent(transitionTelemetryFile, {
481
+ event: "lifecycle-transition-attempt",
482
+ run_id: state.run_id,
483
+ child_id: completedChild,
484
+ transition_event: result.event,
485
+ target_state: result.targetState,
486
+ applied: result.applied,
487
+ skipped: result.skipped,
488
+ skip_reason: result.skipReason,
489
+ error: result.error,
490
+ timestamp: result.timestamp,
491
+ });
492
+ })
493
+ .catch((err) => {
494
+ appendTelemetryEvent(transitionTelemetryFile, {
495
+ event: "lifecycle-transition-error",
496
+ run_id: state.run_id,
497
+ child_id: completedChild,
498
+ transition_event: "child-validation-passed",
499
+ error: err instanceof Error ? err.message : String(err),
500
+ timestamp: new Date().toISOString(),
501
+ });
502
+ });
503
+ }
433
504
  }
434
505
  if (completedChild && completionResultFile) {
435
506
  const telemetryFile = resolveTelemetryFilePath(state, repoRoot);
@@ -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
+ }