sdtk-wiki-kit 0.1.0 → 0.1.1

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.
@@ -12,6 +12,11 @@ const {
12
12
  } = require("./wiki-paths");
13
13
 
14
14
  const REPORT_PREFIX = "compile-dry-run-preview";
15
+ const APPLY_PLAN_PREFIX = "compile-apply-plan";
16
+ const APPLY_PLAN_RECORD_TYPE = "sdtk_wiki_compile_apply_plan";
17
+ const APPLY_PLAN_SCHEMA_VERSION = 1;
18
+ const PERSONAL_BRAIN_RELATIVE = path.join(".sdtk", "wiki", "personal-brain");
19
+ const APPLY_MODE = "create_only_or_same_content_noop";
15
20
  const ALLOWED_OPERATION_TYPES = new Set([
16
21
  "append_section",
17
22
  "create_page",
@@ -27,6 +32,14 @@ function asArray(value) {
27
32
  return Array.isArray(value) ? value : [];
28
33
  }
29
34
 
35
+ function toPosix(value) {
36
+ return String(value || "").replace(/\\/g, "/");
37
+ }
38
+
39
+ function stableHash(value) {
40
+ return require("crypto").createHash("sha256").update(String(value)).digest("hex");
41
+ }
42
+
30
43
  function isRemoteUrl(value) {
31
44
  return /^(?:https?|ftp):\/\//i.test(String(value || ""));
32
45
  }
@@ -59,6 +72,430 @@ function ensureExistingWorkspace(projectPath) {
59
72
  return workspacePath;
60
73
  }
61
74
 
75
+ function firstSourceRef(record) {
76
+ const refs = asArray(record.source_refs || record.sourceRefs);
77
+ return refs.length > 0 ? String(refs[0]) : "semantic_extraction_report";
78
+ }
79
+
80
+ function sourceHashFor(sourceById, sourceId, fallback = "") {
81
+ const source = sourceById.get(sourceId);
82
+ return source && source.source_hash ? String(source.source_hash) : fallback;
83
+ }
84
+
85
+ function refsFor(record) {
86
+ return [
87
+ ...asArray(record.source_refs || record.sourceRefs),
88
+ ...asArray(record.provenance_refs || record.provenanceRefs),
89
+ record.source_logical_path,
90
+ record.source_relative_path,
91
+ ].filter(Boolean).map(String);
92
+ }
93
+
94
+ function sourceRefsFor(record) {
95
+ const refs = asArray(record.source_refs || record.sourceRefs);
96
+ if (record.source_id) refs.unshift(record.source_id);
97
+ return [...new Set(refs.filter(Boolean).map(String))];
98
+ }
99
+
100
+ function provenanceRefsFor(record) {
101
+ return [...new Set(asArray(record.provenance_refs || record.provenanceRefs).filter(Boolean).map(String))];
102
+ }
103
+
104
+ function mdList(values) {
105
+ const items = asArray(values).filter(Boolean).map(String);
106
+ return items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : "- None recorded.";
107
+ }
108
+
109
+ function recordTitle(record, fallback) {
110
+ return String(record.title || record.name || record.topic || record.entity_id || record.concept_id || record.source_id || fallback || "Generated page");
111
+ }
112
+
113
+ function renderSourcePage(source) {
114
+ return [
115
+ `# ${recordTitle(source, "Source")}`,
116
+ "",
117
+ "## Summary",
118
+ "",
119
+ `Local source page generated from ${source.source_logical_path || source.source_relative_path || source.source_id}.`,
120
+ "",
121
+ "## Source Metadata",
122
+ "",
123
+ `- source_id: ${source.source_id}`,
124
+ `- source_relative_path: ${source.source_relative_path || "(missing)"}`,
125
+ `- source_logical_path: ${source.source_logical_path || "(missing)"}`,
126
+ `- source_url: ${source.source_url || "(missing)"}`,
127
+ `- source_hash: ${source.source_hash || "(missing)"}`,
128
+ `- encoding_quality: ${source.encoding_quality || "(unknown)"}`,
129
+ "",
130
+ "## Source Quality",
131
+ "",
132
+ mdList(source.source_quality && source.source_quality.quality_flags),
133
+ "",
134
+ "## Provenance",
135
+ "",
136
+ mdList(provenanceRefsFor(source)),
137
+ "",
138
+ ].join("\n");
139
+ }
140
+
141
+ function renderToolEntityPage(entity) {
142
+ return [
143
+ `# ${recordTitle(entity, "Tool entity")}`,
144
+ "",
145
+ "## Summary",
146
+ "",
147
+ entity.summary || `Generated tool entity page for ${entity.name || entity.entity_id}.`,
148
+ "",
149
+ "## Repository",
150
+ "",
151
+ `- owner: ${entity.repo_owner || "(missing)"}`,
152
+ `- repo: ${entity.repo_name || "(missing)"}`,
153
+ `- url: ${entity.github_url || "(missing)"}`,
154
+ `- category: ${entity.category || "(unknown)"}`,
155
+ "",
156
+ "## Evidence",
157
+ "",
158
+ mdList(sourceRefsFor(entity)),
159
+ "",
160
+ "## Provenance",
161
+ "",
162
+ mdList(provenanceRefsFor(entity)),
163
+ "",
164
+ ].join("\n");
165
+ }
166
+
167
+ function renderConceptPage(concept) {
168
+ return [
169
+ `# ${recordTitle(concept, "Concept")}`,
170
+ "",
171
+ "## Definition",
172
+ "",
173
+ concept.definition || `Generated concept page for ${concept.name || concept.concept_id}.`,
174
+ "",
175
+ "## Aliases",
176
+ "",
177
+ mdList(concept.aliases),
178
+ "",
179
+ "## Related Entities",
180
+ "",
181
+ mdList(concept.related_entities),
182
+ "",
183
+ "## Sources",
184
+ "",
185
+ mdList(sourceRefsFor(concept)),
186
+ "",
187
+ ].join("\n");
188
+ }
189
+
190
+ function renderComparisonPage(comparison) {
191
+ return [
192
+ `# ${recordTitle(comparison, "Comparison")}`,
193
+ "",
194
+ "## Compared Entities",
195
+ "",
196
+ mdList(comparison.compared_entities),
197
+ "",
198
+ "## Criteria",
199
+ "",
200
+ mdList(comparison.criteria),
201
+ "",
202
+ "## Sources",
203
+ "",
204
+ mdList(sourceRefsFor(comparison)),
205
+ "",
206
+ ].join("\n");
207
+ }
208
+
209
+ function renderSynthesisPage(synthesis) {
210
+ return [
211
+ `# ${recordTitle(synthesis, "Synthesis")}`,
212
+ "",
213
+ "## Summary",
214
+ "",
215
+ synthesis.summary || `Generated synthesis page for ${synthesis.topic || synthesis.synthesis_id}.`,
216
+ "",
217
+ "## Recommendations",
218
+ "",
219
+ mdList(synthesis.recommendations),
220
+ "",
221
+ "## Sources",
222
+ "",
223
+ mdList(sourceRefsFor(synthesis)),
224
+ "",
225
+ ].join("\n");
226
+ }
227
+
228
+ function renderRootPage(name, payload) {
229
+ const extractionCounts = payload && payload.source_counts ? payload.source_counts : {};
230
+ const titles = {
231
+ index: "Personal Brain Index",
232
+ overview: "Personal Brain Overview",
233
+ ontology: "Personal Brain Ontology",
234
+ taxonomy: "Personal Brain Taxonomy",
235
+ graph: "Personal Brain Graph",
236
+ log: "Personal Brain Generation Log",
237
+ };
238
+ return [
239
+ `# ${titles[name] || "Personal Brain"}`,
240
+ "",
241
+ "## Generation Context",
242
+ "",
243
+ `- record_type: ${payload.record_type || "(unknown)"}`,
244
+ `- generated_at: ${payload.generated_at || "(unknown)"}`,
245
+ `- sources_scanned: ${extractionCounts.scanned ?? "(unknown)"}`,
246
+ `- sources_indexed: ${extractionCounts.indexed ?? "(unknown)"}`,
247
+ `- tool_candidates: ${asArray(payload.tool_entities).length}`,
248
+ `- concept_candidates: ${asArray(payload.concepts).length}`,
249
+ "",
250
+ "## Structure",
251
+ "",
252
+ "- sources/",
253
+ "- entities/tools/",
254
+ "- concepts/",
255
+ "- comparisons/",
256
+ "- syntheses/",
257
+ "- maintenance/",
258
+ "",
259
+ ].join("\n");
260
+ }
261
+
262
+ function renderMaintenancePage(payload) {
263
+ return [
264
+ "# Extraction Quality Review",
265
+ "",
266
+ "## Source Quality Findings",
267
+ "",
268
+ mdList(asArray(payload.source_quality_findings).map((finding) => `${finding.finding_id || finding.source_id}: ${asArray(finding.quality_flags).join(", ") || "review required"}`)),
269
+ "",
270
+ "## Unsupported Items",
271
+ "",
272
+ mdList(asArray(payload.unsupported_items).map((item) => `${item.item_id || "unsupported"}: ${item.reason || item.raw_observation_summary || "review required"}`)),
273
+ "",
274
+ ].join("\n");
275
+ }
276
+
277
+ function pushCreatePageOperation(operations, raw) {
278
+ if (!raw.target_page_path) return;
279
+ operations.push({
280
+ operation_id: raw.operation_id,
281
+ operation_type: "create_page",
282
+ source_id: raw.source_id || "semantic_extraction_report",
283
+ source_hash: raw.source_hash || "",
284
+ target_page_path: raw.target_page_path,
285
+ content: raw.content || "",
286
+ proposed_content_summary: raw.proposed_content_summary,
287
+ source_refs: raw.source_refs || [],
288
+ provenance_refs: raw.provenance_refs || [],
289
+ evidence_refs: raw.evidence_refs || [],
290
+ confidence: raw.confidence || "medium",
291
+ review_status: raw.review_status || "needs_review",
292
+ });
293
+ }
294
+
295
+ function operationsFromSemanticExtraction(payload) {
296
+ const sourceById = new Map(asArray(payload.sources).map((source) => [String(source.source_id), source]));
297
+ const operations = [];
298
+ const reportHash = stableHash(JSON.stringify({
299
+ generated_at: payload.generated_at,
300
+ source_counts: payload.source_counts,
301
+ })).slice(0, 12);
302
+ const rootSourceId = `semantic_extraction_${reportHash}`;
303
+ const rootSourceHash = reportHash;
304
+
305
+ for (const source of asArray(payload.sources)) {
306
+ pushCreatePageOperation(operations, {
307
+ operation_id: `pb-source-${String(operations.length + 1).padStart(3, "0")}`,
308
+ source_id: source.source_id,
309
+ source_hash: source.source_hash,
310
+ target_page_path: source.target_page_path,
311
+ content: renderSourcePage(source),
312
+ proposed_content_summary: `Create personal-brain source page for ${source.source_logical_path || source.source_relative_path || source.source_id}.`,
313
+ source_refs: sourceRefsFor(source),
314
+ provenance_refs: provenanceRefsFor(source),
315
+ evidence_refs: refsFor(source),
316
+ confidence: source.source_quality && source.source_quality.low_confidence_extraction ? "low" : "medium",
317
+ review_status: source.source_quality && source.source_quality.low_confidence_extraction ? "needs_review" : "ready_for_review",
318
+ });
319
+ }
320
+
321
+ for (const entity of asArray(payload.tool_entities)) {
322
+ const sourceId = firstSourceRef(entity);
323
+ pushCreatePageOperation(operations, {
324
+ operation_id: `pb-tool-${String(operations.length + 1).padStart(3, "0")}`,
325
+ source_id: sourceId,
326
+ source_hash: sourceHashFor(sourceById, sourceId),
327
+ target_page_path: entity.target_page_path,
328
+ content: renderToolEntityPage(entity),
329
+ proposed_content_summary: `Create tool entity page for ${entity.name || entity.entity_id}.`,
330
+ source_refs: sourceRefsFor(entity),
331
+ provenance_refs: provenanceRefsFor(entity),
332
+ evidence_refs: refsFor(entity),
333
+ confidence: entity.confidence_tier || entity.confidence || "medium",
334
+ review_status: "ready_for_review",
335
+ });
336
+ operations.push({
337
+ operation_id: `pb-tool-source-ref-${String(operations.length + 1).padStart(3, "0")}`,
338
+ operation_type: "add_source_ref",
339
+ source_id: sourceId,
340
+ source_hash: sourceHashFor(sourceById, sourceId),
341
+ target_page_path: entity.target_page_path,
342
+ content_sections: [
343
+ {
344
+ title: "Source References",
345
+ body: mdList(sourceRefsFor(entity)),
346
+ },
347
+ ],
348
+ proposed_content_summary: `Add source references for tool entity ${entity.name || entity.entity_id}.`,
349
+ source_refs: sourceRefsFor(entity),
350
+ provenance_refs: provenanceRefsFor(entity),
351
+ evidence_refs: refsFor(entity),
352
+ confidence: entity.confidence_tier || entity.confidence || "medium",
353
+ review_status: "ready_for_review",
354
+ });
355
+ }
356
+
357
+ for (const concept of asArray(payload.concepts)) {
358
+ const sourceId = firstSourceRef(concept);
359
+ pushCreatePageOperation(operations, {
360
+ operation_id: `pb-concept-${String(operations.length + 1).padStart(3, "0")}`,
361
+ source_id: sourceId,
362
+ source_hash: sourceHashFor(sourceById, sourceId),
363
+ target_page_path: concept.target_page_path,
364
+ content: renderConceptPage(concept),
365
+ proposed_content_summary: `Create concept page for ${concept.name || concept.concept_id}.`,
366
+ source_refs: sourceRefsFor(concept),
367
+ provenance_refs: provenanceRefsFor(concept),
368
+ evidence_refs: refsFor(concept),
369
+ confidence: concept.confidence_tier || concept.confidence || "medium",
370
+ review_status: "ready_for_review",
371
+ });
372
+ }
373
+
374
+ for (const comparison of asArray(payload.comparisons)) {
375
+ const sourceId = firstSourceRef(comparison);
376
+ pushCreatePageOperation(operations, {
377
+ operation_id: `pb-comparison-${String(operations.length + 1).padStart(3, "0")}`,
378
+ source_id: sourceId,
379
+ source_hash: sourceHashFor(sourceById, sourceId),
380
+ target_page_path: comparison.target_page_path,
381
+ content: renderComparisonPage(comparison),
382
+ proposed_content_summary: `Create comparison page for ${comparison.topic || comparison.comparison_id}.`,
383
+ source_refs: sourceRefsFor(comparison),
384
+ provenance_refs: provenanceRefsFor(comparison),
385
+ evidence_refs: refsFor(comparison),
386
+ confidence: comparison.confidence_tier || comparison.confidence || "medium",
387
+ review_status: "needs_review",
388
+ });
389
+ }
390
+
391
+ for (const synthesis of asArray(payload.syntheses)) {
392
+ const sourceId = firstSourceRef(synthesis);
393
+ pushCreatePageOperation(operations, {
394
+ operation_id: `pb-synthesis-${String(operations.length + 1).padStart(3, "0")}`,
395
+ source_id: sourceId,
396
+ source_hash: sourceHashFor(sourceById, sourceId),
397
+ target_page_path: synthesis.target_page_path,
398
+ content: renderSynthesisPage(synthesis),
399
+ proposed_content_summary: `Create synthesis page for ${synthesis.topic || synthesis.synthesis_id}.`,
400
+ source_refs: sourceRefsFor(synthesis),
401
+ provenance_refs: provenanceRefsFor(synthesis),
402
+ evidence_refs: refsFor(synthesis),
403
+ confidence: synthesis.confidence_tier || synthesis.confidence || "medium",
404
+ review_status: "needs_review",
405
+ });
406
+ }
407
+
408
+ const rootPages = [
409
+ ["index", ".sdtk/wiki/personal-brain/index.md", "Create personal-brain index from semantic extraction output."],
410
+ ["overview", ".sdtk/wiki/personal-brain/overview.md", "Create personal-brain overview summarizing extracted local sources."],
411
+ ["ontology", ".sdtk/wiki/personal-brain/ontology.md", "Create ontology page describing source, tool_entity, concept, claim, relation, comparison, and synthesis records."],
412
+ ["taxonomy", ".sdtk/wiki/personal-brain/taxonomy.md", "Create taxonomy page for extracted categories and concepts."],
413
+ ["graph", ".sdtk/wiki/personal-brain/graph.md", "Create semantic graph summary page from extracted relations."],
414
+ ["log", ".sdtk/wiki/personal-brain/log.md", "Create generation log page for extraction and compile preview evidence."],
415
+ ];
416
+ for (const [name, target, summary] of rootPages) {
417
+ pushCreatePageOperation(operations, {
418
+ operation_id: `pb-root-${name}`,
419
+ source_id: rootSourceId,
420
+ source_hash: rootSourceHash,
421
+ target_page_path: target,
422
+ content: renderRootPage(name, payload),
423
+ proposed_content_summary: summary,
424
+ evidence_refs: [`record_type:${payload.record_type}`, `generated_at:${payload.generated_at || "(missing)"}`],
425
+ confidence: "medium",
426
+ review_status: "needs_review",
427
+ });
428
+ }
429
+
430
+ for (const relation of asArray(payload.relations)) {
431
+ operations.push({
432
+ operation_id: `pb-relation-${String(operations.length + 1).padStart(3, "0")}`,
433
+ operation_type: "add_relation",
434
+ source_id: relation.source_id || rootSourceId,
435
+ source_hash: rootSourceHash,
436
+ target_page_path: ".sdtk/wiki/personal-brain/graph.md",
437
+ content_sections: [
438
+ {
439
+ title: `Relation: ${relation.relation_type || relation.relation_id}`,
440
+ body: [
441
+ `- source_id: ${relation.source_id || "(missing)"}`,
442
+ `- target_id: ${relation.target_id || "(missing)"}`,
443
+ `- relation_type: ${relation.relation_type || "(missing)"}`,
444
+ `- evidence: ${relation.evidence || "(missing)"}`,
445
+ `- source_refs: ${sourceRefsFor(relation).join(", ") || "(missing)"}`,
446
+ `- provenance_refs: ${provenanceRefsFor(relation).join(", ") || "(missing)"}`,
447
+ ].join("\n"),
448
+ },
449
+ ],
450
+ proposed_content_summary: `Preview semantic relation ${relation.relation_type || relation.relation_id}.`,
451
+ source_refs: sourceRefsFor(relation),
452
+ provenance_refs: provenanceRefsFor(relation),
453
+ evidence_refs: refsFor(relation),
454
+ confidence: relation.confidence_tier || relation.confidence || "medium",
455
+ review_status: "ready_for_review",
456
+ });
457
+ }
458
+
459
+ const qualityFindingCount = asArray(payload.source_quality_findings).length;
460
+ const unsupportedCount = asArray(payload.unsupported_items).length;
461
+ if (qualityFindingCount > 0 || unsupportedCount > 0) {
462
+ pushCreatePageOperation(operations, {
463
+ operation_id: "pb-maintenance-extraction-quality-review",
464
+ source_id: rootSourceId,
465
+ source_hash: rootSourceHash,
466
+ target_page_path: ".sdtk/wiki/personal-brain/maintenance/extraction-quality-review.md",
467
+ content: renderMaintenancePage(payload),
468
+ proposed_content_summary: `Create maintenance review page for ${qualityFindingCount} source-quality finding(s) and ${unsupportedCount} unsupported extraction item(s).`,
469
+ evidence_refs: [`source_quality_findings:${qualityFindingCount}`, `unsupported_items:${unsupportedCount}`],
470
+ confidence: unsupportedCount > 0 ? "low" : "medium",
471
+ review_status: "needs_review",
472
+ });
473
+ }
474
+
475
+ return operations;
476
+ }
477
+
478
+ function emptySourceQualitySummary() {
479
+ return {
480
+ findings_count: 0,
481
+ unsupported_count: 0,
482
+ low_confidence_count: 0,
483
+ warning_count: 0,
484
+ };
485
+ }
486
+
487
+ function sourceQualitySummaryFromExtraction(payload) {
488
+ const findings = asArray(payload.source_quality_findings);
489
+ const unsupported = asArray(payload.unsupported_items);
490
+ const lowConfidence = findings.filter((finding) => ["low", "unsupported"].includes(String(finding.confidence_tier || finding.confidence || "").toLowerCase())).length;
491
+ return {
492
+ findings_count: findings.length,
493
+ unsupported_count: unsupported.length,
494
+ low_confidence_count: lowConfidence,
495
+ warning_count: findings.length + unsupported.length,
496
+ };
497
+ }
498
+
62
499
  function parseJsonPlan(planPath) {
63
500
  let payload;
64
501
  try {
@@ -66,10 +503,25 @@ function parseJsonPlan(planPath) {
66
503
  } catch (error) {
67
504
  throw new ValidationError(`Invalid JSON compile plan: ${error.message}. No project files were changed.`);
68
505
  }
506
+ if (payload && payload.record_type === APPLY_PLAN_RECORD_TYPE) {
507
+ return {
508
+ operations: asArray(payload.operations),
509
+ sourceQualitySummary: payload.source_quality_summary || emptySourceQualitySummary(),
510
+ };
511
+ }
512
+ if (payload && payload.record_type === "sdtk_wiki_semantic_extraction") {
513
+ return {
514
+ operations: operationsFromSemanticExtraction(payload),
515
+ sourceQualitySummary: sourceQualitySummaryFromExtraction(payload),
516
+ };
517
+ }
69
518
  if (!payload || !Array.isArray(payload.operations)) {
70
519
  throw new ValidationError("Invalid compile plan: JSON plan must contain an operations array. No project files were changed.");
71
520
  }
72
- return payload.operations;
521
+ return {
522
+ operations: payload.operations,
523
+ sourceQualitySummary: payload.source_quality_summary || emptySourceQualitySummary(),
524
+ };
73
525
  }
74
526
 
75
527
  function parseMarkdownPlan(planPath) {
@@ -104,7 +556,10 @@ function parseMarkdownPlan(planPath) {
104
556
  "Invalid markdown compile plan: expected structured operation blocks with operation_type. No project files were changed."
105
557
  );
106
558
  }
107
- return operations;
559
+ return {
560
+ operations,
561
+ sourceQualitySummary: emptySourceQualitySummary(),
562
+ };
108
563
  }
109
564
 
110
565
  function parsePlanOperations(planPath) {
@@ -141,9 +596,52 @@ function expectedMutationFor(operation) {
141
596
  }
142
597
  }
143
598
 
144
- function normalizeOperation(rawOperation, index) {
599
+ function renderContentSections(sections) {
600
+ return asArray(sections)
601
+ .map((section) => {
602
+ if (typeof section === "string") return section;
603
+ const title = section && section.title ? String(section.title) : "Generated Section";
604
+ const body = section && section.body ? String(section.body) : "";
605
+ return `## ${title}\n\n${body}`.trimEnd();
606
+ })
607
+ .filter(Boolean)
608
+ .join("\n\n");
609
+ }
610
+
611
+ function fallbackOperationContent(operation) {
612
+ const title = operation.target_page_id || path.basename(operation.target_page_path || "generated-page.md", path.extname(operation.target_page_path || ""));
613
+ return [
614
+ `# ${title}`,
615
+ "",
616
+ "## Generated Operation",
617
+ "",
618
+ operation.proposed_content_summary || "Generated personal-brain content.",
619
+ "",
620
+ "## Evidence",
621
+ "",
622
+ mdList(operation.evidence_refs),
623
+ "",
624
+ ].join("\n");
625
+ }
626
+
627
+ function normalizeTargetPath(rawTargetPath, projectPath) {
628
+ const personalBrainRoot = path.join(getWikiWorkspacePath(projectPath), "personal-brain");
629
+ const targetValue = String(rawTargetPath || "");
630
+ const resolvedTarget = targetValue ? path.resolve(projectPath, targetValue) : "";
631
+ const withinProject = Boolean(resolvedTarget) && isPathInsideOrEqual(resolvedTarget, projectPath);
632
+ const withinPersonalBrain = Boolean(resolvedTarget) && isPathInsideOrEqual(resolvedTarget, personalBrainRoot);
633
+ return {
634
+ resolvedTarget,
635
+ targetPathNormalized: withinProject ? toPosix(path.relative(projectPath, resolvedTarget)) : toPosix(resolvedTarget),
636
+ withinProject,
637
+ withinPersonalBrain,
638
+ };
639
+ }
640
+
641
+ function normalizeOperation(rawOperation, index, projectPath) {
145
642
  const operationType = String(rawOperation.operation_type || rawOperation.operationType || "").trim();
146
643
  const isSupported = ALLOWED_OPERATION_TYPES.has(operationType);
644
+ const target = normalizeTargetPath(rawOperation.target_page_path || rawOperation.targetPagePath || "", projectPath);
147
645
  const operation = {
148
646
  operation_id: String(rawOperation.operation_id || rawOperation.operationId || `operation-${String(index + 1).padStart(3, "0")}`),
149
647
  source_id: String(rawOperation.source_id || rawOperation.sourceId || "unknown_source"),
@@ -151,14 +649,28 @@ function normalizeOperation(rawOperation, index) {
151
649
  revision: String(rawOperation.revision || ""),
152
650
  target_page_id: String(rawOperation.target_page_id || rawOperation.targetPageId || ""),
153
651
  target_page_path: String(rawOperation.target_page_path || rawOperation.targetPagePath || ""),
652
+ target_path_normalized: target.targetPathNormalized,
653
+ target_path_within_project: target.withinProject,
654
+ target_path_within_personal_brain: target.withinPersonalBrain,
154
655
  operation_type: operationType || "unsupported_operation",
155
656
  requested_operation_type: operationType || "(missing)",
156
657
  proposed_content_summary: String(rawOperation.proposed_content_summary || rawOperation.proposedContentSummary || ""),
658
+ content: String(rawOperation.content || ""),
659
+ content_sections: asArray(rawOperation.content_sections || rawOperation.contentSections),
660
+ source_refs: splitRefs(rawOperation.source_refs || rawOperation.sourceRefs || rawOperation.source_id || rawOperation.sourceId),
661
+ provenance_refs: splitRefs(rawOperation.provenance_refs || rawOperation.provenanceRefs),
157
662
  evidence_refs: splitRefs(rawOperation.evidence_refs || rawOperation.evidenceRefs),
158
663
  confidence: String(rawOperation.confidence || "unknown"),
159
664
  review_status: String(rawOperation.review_status || rawOperation.reviewStatus || "needs_review"),
160
665
  validation_status: isSupported ? "supported" : "unsupported_operation",
666
+ apply_mode: rawOperation.apply_mode || rawOperation.applyMode || APPLY_MODE,
161
667
  };
668
+ if (!operation.content && operation.content_sections.length > 0) {
669
+ operation.content = renderContentSections(operation.content_sections);
670
+ }
671
+ if (!operation.content) {
672
+ operation.content = fallbackOperationContent(operation);
673
+ }
162
674
  operation.expected_mutation_if_applied = expectedMutationFor(operation);
163
675
  if (!isSupported) {
164
676
  operation.operation_type = "unsupported_operation";
@@ -175,7 +687,7 @@ function summarizeOperations(operations) {
175
687
  return counts;
176
688
  }
177
689
 
178
- function renderReport({ projectPath, workspacePath, planPath, generatedAt, operations }) {
690
+ function renderReport({ projectPath, workspacePath, planPath, applyPlanPath, generatedAt, operations }) {
179
691
  const summary = summarizeOperations(operations);
180
692
  const sortedTypes = Array.from(new Set([...Array.from(ALLOWED_OPERATION_TYPES), "unsupported_operation"]));
181
693
  const unsupported = operations.filter((operation) => operation.validation_status === "unsupported_operation");
@@ -186,6 +698,7 @@ function renderReport({ projectPath, workspacePath, planPath, generatedAt, opera
186
698
  `Project path: ${projectPath}`,
187
699
  `Workspace path: ${workspacePath}`,
188
700
  `Input plan path: ${planPath}`,
701
+ `Apply JSON sidecar path: ${applyPlanPath}`,
189
702
  `Operations: ${operations.length}`,
190
703
  "",
191
704
  "No wiki pages, raw sources, provenance, or atlas compatibility files were modified.",
@@ -238,6 +751,161 @@ function renderReport({ projectPath, workspacePath, planPath, generatedAt, opera
238
751
  return `${lines.join("\n").trimEnd()}\n`;
239
752
  }
240
753
 
754
+ function renderApplyPlan({ projectPath, planPath, reportPath, generatedAt, operations, sourceQualitySummary }) {
755
+ return {
756
+ schema_version: APPLY_PLAN_SCHEMA_VERSION,
757
+ record_type: APPLY_PLAN_RECORD_TYPE,
758
+ generated_at: generatedAt,
759
+ project_path: projectPath,
760
+ source_plan_path: planPath,
761
+ dry_run_report_path: reportPath,
762
+ operation_counts: summarizeOperations(operations),
763
+ source_quality_summary: sourceQualitySummary || emptySourceQualitySummary(),
764
+ operations: operations.map((operation) => ({
765
+ operation_id: operation.operation_id,
766
+ operation_type: operation.operation_type,
767
+ target_page_path: operation.target_page_path,
768
+ target_path_normalized: operation.target_path_normalized,
769
+ target_path_within_project: operation.target_path_within_project,
770
+ target_path_within_personal_brain: operation.target_path_within_personal_brain,
771
+ content: operation.content,
772
+ content_sections: operation.content_sections,
773
+ source_refs: operation.source_refs,
774
+ provenance_refs: operation.provenance_refs,
775
+ evidence_refs: operation.evidence_refs,
776
+ confidence: operation.confidence,
777
+ review_status: operation.review_status,
778
+ validation_status: operation.validation_status,
779
+ expected_mutation_if_applied: operation.expected_mutation_if_applied,
780
+ apply_mode: APPLY_MODE,
781
+ })),
782
+ };
783
+ }
784
+
785
+ function ensureTrailingNewline(value) {
786
+ const text = String(value || "").replace(/\r\n/g, "\n").trimEnd();
787
+ return `${text}\n`;
788
+ }
789
+
790
+ function loadApplyPlan(planPath) {
791
+ if (path.extname(planPath).toLowerCase() !== ".json") {
792
+ throw new ValidationError("Apply requires a compile apply JSON sidecar. Markdown plans are rejected for apply. No project files were changed.");
793
+ }
794
+ let payload;
795
+ try {
796
+ payload = JSON.parse(fs.readFileSync(planPath, "utf-8"));
797
+ } catch (error) {
798
+ throw new ValidationError(`Invalid compile apply JSON sidecar: ${error.message}. No project files were changed.`);
799
+ }
800
+ if (payload && payload.record_type === "sdtk_wiki_semantic_extraction") {
801
+ throw new ValidationError("Apply rejects semantic extraction JSON. Run compile --dry-run first and apply the generated compile apply JSON sidecar. No project files were changed.");
802
+ }
803
+ if (!payload || payload.record_type !== APPLY_PLAN_RECORD_TYPE) {
804
+ throw new ValidationError(`Apply requires record_type ${APPLY_PLAN_RECORD_TYPE}. No project files were changed.`);
805
+ }
806
+ if (!Array.isArray(payload.operations)) {
807
+ throw new ValidationError("Compile apply JSON sidecar must contain operations[]. No project files were changed.");
808
+ }
809
+ return payload;
810
+ }
811
+
812
+ function validateApplyOperations(operations) {
813
+ const blocked = operations.filter((operation) => (
814
+ operation.validation_status !== "supported" ||
815
+ operation.operation_type === "unsupported_operation" ||
816
+ !ALLOWED_OPERATION_TYPES.has(operation.operation_type) ||
817
+ operation.apply_mode !== APPLY_MODE ||
818
+ !operation.target_path_within_project ||
819
+ !operation.target_path_within_personal_brain
820
+ ));
821
+ if (blocked.length > 0) {
822
+ throw new ValidationError(
823
+ `Compile apply plan contains ${blocked.length} blocked or unsafe operation(s). No project files were changed.`
824
+ );
825
+ }
826
+ }
827
+
828
+ function aggregateApplyTargets(operations, projectPath) {
829
+ const targets = new Map();
830
+ for (const operation of operations) {
831
+ const target = normalizeTargetPath(operation.target_page_path, projectPath);
832
+ if (!target.withinProject || !target.withinPersonalBrain) {
833
+ throw new ValidationError(`Unsafe personal-brain target path: ${operation.target_page_path}. No project files were changed.`);
834
+ }
835
+ const existing = targets.get(target.resolvedTarget) || {
836
+ path: target.resolvedTarget,
837
+ normalized: target.targetPathNormalized,
838
+ sections: [],
839
+ operationIds: [],
840
+ };
841
+ existing.operationIds.push(operation.operation_id);
842
+ existing.sections.push(ensureTrailingNewline(operation.content));
843
+ targets.set(target.resolvedTarget, existing);
844
+ }
845
+ for (const target of targets.values()) {
846
+ target.content = ensureTrailingNewline(target.sections.join("\n"));
847
+ }
848
+ return [...targets.values()].sort((a, b) => a.normalized.localeCompare(b.normalized));
849
+ }
850
+
851
+ function runWikiCompileApply({ projectPath, planArg }) {
852
+ const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
853
+ if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
854
+ throw new ValidationError(`--project-path is not a valid directory: ${resolvedProjectPath}`);
855
+ }
856
+
857
+ try {
858
+ ensureExistingWorkspace(resolvedProjectPath);
859
+ const planPath = resolvePlanPath(planArg, resolvedProjectPath);
860
+ const payload = loadApplyPlan(planPath);
861
+ const operations = payload.operations.map((operation, index) => normalizeOperation(operation, index, resolvedProjectPath));
862
+ validateApplyOperations(operations);
863
+ const targets = aggregateApplyTargets(operations, resolvedProjectPath);
864
+ const conflicts = [];
865
+ const unchanged = [];
866
+ const toCreate = [];
867
+
868
+ for (const target of targets) {
869
+ assertWikiWorkspaceWritePath(target.path, resolvedProjectPath);
870
+ if (!isPathInsideOrEqual(target.path, path.join(getWikiWorkspacePath(resolvedProjectPath), "personal-brain"))) {
871
+ throw new ValidationError(`Refusing to write outside .sdtk/wiki/personal-brain: ${target.normalized}. No project files were changed.`);
872
+ }
873
+ if (!fs.existsSync(target.path)) {
874
+ toCreate.push(target);
875
+ continue;
876
+ }
877
+ const current = fs.readFileSync(target.path, "utf-8").replace(/\r\n/g, "\n");
878
+ if (current === target.content) {
879
+ unchanged.push(target.normalized);
880
+ } else {
881
+ conflicts.push(target.normalized);
882
+ }
883
+ }
884
+
885
+ if (conflicts.length > 0) {
886
+ throw new ValidationError(`Compile apply would overwrite ${conflicts.length} existing different file(s): ${conflicts.join(", ")}. No project files were changed.`);
887
+ }
888
+
889
+ const created = [];
890
+ for (const target of toCreate) {
891
+ fs.mkdirSync(path.dirname(target.path), { recursive: true });
892
+ fs.writeFileSync(target.path, target.content, "utf-8");
893
+ created.push(target.normalized);
894
+ }
895
+
896
+ const sourceQualitySummary = payload.source_quality_summary || emptySourceQualitySummary();
897
+ return {
898
+ planPath,
899
+ created,
900
+ unchanged,
901
+ sourceQualityWarningCount: Number(sourceQualitySummary.warning_count || sourceQualitySummary.findings_count || 0),
902
+ };
903
+ } catch (error) {
904
+ if (error instanceof CliError) throw error;
905
+ throw new CliError(`Failed to apply SDTK-WIKI compile plan: ${error.message}`);
906
+ }
907
+ }
908
+
241
909
  function runWikiCompileDryRun({ projectPath, planArg }) {
242
910
  const resolvedProjectPath = resolveProjectPath(projectPath || process.cwd());
243
911
  if (!fs.existsSync(resolvedProjectPath) || !fs.statSync(resolvedProjectPath).isDirectory()) {
@@ -247,13 +915,17 @@ function runWikiCompileDryRun({ projectPath, planArg }) {
247
915
  try {
248
916
  const workspacePath = ensureExistingWorkspace(resolvedProjectPath);
249
917
  const planPath = resolvePlanPath(planArg, resolvedProjectPath);
250
- const rawOperations = parsePlanOperations(planPath);
251
- const operations = rawOperations.map((operation, index) => normalizeOperation(operation, index));
918
+ const parsed = parsePlanOperations(planPath);
919
+ const rawOperations = parsed.operations;
920
+ const sourceQualitySummary = parsed.sourceQualitySummary || emptySourceQualitySummary();
921
+ const operations = rawOperations.map((operation, index) => normalizeOperation(operation, index, resolvedProjectPath));
252
922
  const reportsPath = getWikiReportsPath(resolvedProjectPath);
253
923
  assertWikiWorkspaceWritePath(reportsPath, resolvedProjectPath);
254
924
  const generatedAt = new Date().toISOString();
255
925
  const reportPath = path.join(reportsPath, `${REPORT_PREFIX}-${todayStamp(new Date(generatedAt))}.md`);
926
+ const applyPlanPath = path.join(reportsPath, `${APPLY_PLAN_PREFIX}-${todayStamp(new Date(generatedAt))}.json`);
256
927
  assertWikiWorkspaceWritePath(reportPath, resolvedProjectPath);
928
+ assertWikiWorkspaceWritePath(applyPlanPath, resolvedProjectPath);
257
929
  fs.mkdirSync(reportsPath, { recursive: true });
258
930
  fs.writeFileSync(
259
931
  reportPath,
@@ -261,14 +933,28 @@ function runWikiCompileDryRun({ projectPath, planArg }) {
261
933
  projectPath: resolvedProjectPath,
262
934
  workspacePath,
263
935
  planPath,
936
+ applyPlanPath,
264
937
  generatedAt,
265
938
  operations,
266
939
  }),
267
940
  "utf-8"
268
941
  );
942
+ fs.writeFileSync(
943
+ applyPlanPath,
944
+ JSON.stringify(renderApplyPlan({
945
+ projectPath: resolvedProjectPath,
946
+ planPath,
947
+ reportPath,
948
+ generatedAt,
949
+ operations,
950
+ sourceQualitySummary,
951
+ }), null, 2) + "\n",
952
+ "utf-8"
953
+ );
269
954
 
270
955
  return {
271
956
  reportPath,
957
+ applyPlanPath,
272
958
  operations,
273
959
  summary: summarizeOperations(operations),
274
960
  unsupportedCount: operations.filter((operation) => operation.validation_status === "unsupported_operation").length,
@@ -280,8 +966,10 @@ function runWikiCompileDryRun({ projectPath, planArg }) {
280
966
  }
281
967
 
282
968
  module.exports = {
969
+ APPLY_PLAN_RECORD_TYPE,
283
970
  ALLOWED_OPERATION_TYPES,
284
971
  REPORT_PREFIX,
285
972
  renderReport,
973
+ runWikiCompileApply,
286
974
  runWikiCompileDryRun,
287
975
  };