project-librarian 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/migration.js CHANGED
@@ -33,11 +33,18 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.generatedMigrationInboxFiles = void 0;
36
37
  exports.classifyMarkdown = classifyMarkdown;
38
+ exports.formOnlyMigrationDocumentReason = formOnlyMigrationDocumentReason;
37
39
  exports.extractMigrationUnits = extractMigrationUnits;
38
40
  exports.collectMigrationCoverageDiagnostics = collectMigrationCoverageDiagnostics;
41
+ exports.collectMigrationUnitMapDiagnostics = collectMigrationUnitMapDiagnostics;
42
+ exports.collectMigrationSplitPlanDiagnostics = collectMigrationSplitPlanDiagnostics;
39
43
  exports.markdownTableRows = markdownTableRows;
40
44
  exports.buildInbox = buildInbox;
45
+ exports.isPrunableGeneratedMigrationInbox = isPrunableGeneratedMigrationInbox;
46
+ exports.migrationSemanticReviewComplete = migrationSemanticReviewComplete;
47
+ exports.buildMigrationBulkReviewPlan = buildMigrationBulkReviewPlan;
41
48
  exports.timestampSuffix = timestampSuffix;
42
49
  exports.prepareMigrationMode = prepareMigrationMode;
43
50
  exports.migrationTargetForKind = migrationTargetForKind;
@@ -45,9 +52,11 @@ exports.runMigrationMode = runMigrationMode;
45
52
  exports.normalizeMigrationStatus = normalizeMigrationStatus;
46
53
  exports.isMigrationInboxStatus = isMigrationInboxStatus;
47
54
  exports.migrationInboxStatusMap = migrationInboxStatusMap;
55
+ exports.migrationCoverageStatusMap = migrationCoverageStatusMap;
48
56
  exports.semanticStatusForInboxStatus = semanticStatusForInboxStatus;
49
57
  exports.runReviewMigrationMode = runReviewMigrationMode;
50
58
  const fs = __importStar(require("node:fs"));
59
+ const taxonomy_1 = require("./taxonomy");
51
60
  const workspace_1 = require("./workspace");
52
61
  const templates_1 = require("./templates");
53
62
  const wiki_files_1 = require("./wiki-files");
@@ -69,6 +78,9 @@ function classifyMarkdown(relativePath, text) {
69
78
  function markdownTableCell(value) {
70
79
  return String(value).replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
71
80
  }
81
+ function plainMarkdownTableCell(value) {
82
+ return markdownTableCell(String(value).replace(/\[/g, "&#91;").replace(/\]/g, "&#93;"));
83
+ }
72
84
  function slugPart(value) {
73
85
  return value.toLowerCase().replace(/[^a-z0-9가-힣ぁ-んァ-ン一-龥]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "unit";
74
86
  }
@@ -78,24 +90,65 @@ function unitSummary(value) {
78
90
  function nextUnitId(legacyPath, index, summary) {
79
91
  return `${legacyPath}#u${String(index).padStart(3, "0")}-${slugPart(summary)}`;
80
92
  }
93
+ const generatedEmptyStarterPaths = new Map([
94
+ ["canonical/project-brief.md", "wiki/canonical/project-brief.md"],
95
+ ["canonical/open-questions.md", "wiki/canonical/open-questions.md"],
96
+ ["canonical/assumptions.md", "wiki/canonical/assumptions.md"],
97
+ ["canonical/risks.md", "wiki/canonical/risks.md"],
98
+ ]);
99
+ function normalizedTemplateBody(value) {
100
+ return (0, workspace_1.stripMetadataHeader)(value).replace(/\r\n/g, "\n").trim().replace(/[ \t]+/g, " ");
101
+ }
102
+ function isGeneratedEmptyStarterDocument(legacyPath, text) {
103
+ const starterPath = generatedEmptyStarterPaths.get(legacyPath.replace(/^wiki\//, ""));
104
+ if (!starterPath)
105
+ return false;
106
+ const starter = templates_1.starterFiles[starterPath];
107
+ if (!starter)
108
+ return false;
109
+ return normalizedTemplateBody(text) === normalizedTemplateBody(starter);
110
+ }
111
+ function formOnlyMigrationDocumentReason(legacyPath, text) {
112
+ if ((0, workspace_1.metadataValue)(text, "status").toLowerCase() === "template")
113
+ return "metadata status is template";
114
+ if (isGeneratedEmptyStarterDocument(legacyPath, text))
115
+ return "matches generated empty starter page";
116
+ return "";
117
+ }
118
+ function isFormOnlyTemplateRouteUnit(legacyPath, value) {
119
+ if (legacyPath.replace(/^wiki\//, "") !== "index.md")
120
+ return false;
121
+ return /\bdecisions\/(?:decision-pack-template|full-adr-template)\b/.test(value);
122
+ }
81
123
  function extractMigrationUnits(legacyPath, text) {
124
+ if (formOnlyMigrationDocumentReason(legacyPath, text))
125
+ return [];
82
126
  const body = (0, workspace_1.stripMetadataHeader)(text);
83
127
  const lines = body.split(/\r?\n/);
84
128
  const units = [];
85
129
  let heading = "";
130
+ let headingPath = [];
86
131
  let paragraph = [];
87
132
  let inCodeFence = false;
88
133
  let codeBlock = [];
134
+ let sourceUnitIndex = 0;
89
135
  const pushUnit = (type, value) => {
90
136
  const summary = unitSummary(value);
91
137
  if (!summary)
92
138
  return;
139
+ sourceUnitIndex += 1;
140
+ if (isFormOnlyTemplateRouteUnit(legacyPath, value))
141
+ return;
142
+ const unitHeadingPath = [...headingPath];
93
143
  units.push({
94
- id: nextUnitId(legacyPath, units.length + 1, summary),
144
+ id: nextUnitId(legacyPath, sourceUnitIndex, summary),
95
145
  legacyPath,
96
146
  type,
97
147
  heading,
148
+ headingPath: unitHeadingPath,
149
+ content: value,
98
150
  summary,
151
+ classification: (0, taxonomy_1.classifyMigrationUnit)({ legacyPath, heading, headingPath: unitHeadingPath, content: value, summary }),
99
152
  });
100
153
  };
101
154
  const flushParagraph = () => {
@@ -132,6 +185,8 @@ function extractMigrationUnits(legacyPath, text) {
132
185
  if (headingMatch?.[2]) {
133
186
  flushParagraph();
134
187
  heading = headingMatch[2].trim();
188
+ const level = headingMatch[1]?.length ?? 1;
189
+ headingPath = [...headingPath.slice(0, level - 1), heading];
135
190
  pushUnit("heading", heading);
136
191
  continue;
137
192
  }
@@ -154,8 +209,89 @@ function extractMigrationUnits(legacyPath, text) {
154
209
  }
155
210
  function coverageTableRows(units) {
156
211
  if (units.length === 0)
157
- return "| none | - | - | - | - | pending | - | - |\n";
158
- return units.map((unit) => `| ${markdownTableCell(unit.id)} | ${markdownTableCell(unit.legacyPath)} | ${unit.type} | ${markdownTableCell(unit.heading || "-")} | ${markdownTableCell(unit.summary)} | pending | - | - |`).join("\n") + "\n";
212
+ return "| none | - | - | - | - | pending | - | - | - | - | - |\n";
213
+ return units.map((unit) => {
214
+ const status = unit.classification.confidence === "low" ? "needs-human-review" : "pending";
215
+ return `| ${markdownTableCell(unit.id)} | ${markdownTableCell(unit.legacyPath)} | ${unit.type} | ${plainMarkdownTableCell(unit.heading || "-")} | ${plainMarkdownTableCell(unit.summary)} | ${status} | ${markdownTableCell(unit.classification.target)} | - | ${markdownTableCell(unit.classification.label)} | ${unit.classification.confidence} | ${plainMarkdownTableCell(unit.classification.reason)} |`;
216
+ }).join("\n") + "\n";
217
+ }
218
+ function coverageRowsFromUnits(units) {
219
+ return units.map((unit) => ({
220
+ unitId: unit.id,
221
+ legacySource: unit.legacyPath,
222
+ type: unit.type,
223
+ heading: unit.heading || "-",
224
+ summary: unit.summary,
225
+ status: unit.classification.confidence === "low" ? "needs-human-review" : "pending",
226
+ target: unit.classification.target,
227
+ note: "-",
228
+ area: unit.classification.label,
229
+ confidence: unit.classification.confidence,
230
+ reason: unit.classification.reason,
231
+ }));
232
+ }
233
+ function normalizeCoverageStatus(value) {
234
+ const status = String(value || "").trim().toLowerCase();
235
+ if (isMigrationCoverageStatus(status))
236
+ return status;
237
+ return "pending";
238
+ }
239
+ function normalizeConfidence(value) {
240
+ const confidence = String(value || "").trim().toLowerCase();
241
+ if (isMigrationConfidence(confidence))
242
+ return confidence;
243
+ return "low";
244
+ }
245
+ function parseMigrationCoverageRows(text) {
246
+ return (0, wiki_files_1.parseMarkdownTableRows)(text, 11)
247
+ .filter((cells) => cells[0] !== "Unit ID")
248
+ .filter((cells) => !isMarkdownTableSeparatorRow(cells))
249
+ .map((cells) => ({
250
+ unitId: cells[0] ?? "",
251
+ legacySource: cells[1] ?? "",
252
+ type: cells[2] ?? "",
253
+ heading: cells[3] ?? "",
254
+ summary: cells[4] ?? "",
255
+ status: normalizeCoverageStatus(cells[5]),
256
+ target: cells[6] ?? "",
257
+ note: cells[7] ?? "",
258
+ area: cells[8] ?? "",
259
+ confidence: normalizeConfidence(cells[9]),
260
+ reason: cells[10] ?? "",
261
+ }))
262
+ .filter((row) => row.unitId);
263
+ }
264
+ function unitMapRows(units) {
265
+ if (units.length === 0)
266
+ return "| none | - | - | - | - | - | - | - | - | - |\n";
267
+ return units.map((unit) => {
268
+ const status = unit.classification.confidence === "low" ? "needs-human-review" : "pending";
269
+ return `| ${markdownTableCell(unit.id)} | ${markdownTableCell(unit.legacyPath)} | ${plainMarkdownTableCell(unit.headingPath.join(" > ") || "-")} | ${markdownTableCell(unit.classification.label)} | ${unit.classification.storage} | ${unit.classification.confidence} | ${markdownTableCell(unit.classification.target)} | ${plainMarkdownTableCell(unit.classification.reason)} | ${plainMarkdownTableCell(unit.summary)} | ${status} |`;
270
+ }).join("\n") + "\n";
271
+ }
272
+ function confidenceRank(value) {
273
+ if (value === "high")
274
+ return 3;
275
+ if (value === "medium")
276
+ return 2;
277
+ return 1;
278
+ }
279
+ function splitPlanRows(units) {
280
+ if (units.length === 0)
281
+ return "| none | - | - | - | 0 | - |\n";
282
+ const groups = new Map();
283
+ for (const unit of units) {
284
+ const key = unit.classification.target;
285
+ groups.set(key, [...(groups.get(key) ?? []), unit]);
286
+ }
287
+ return Array.from(groups.entries())
288
+ .sort(([left], [right]) => left.localeCompare(right))
289
+ .map(([target, group]) => {
290
+ const first = group[0];
291
+ const confidence = group.map((unit) => unit.classification.confidence).sort((left, right) => confidenceRank(left) - confidenceRank(right))[0] ?? "low";
292
+ const unitIds = group.map((unit) => unit.id).join("<br>");
293
+ return `| ${markdownTableCell(target)} | ${markdownTableCell(first?.classification.label ?? "-")} | ${first?.classification.storage ?? "-"} | ${confidence} | ${group.length} | ${markdownTableCell(unitIds)} |`;
294
+ }).join("\n") + "\n";
159
295
  }
160
296
  function isMigrationCoverageStatus(value) {
161
297
  return ["adopted", "merged", "superseded", "rejected", "resolved", "needs-human-review", "pending"].includes(value);
@@ -168,11 +304,55 @@ function legacyWikiRoots() {
168
304
  .map((entry) => entry.name)
169
305
  .sort();
170
306
  }
307
+ function migrationVerificationLegacyRoot() {
308
+ if (!(0, workspace_1.exists)("wiki/migration/verification.md"))
309
+ return null;
310
+ const legacyRoot = ((0, workspace_1.read)("wiki/migration/verification.md").match(/^- legacy root:\s*(.+)$/m) || [])[1]?.trim();
311
+ if (!legacyRoot || ["none", "unknown"].includes(legacyRoot))
312
+ return null;
313
+ return (0, workspace_1.exists)(legacyRoot) ? legacyRoot : null;
314
+ }
315
+ function activeMigrationLegacyRoots() {
316
+ const verifiedRoot = migrationVerificationLegacyRoot();
317
+ return verifiedRoot ? [verifiedRoot] : legacyWikiRoots();
318
+ }
171
319
  function expectedMigrationUnits() {
172
- return legacyWikiRoots()
320
+ return activeMigrationLegacyRoots()
173
321
  .flatMap((legacyRoot) => (0, wiki_files_1.walkMarkdownFiles)((0, workspace_1.abs)(legacyRoot), [], (0, workspace_1.abs)(legacyRoot)))
174
322
  .flatMap((file) => extractMigrationUnits(file.basePath, (0, workspace_1.read)(file.path)));
175
323
  }
324
+ function isMigrationConfidence(value) {
325
+ return ["high", "medium", "low"].includes(value);
326
+ }
327
+ function isMigrationStorage(value) {
328
+ return ["canonical", "decisions", "sources", "meta"].includes(value);
329
+ }
330
+ exports.generatedMigrationInboxFiles = [
331
+ "wiki/canonical/migration-inbox.md",
332
+ "wiki/decisions/migration-inbox.md",
333
+ "wiki/sources/migration-inbox.md",
334
+ ];
335
+ function isNewWikiTarget(value) {
336
+ return /^wiki\/(canonical|decisions|sources|meta)\//.test(value);
337
+ }
338
+ function expectedUnitMap(units) {
339
+ return new Map(units.map((unit) => [unit.id, unit]));
340
+ }
341
+ function splitLegacyUnitsCell(value) {
342
+ return value
343
+ .split(/<br\s*\/?>|,/i)
344
+ .map((item) => item.trim())
345
+ .filter(Boolean);
346
+ }
347
+ function isMarkdownTableSeparatorRow(cells) {
348
+ return cells.every((cell) => /^:?-+:?$/.test(cell.replace(/\s/g, "")));
349
+ }
350
+ function isReviewedCoverageRetarget(cells) {
351
+ const note = String(cells[7] || "").toLowerCase();
352
+ const reason = String(cells[10] || "").toLowerCase();
353
+ return note.includes("reviewed low-confidence content; retargeted")
354
+ || reason.includes("reviewed source context; taxonomy target assigned");
355
+ }
176
356
  function collectMigrationCoverageDiagnostics() {
177
357
  const units = expectedMigrationUnits();
178
358
  if (units.length === 0)
@@ -187,12 +367,20 @@ function collectMigrationCoverageDiagnostics() {
187
367
  }
188
368
  const diagnostics = [];
189
369
  const expectedIds = new Set(units.map((unit) => unit.id));
370
+ const unitsById = expectedUnitMap(units);
190
371
  const seenIds = new Set();
191
- const rows = (0, wiki_files_1.parseMarkdownTableRows)((0, workspace_1.read)("wiki/migration/coverage.md"), 8).filter((cells) => cells[0] !== "Unit ID");
372
+ const rows = (0, wiki_files_1.parseMarkdownTableRows)((0, workspace_1.read)("wiki/migration/coverage.md"), 8)
373
+ .filter((cells) => cells[0] !== "Unit ID")
374
+ .filter((cells) => !isMarkdownTableSeparatorRow(cells));
192
375
  for (const cells of rows) {
193
376
  const id = cells[0] || "";
194
377
  const status = String(cells[5] || "").trim().toLowerCase();
195
378
  const target = String(cells[6] || "").trim();
379
+ const confidence = String(cells[9] || "").trim().toLowerCase();
380
+ const expectedUnit = unitsById.get(id);
381
+ if (cells.length < 11) {
382
+ diagnostics.push({ code: "migration-coverage-schema-drift", severity: "error", file: "wiki/migration/coverage.md", message: `coverage row for ${id || "(blank)"} is missing Area, Confidence, or Reason columns; rerun --migrate` });
383
+ }
196
384
  if (seenIds.has(id)) {
197
385
  diagnostics.push({ code: "migration-duplicate-unit", severity: "error", file: "wiki/migration/coverage.md", message: `duplicate migration unit row: ${id}` });
198
386
  }
@@ -203,9 +391,18 @@ function collectMigrationCoverageDiagnostics() {
203
391
  if (!isMigrationCoverageStatus(status)) {
204
392
  diagnostics.push({ code: "migration-invalid-status", severity: "error", file: "wiki/migration/coverage.md", message: `unit ${id} has invalid status: ${status || "(blank)"}` });
205
393
  }
206
- if (["adopted", "merged"].includes(status) && !/^wiki\/(canonical|decisions|sources|meta)\//.test(target)) {
394
+ if (confidence && !isMigrationConfidence(confidence)) {
395
+ diagnostics.push({ code: "migration-invalid-confidence", severity: "error", file: "wiki/migration/coverage.md", message: `unit ${id} has invalid confidence: ${confidence}` });
396
+ }
397
+ if (["adopted", "merged"].includes(status) && !isNewWikiTarget(target)) {
207
398
  diagnostics.push({ code: "migration-missing-target", severity: "error", file: "wiki/migration/coverage.md", message: `unit ${id} is ${status} but target is not a new wiki page` });
208
399
  }
400
+ if (status === "pending" && expectedUnit && target && target !== expectedUnit.classification.target && !isReviewedCoverageRetarget(cells)) {
401
+ diagnostics.push({ code: "migration-pending-target-drift", severity: "warn", file: "wiki/migration/coverage.md", message: `pending unit ${id} target differs from generated taxonomy target ${expectedUnit.classification.target}` });
402
+ }
403
+ if (["adopted", "merged"].includes(status) && confidence === "low") {
404
+ diagnostics.push({ code: "migration-low-confidence-adopted", severity: "warn", file: "wiki/migration/coverage.md", message: `unit ${id} is ${status} despite low confidence classification; verify the target manually` });
405
+ }
209
406
  if (/\bwiki_legacy(?:_|\b|\/)/.test(target)) {
210
407
  diagnostics.push({ code: "migration-legacy-target", severity: "error", file: "wiki/migration/coverage.md", message: `unit ${id} targets wiki_legacy* instead of migrated new-wiki truth` });
211
408
  }
@@ -220,10 +417,150 @@ function collectMigrationCoverageDiagnostics() {
220
417
  }
221
418
  return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code) || a.message.localeCompare(b.message));
222
419
  }
420
+ function collectMigrationUnitMapDiagnostics() {
421
+ const units = expectedMigrationUnits();
422
+ if (units.length === 0)
423
+ return [];
424
+ const file = "wiki/migration/unit-map.md";
425
+ if (!(0, workspace_1.exists)(file)) {
426
+ return [{
427
+ code: "migration-unit-map-missing",
428
+ severity: "error",
429
+ file,
430
+ message: "migration unit map is missing; run --migrate to classify legacy meaning units",
431
+ }];
432
+ }
433
+ const diagnostics = [];
434
+ const unitsById = expectedUnitMap(units);
435
+ const seenIds = new Set();
436
+ const rows = (0, wiki_files_1.parseMarkdownTableRows)((0, workspace_1.read)(file), 10)
437
+ .filter((cells) => cells[0] !== "Unit ID")
438
+ .filter((cells) => !isMarkdownTableSeparatorRow(cells));
439
+ for (const cells of rows) {
440
+ const id = cells[0] || "";
441
+ const area = String(cells[3] || "").trim();
442
+ const storage = String(cells[4] || "").trim();
443
+ const confidence = String(cells[5] || "").trim().toLowerCase();
444
+ const target = String(cells[6] || "").trim();
445
+ const status = String(cells[9] || "").trim().toLowerCase();
446
+ const expectedUnit = unitsById.get(id);
447
+ if (seenIds.has(id)) {
448
+ diagnostics.push({ code: "migration-unit-map-duplicate-unit", severity: "error", file, message: `duplicate unit-map row: ${id}` });
449
+ }
450
+ seenIds.add(id);
451
+ if (!expectedUnit) {
452
+ diagnostics.push({ code: "migration-unit-map-stale-unit", severity: "warn", file, message: `unit-map row does not match current migration batch: ${id}` });
453
+ }
454
+ if (!isMigrationStorage(storage)) {
455
+ diagnostics.push({ code: "migration-unit-map-invalid-storage", severity: "error", file, message: `unit ${id} has invalid storage: ${storage || "(blank)"}` });
456
+ }
457
+ if (!isMigrationConfidence(confidence)) {
458
+ diagnostics.push({ code: "migration-unit-map-invalid-confidence", severity: "error", file, message: `unit ${id} has invalid confidence: ${confidence || "(blank)"}` });
459
+ }
460
+ if (!isMigrationCoverageStatus(status)) {
461
+ diagnostics.push({ code: "migration-unit-map-invalid-status", severity: "error", file, message: `unit ${id} has invalid status: ${status || "(blank)"}` });
462
+ }
463
+ if (!isNewWikiTarget(target)) {
464
+ diagnostics.push({ code: "migration-unit-map-invalid-target", severity: "error", file, message: `unit ${id} target is not under wiki/canonical, wiki/decisions, wiki/sources, or wiki/meta` });
465
+ }
466
+ if (expectedUnit) {
467
+ if (area !== expectedUnit.classification.label) {
468
+ diagnostics.push({ code: "migration-unit-map-area-drift", severity: "warn", file, message: `unit ${id} area differs from generated classification ${expectedUnit.classification.label}` });
469
+ }
470
+ if (storage !== expectedUnit.classification.storage) {
471
+ diagnostics.push({ code: "migration-unit-map-storage-drift", severity: "warn", file, message: `unit ${id} storage differs from generated classification ${expectedUnit.classification.storage}` });
472
+ }
473
+ if (confidence !== expectedUnit.classification.confidence) {
474
+ diagnostics.push({ code: "migration-unit-map-confidence-drift", severity: "warn", file, message: `unit ${id} confidence differs from generated classification ${expectedUnit.classification.confidence}` });
475
+ }
476
+ if (target !== expectedUnit.classification.target) {
477
+ diagnostics.push({ code: "migration-unit-map-target-drift", severity: "warn", file, message: `unit ${id} target differs from generated classification ${expectedUnit.classification.target}` });
478
+ }
479
+ }
480
+ }
481
+ for (const unit of units) {
482
+ if (!seenIds.has(unit.id)) {
483
+ diagnostics.push({ code: "migration-unit-map-unaccounted-unit", severity: "error", file: unit.legacyPath, message: `legacy meaning unit missing from unit map: ${unit.id}` });
484
+ }
485
+ }
486
+ return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code) || a.message.localeCompare(b.message));
487
+ }
488
+ function collectMigrationSplitPlanDiagnostics() {
489
+ const units = expectedMigrationUnits();
490
+ if (units.length === 0)
491
+ return [];
492
+ const file = "wiki/migration/split-plan.md";
493
+ if (!(0, workspace_1.exists)(file)) {
494
+ return [{
495
+ code: "migration-split-plan-missing",
496
+ severity: "error",
497
+ file,
498
+ message: "migration split plan is missing; run --migrate to group legacy units by target page",
499
+ }];
500
+ }
501
+ const diagnostics = [];
502
+ const unitsById = expectedUnitMap(units);
503
+ const seenTargets = new Set();
504
+ const seenUnitIds = new Set();
505
+ const rows = (0, wiki_files_1.parseMarkdownTableRows)((0, workspace_1.read)(file), 6)
506
+ .filter((cells) => cells[0] !== "Suggested Target")
507
+ .filter((cells) => !isMarkdownTableSeparatorRow(cells));
508
+ for (const cells of rows) {
509
+ const target = String(cells[0] || "").trim();
510
+ const area = String(cells[1] || "").trim();
511
+ const storage = String(cells[2] || "").trim();
512
+ const confidence = String(cells[3] || "").trim().toLowerCase();
513
+ const count = Number(String(cells[4] || "").trim());
514
+ const unitIds = splitLegacyUnitsCell(cells[5] || "");
515
+ const targetStorage = target.match(/^wiki\/([^/]+)\//)?.[1] ?? "";
516
+ if (seenTargets.has(target)) {
517
+ diagnostics.push({ code: "migration-split-plan-duplicate-target", severity: "error", file, message: `duplicate split-plan target row: ${target}` });
518
+ }
519
+ seenTargets.add(target);
520
+ if (!isNewWikiTarget(target)) {
521
+ diagnostics.push({ code: "migration-split-plan-invalid-target", severity: "error", file, message: `split-plan target is not under wiki/canonical, wiki/decisions, wiki/sources, or wiki/meta: ${target || "(blank)"}` });
522
+ }
523
+ if (!isMigrationStorage(storage)) {
524
+ diagnostics.push({ code: "migration-split-plan-invalid-storage", severity: "error", file, message: `target ${target || "(blank)"} has invalid storage: ${storage || "(blank)"}` });
525
+ }
526
+ if (isMigrationStorage(storage) && targetStorage && storage !== targetStorage) {
527
+ diagnostics.push({ code: "migration-split-plan-storage-target-mismatch", severity: "error", file, message: `target ${target} is under wiki/${targetStorage}/ but split-plan storage is ${storage}` });
528
+ }
529
+ if (!isMigrationConfidence(confidence)) {
530
+ diagnostics.push({ code: "migration-split-plan-invalid-confidence", severity: "error", file, message: `target ${target || "(blank)"} has invalid confidence: ${confidence || "(blank)"}` });
531
+ }
532
+ if (!Number.isInteger(count) || count !== unitIds.length) {
533
+ diagnostics.push({ code: "migration-split-plan-count-mismatch", severity: "error", file, message: `target ${target || "(blank)"} says ${Number.isFinite(count) ? count : "(blank)"} units but lists ${unitIds.length}` });
534
+ }
535
+ for (const unitId of unitIds) {
536
+ const expectedUnit = unitsById.get(unitId);
537
+ if (seenUnitIds.has(unitId)) {
538
+ diagnostics.push({ code: "migration-split-plan-duplicate-unit", severity: "error", file, message: `unit appears in more than one split-plan row: ${unitId}` });
539
+ }
540
+ seenUnitIds.add(unitId);
541
+ if (!expectedUnit) {
542
+ diagnostics.push({ code: "migration-split-plan-stale-unit", severity: "warn", file, message: `split-plan unit does not match current migration batch: ${unitId}` });
543
+ continue;
544
+ }
545
+ if (expectedUnit.classification.target !== target) {
546
+ diagnostics.push({ code: "migration-split-plan-target-drift", severity: "warn", file, message: `unit ${unitId} is listed under ${target} but generated classification targets ${expectedUnit.classification.target}` });
547
+ }
548
+ if (expectedUnit.classification.label !== area) {
549
+ diagnostics.push({ code: "migration-split-plan-area-drift", severity: "warn", file, message: `unit ${unitId} row area differs from generated classification ${expectedUnit.classification.label}` });
550
+ }
551
+ }
552
+ }
553
+ for (const unit of units) {
554
+ if (!seenUnitIds.has(unit.id)) {
555
+ diagnostics.push({ code: "migration-split-plan-unaccounted-unit", severity: "error", file: unit.legacyPath, message: `legacy meaning unit missing from split plan: ${unit.id}` });
556
+ }
557
+ }
558
+ return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code) || a.message.localeCompare(b.message));
559
+ }
223
560
  function markdownTableRows(items) {
224
561
  if (items.length === 0)
225
562
  return "| none | - | - | - |\n";
226
- return items.map((item) => `| ${markdownTableCell(item.path)} | ${markdownTableCell(item.title)} | ${markdownTableCell(item.summary)} | pending |`).join("\n") + "\n";
563
+ return items.map((item) => `| ${markdownTableCell(item.path)} | ${plainMarkdownTableCell(item.title)} | ${plainMarkdownTableCell(item.summary)} | pending |`).join("\n") + "\n";
227
564
  }
228
565
  function buildInbox(title, description, items) {
229
566
  return `${(0, templates_1.metadata)("migration-inbox", "medium", "wiki/meta/wiki-ops-v1-decisions.md", "migration candidates are adopted or rescanned")}
@@ -240,6 +577,34 @@ function buildInbox(title, description, items) {
240
577
  | --- | --- | --- | --- |
241
578
  ${markdownTableRows(items)}`;
242
579
  }
580
+ function wikiLinkForMigrationPath(relativePath) {
581
+ return `[[${relativePath.replace(/^wiki\//, "").replace(/\.(md|mdx)$/i, "")}]]`;
582
+ }
583
+ function isGeneratedFileLevelMigrationInboxText(text) {
584
+ if ((0, workspace_1.metadataValue)(text, "scope") !== "migration-inbox")
585
+ return false;
586
+ const body = (0, workspace_1.stripMetadataHeader)(text).trim();
587
+ if (!body.includes("| Source | Title | Summary | Status |"))
588
+ return false;
589
+ if (!body.includes("Status values: pending, adopted, rejected, resolved, needs-human-review."))
590
+ return false;
591
+ const headings = Array.from(body.matchAll(/^#{1,6}\s+(.+)$/gm)).map((match) => match[1]?.trim() ?? "");
592
+ if (headings.length !== 2)
593
+ return false;
594
+ return (headings[0]?.endsWith("Migration Inbox") ?? false) && headings[1] === "TL;DR";
595
+ }
596
+ function isPrunableGeneratedMigrationInbox(relativePath) {
597
+ if (!exports.generatedMigrationInboxFiles.includes(relativePath))
598
+ return false;
599
+ if (!(0, workspace_1.exists)(relativePath))
600
+ return false;
601
+ return isGeneratedFileLevelMigrationInboxText((0, workspace_1.read)(relativePath));
602
+ }
603
+ function migrationSemanticReviewComplete() {
604
+ if (!(0, workspace_1.exists)("wiki/migration/verification.md"))
605
+ return false;
606
+ return /^- semantic migration complete:\s*yes, for\b/m.test((0, workspace_1.read)("wiki/migration/verification.md"));
607
+ }
243
608
  function migrationBatchScope(legacyRoot) {
244
609
  return `${workspace_1.today} migration batch${legacyRoot && legacyRoot !== "none" ? ` from ${legacyRoot}` : ""}`;
245
610
  }
@@ -256,6 +621,325 @@ function completionScopeSection(batchScope) {
256
621
  - For a fresh rebuild request, treat current \`wiki/\` as the legacy source unless the user says otherwise: preserve it as \`wiki_legacy*\`, create a fresh standard \`wiki/\`, migrate/adopt content from the preserved legacy source, then refresh routing and diagnostics.
257
622
  `;
258
623
  }
624
+ function completedMigrationStartupBlock(legacyRoot, remainingInboxes) {
625
+ const retainedInboxLine = remainingInboxes.length === 0
626
+ ? "- Generated file-level migration inboxes were pruned after semantic completion."
627
+ : `- Generated file-level migration inboxes were pruned where safe; retained inbox-like pages: ${remainingInboxes.map((file) => `\`${file}\``).join(", ")}.`;
628
+ return `<!-- PROJECT-WIKI-MIGRATION:START -->
629
+ ## Migration State
630
+
631
+ - ${workspace_1.today}: migration review is complete for \`${legacyRoot || "wiki_legacy"}\`; pending 0 and needs-human-review 0.
632
+ ${retainedInboxLine}
633
+ - Migration ledgers under \`wiki/migration/\` remain as audit evidence.
634
+ - \`${legacyRoot || "wiki_legacy"}\` remains preserved as source/rollback archive and is not deleted automatically.
635
+ - Migration completion status is scoped to this batch only. For a future fresh rebuild request, treat current \`wiki/\` as the legacy source unless the user says otherwise.
636
+ <!-- PROJECT-WIKI-MIGRATION:END -->`;
637
+ }
638
+ function completedMigrationIndexBlock(remainingInboxes) {
639
+ const inboxLine = remainingInboxes.length === 0
640
+ ? "- Generated file-level migration inboxes were pruned after semantic completion."
641
+ : `- Retained inbox-like pages: ${remainingInboxes.map(wikiLinkForMigrationPath).join(", ")}.`;
642
+ return `<!-- PROJECT-WIKI-MIGRATION:START -->
643
+ ## Migration
644
+
645
+ - Core ledgers: [[migration/coverage]], [[migration/review]], [[migration/verification]], [[migration/bulk-review]].
646
+ - Planning ledgers: [[migration/plan]], [[migration/inventory]], [[migration/unit-map]], [[migration/split-plan]].
647
+ ${inboxLine}
648
+ - Keep \`wiki_legacy*\` only as source/rollback archive after semantic completion; it is not deleted automatically.
649
+ <!-- PROJECT-WIKI-MIGRATION:END -->`;
650
+ }
651
+ function pruneCompletedMigrationJunk(legacyRoot) {
652
+ const results = [];
653
+ for (const file of exports.generatedMigrationInboxFiles) {
654
+ if (!isPrunableGeneratedMigrationInbox(file))
655
+ continue;
656
+ fs.unlinkSync((0, workspace_1.abs)(file));
657
+ results.push([file, "removed"]);
658
+ }
659
+ const remainingInboxes = exports.generatedMigrationInboxFiles.filter((file) => (0, workspace_1.exists)(file));
660
+ if ((0, workspace_1.exists)("wiki/startup.md")) {
661
+ results.push(["wiki/startup.md migration state", (0, workspace_1.upsertMarkedSection)("wiki/startup.md", "<!-- PROJECT-WIKI-MIGRATION:START -->", "<!-- PROJECT-WIKI-MIGRATION:END -->", completedMigrationStartupBlock(legacyRoot, remainingInboxes))]);
662
+ }
663
+ if ((0, workspace_1.exists)("wiki/index.md")) {
664
+ results.push(["wiki/index.md migration router", (0, workspace_1.upsertMarkedSection)("wiki/index.md", "<!-- PROJECT-WIKI-MIGRATION:START -->", "<!-- PROJECT-WIKI-MIGRATION:END -->", completedMigrationIndexBlock(remainingInboxes))]);
665
+ }
666
+ return results;
667
+ }
668
+ function isOpenMigrationRow(row) {
669
+ return row.inboxStatus === "pending" || row.inboxStatus === "needs-human-review" || row.semanticStatus === "pending semantic rewrite";
670
+ }
671
+ function sourceLooksGeneratedOrRouter(source) {
672
+ return /^(AGENTS|CLAUDE|GEMINI|README|startup|index)\.md$/.test(source)
673
+ || /^meta\/(operating-model|decision-policy|wiki-ops-v1-decisions|document-taxonomy)\.md$/.test(source);
674
+ }
675
+ const structuralHumanReviewHeadings = new Set([
676
+ "Active",
677
+ "Audience",
678
+ "Claim Boundary",
679
+ "Code-Proven Behavior",
680
+ "Code-proven behavior",
681
+ "Constraints",
682
+ "Core Scenarios",
683
+ "Current State",
684
+ "Diagnostics And Search",
685
+ "Discovery Rules",
686
+ "Evidence",
687
+ "Generated Output Ownership",
688
+ "Incremental Update Behavior",
689
+ "Inference",
690
+ "Mode Surface",
691
+ "Parser Backend Registry",
692
+ "Product",
693
+ "Product Direction",
694
+ "Project State",
695
+ "Query And Staleness",
696
+ "Read On Demand",
697
+ "Related Canonical Pages",
698
+ "Resolved",
699
+ "Retired",
700
+ "Routing And Inbox",
701
+ "Skill Installation",
702
+ "Success Criteria",
703
+ "TL;DR",
704
+ "Token Discipline",
705
+ "Tree-Sitter Mode",
706
+ "Workspace And Ownership Adapters",
707
+ ]);
708
+ function normalizedReviewText(value) {
709
+ return value.replace(/\\\|/g, "|").replace(/<br>/g, " ").replace(/\s+/g, " ").trim();
710
+ }
711
+ function isMarkdownTableDividerSummary(summary) {
712
+ return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(summary);
713
+ }
714
+ function isMarkdownTableHeaderSummary(summary) {
715
+ if (!/^\|.+\|$/.test(summary))
716
+ return false;
717
+ if (summary.includes("`"))
718
+ return false;
719
+ if (isMarkdownTableDividerSummary(summary))
720
+ return true;
721
+ const cells = summary.split("|").map((cell) => cell.trim()).filter(Boolean);
722
+ return cells.length > 1 && cells.every((cell) => /^[A-Z가-힣][A-Za-z가-힣 /-]{0,40}$/.test(cell));
723
+ }
724
+ function isStructuralHumanReviewRow(row) {
725
+ const summary = normalizedReviewText(row.summary);
726
+ const heading = normalizedReviewText(row.heading);
727
+ if (row.unitType === "heading" && (summary === heading || structuralHumanReviewHeadings.has(summary)))
728
+ return true;
729
+ if (row.unitType === "paragraph" && /^(Code-proven facts:|Code-proven behavior:|Evidence:|Inference:|Product framing:)$/i.test(summary))
730
+ return true;
731
+ if (isMarkdownTableDividerSummary(summary) || isMarkdownTableHeaderSummary(summary))
732
+ return true;
733
+ return false;
734
+ }
735
+ function confidenceCounts(rows) {
736
+ return {
737
+ high: rows.filter((row) => row.confidence === "high").length,
738
+ medium: rows.filter((row) => row.confidence === "medium").length,
739
+ low: rows.filter((row) => row.confidence === "low").length,
740
+ };
741
+ }
742
+ function groupBulkRows(rows, keyForRow) {
743
+ const groups = new Map();
744
+ for (const row of rows) {
745
+ const key = keyForRow(row);
746
+ groups.set(key, [...(groups.get(key) ?? []), row]);
747
+ }
748
+ return Array.from(groups.entries())
749
+ .map(([key, groupRows]) => {
750
+ const counts = confidenceCounts(groupRows);
751
+ return {
752
+ key,
753
+ rows: groupRows.length,
754
+ ...counts,
755
+ sources: Array.from(new Set(groupRows.map((row) => row.legacySource))).sort(),
756
+ targets: Array.from(new Set(groupRows.map((row) => row.target))).sort(),
757
+ areas: Array.from(new Set(groupRows.map((row) => row.area))).sort(),
758
+ sampleUnitIds: groupRows.slice(0, 5).map((row) => row.unitId),
759
+ sampleSummaries: groupRows.slice(0, 3).map((row) => row.summary),
760
+ };
761
+ })
762
+ .sort((left, right) => right.rows - left.rows || left.key.localeCompare(right.key));
763
+ }
764
+ function groupList(values, limit = 4) {
765
+ const shown = values.slice(0, limit).map((value) => markdownTableCell(value));
766
+ const hidden = values.length - shown.length;
767
+ return hidden > 0 ? `${shown.join("<br>")}<br>+${hidden} more` : shown.join("<br>") || "-";
768
+ }
769
+ function sampleList(values, limit = 3) {
770
+ const shown = values.slice(0, limit).map((value) => plainMarkdownTableCell(value));
771
+ const hidden = values.length - shown.length;
772
+ return hidden > 0 ? `${shown.join("<br>")}<br>+${hidden} more` : shown.join("<br>") || "-";
773
+ }
774
+ function confidenceMix(group) {
775
+ return `high ${group.high}, medium ${group.medium}, low ${group.low}`;
776
+ }
777
+ function renderTargetGroupRows(groups, suggestedStatus, limit = Number.POSITIVE_INFINITY) {
778
+ const selected = groups.slice(0, limit);
779
+ if (selected.length === 0)
780
+ return "| none | - | 0 | - | - | - |\n";
781
+ return selected.map((group) => `| ${markdownTableCell(group.key)} | ${group.rows} | ${confidenceMix(group)} | ${groupList(group.sources)} | ${groupList(group.areas)} | ${markdownTableCell(suggestedStatus)} |`).join("\n") + "\n";
782
+ }
783
+ function renderSourceGroupRows(groups, suggestedStatus, limit = Number.POSITIVE_INFINITY) {
784
+ const selected = groups.slice(0, limit);
785
+ if (selected.length === 0)
786
+ return "| none | 0 | - | - | - | - |\n";
787
+ return selected.map((group) => `| ${markdownTableCell(group.key)} | ${group.rows} | ${confidenceMix(group)} | ${groupList(group.targets, 3)} | ${groupList(group.areas)} | ${markdownTableCell(suggestedStatus)} |`).join("\n") + "\n";
788
+ }
789
+ function renderHumanReviewGroupRows(groups, suggestedStatus, limit = Number.POSITIVE_INFINITY) {
790
+ const selected = groups.slice(0, limit);
791
+ if (selected.length === 0)
792
+ return "| none | 0 | - | - | - | - | - |\n";
793
+ return selected.map((group) => `| ${markdownTableCell(group.key)} | ${group.rows} | ${confidenceMix(group)} | ${groupList(group.targets, 3)} | ${sampleList(group.sampleSummaries)} | ${groupList(group.sampleUnitIds, 3)} | ${markdownTableCell(suggestedStatus)} |`).join("\n") + "\n";
794
+ }
795
+ function buildMigrationBulkReviewPlan(rows) {
796
+ const openRows = rows.filter(isOpenMigrationRow);
797
+ const humanRows = openRows.filter((row) => row.inboxStatus === "needs-human-review" || row.confidence === "low");
798
+ const humanStructuralRows = humanRows.filter(isStructuralHumanReviewRow);
799
+ const humanContentRows = humanRows.filter((row) => !isStructuralHumanReviewRow(row));
800
+ const highRows = openRows.filter((row) => row.inboxStatus === "pending" && row.confidence === "high");
801
+ const mediumRows = openRows.filter((row) => row.inboxStatus === "pending" && row.confidence === "medium");
802
+ const sourceGroups = groupBulkRows(openRows, (row) => row.legacySource);
803
+ const singleTargetSourceGroups = sourceGroups.filter((group) => group.targets.length === 1 && group.low === 0);
804
+ const generatedSourceGroups = sourceGroups.filter((group) => sourceLooksGeneratedOrRouter(group.key));
805
+ return {
806
+ totalRows: rows.length,
807
+ openRows: openRows.length,
808
+ completedRows: rows.length - openRows.length,
809
+ highConfidenceRows: highRows.length,
810
+ mediumConfidenceRows: mediumRows.length,
811
+ humanReviewRows: humanRows.length,
812
+ humanReviewStructuralRows: humanStructuralRows.length,
813
+ humanReviewContentRows: humanContentRows.length,
814
+ highTargetGroups: groupBulkRows(highRows, (row) => row.target),
815
+ mediumTargetGroups: groupBulkRows(mediumRows, (row) => row.target),
816
+ singleTargetSourceGroups,
817
+ humanReviewSourceGroups: groupBulkRows(humanRows, (row) => row.legacySource),
818
+ humanReviewStructuralSourceGroups: groupBulkRows(humanStructuralRows, (row) => row.legacySource),
819
+ humanReviewContentSourceGroups: groupBulkRows(humanContentRows, (row) => row.legacySource),
820
+ generatedSourceGroups,
821
+ };
822
+ }
823
+ function bulkReviewRowsFromCoverage(coverageRows, reviewedRows = []) {
824
+ const reviewedByUnit = new Map(reviewedRows.map((row) => [row.legacyPath, row]));
825
+ return coverageRows.map((row) => {
826
+ const reviewed = reviewedByUnit.get(row.unitId);
827
+ const inboxStatus = reviewed?.inboxStatus ?? normalizeCoverageStatusForReview(row.status);
828
+ const semanticStatus = reviewed?.semanticStatus ?? semanticStatusForInboxStatus(inboxStatus);
829
+ return {
830
+ unitId: row.unitId,
831
+ legacySource: row.legacySource,
832
+ unitType: row.type,
833
+ heading: row.heading,
834
+ summary: row.summary,
835
+ target: reviewed?.target || row.target,
836
+ area: row.area,
837
+ confidence: row.confidence,
838
+ inboxStatus,
839
+ semanticStatus,
840
+ reason: row.reason,
841
+ };
842
+ });
843
+ }
844
+ function bulkReviewSummarySection(plan) {
845
+ return `## Bulk Review Summary
846
+
847
+ - Do not review all ${plan.totalRows} rows one by one; use [[migration/bulk-review]] to process batches.
848
+ - Completed rows: ${plan.completedRows}
849
+ - Open rows: ${plan.openRows}
850
+ - High-confidence bulk candidates: ${plan.highConfidenceRows} rows across ${plan.highTargetGroups.length} target groups.
851
+ - Medium-confidence batch candidates: ${plan.mediumConfidenceRows} rows across ${plan.mediumTargetGroups.length} target groups.
852
+ - Human-review priority: ${plan.humanReviewContentSourceGroups.length} content-bearing source batches (${plan.humanReviewContentRows} rows), plus ${plan.humanReviewStructuralRows} structural/layout rows handled with the same source review.
853
+
854
+ | Queue | Rows | Groups | Recommended handling |
855
+ | --- | ---: | ---: | --- |
856
+ | High-confidence target batches | ${plan.highConfidenceRows} | ${plan.highTargetGroups.length} | Rewrite or confirm target page, then bulk mark rows adopted or merged. |
857
+ | Medium-confidence target batches | ${plan.mediumConfidenceRows} | ${plan.mediumTargetGroups.length} | Review by target page, sample source evidence, then bulk mark rows. |
858
+ | Human-review content batches | ${plan.humanReviewContentRows} | ${plan.humanReviewContentSourceGroups.length} | Inspect by source page; do not handle as isolated rows unless a batch remains ambiguous. |
859
+ | Human-review structural/layout rows | ${plan.humanReviewStructuralRows} | ${plan.humanReviewStructuralSourceGroups.length} | Close with the source rewrite when headings, table headers, or boilerplate carry no standalone project truth. |
860
+ | Single-target source shortcuts | ${plan.singleTargetSourceGroups.reduce((sum, group) => sum + group.rows, 0)} | ${plan.singleTargetSourceGroups.length} | One source maps to one target; a file-level inbox decision can close the batch. |
861
+ | Generated/router source candidates | ${plan.generatedSourceGroups.reduce((sum, group) => sum + group.rows, 0)} | ${plan.generatedSourceGroups.length} | Compare against regenerated operating docs; mark superseded or resolved only when no project truth remains. |
862
+ `;
863
+ }
864
+ function renderMigrationBulkReviewDocument(plan, batchScope) {
865
+ return `${(0, templates_1.metadata)("migration-bulk-review", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "migration coverage or confidence grouping changes")}
866
+ # Migration Bulk Review Plan
867
+
868
+ ## TL;DR
869
+
870
+ - Scope: ${batchScope}
871
+ - Total rows: ${plan.totalRows}
872
+ - Open rows: ${plan.openRows}
873
+ - Completed rows: ${plan.completedRows}
874
+ - The low-confidence queue is ${plan.humanReviewContentSourceGroups.length} content-bearing source batches, not ${plan.humanReviewRows} isolated row decisions.
875
+ - Structural/layout low rows: ${plan.humanReviewStructuralRows}; content-bearing low rows: ${plan.humanReviewContentRows}.
876
+ - This page is advisory. It never changes \`wiki/migration/coverage.md\` statuses by itself.
877
+
878
+ ${bulkReviewSummarySection(plan)}
879
+
880
+ ## Human-Review Triage
881
+
882
+ Low-confidence rows are not intended to be reviewed one by one. Start with content-bearing source batches, then close structural/layout rows while rewriting or rejecting that same source.
883
+
884
+ | Queue | Rows | Source batches | What to do |
885
+ | --- | ---: | ---: | --- |
886
+ | Content-bearing low rows | ${plan.humanReviewContentRows} | ${plan.humanReviewContentSourceGroups.length} | Review the legacy source page once, adopt/retarget/reject the useful meaning, then mark its low rows together. |
887
+ | Structural/layout low rows | ${plan.humanReviewStructuralRows} | ${plan.humanReviewStructuralSourceGroups.length} | Treat headings, TL;DR labels, table headers, and separators as source-structure evidence; usually close them with the source page decision. |
888
+ | Total low/needs-human-review rows | ${plan.humanReviewRows} | ${plan.humanReviewSourceGroups.length} | Escalate to row-level review only when a source batch mixes unrelated truths that cannot be split safely. |
889
+
890
+ ## Single-Target Source Shortcuts
891
+
892
+ Use these when a legacy source maps to one target and has no low-confidence rows. A compatible file-level inbox status can close the whole source batch.
893
+
894
+ | Legacy Source | Rows | Confidence Mix | Target | Areas | Suggested status |
895
+ | --- | ---: | --- | --- | --- | --- |
896
+ ${renderSourceGroupRows(plan.singleTargetSourceGroups, "adopted or merged after target rewrite")}
897
+
898
+ ## High-Confidence Target Batches
899
+
900
+ | Suggested Target | Rows | Confidence Mix | Sources | Areas | Suggested status |
901
+ | --- | ---: | --- | --- | --- | --- |
902
+ ${renderTargetGroupRows(plan.highTargetGroups, "adopted or merged after target rewrite")}
903
+
904
+ ## Medium-Confidence Target Batches
905
+
906
+ | Suggested Target | Rows | Confidence Mix | Sources | Areas | Suggested status |
907
+ | --- | ---: | --- | --- | --- | --- |
908
+ ${renderTargetGroupRows(plan.mediumTargetGroups, "review by target page before adopting")}
909
+
910
+ ## Content-Bearing Human-Review Batches
911
+
912
+ These are the practical manual review units. Review each legacy source page once; use the sample rows to find the relevant evidence quickly.
913
+
914
+ | Legacy Source | Rows | Confidence Mix | Targets | Sample summaries | Sample unit ids | Suggested status |
915
+ | --- | ---: | --- | --- | --- | --- | --- |
916
+ ${renderHumanReviewGroupRows(plan.humanReviewContentSourceGroups, "source-batch adopt, retarget, resolve, or reject")}
917
+
918
+ ## Structural Human-Review Batches
919
+
920
+ These rows are low-confidence because they are headings, table scaffolding, or boilerplate labels. They rarely require standalone decisions.
921
+
922
+ | Legacy Source | Rows | Confidence Mix | Targets | Sample summaries | Sample unit ids | Suggested status |
923
+ | --- | ---: | --- | --- | --- | --- | --- |
924
+ ${renderHumanReviewGroupRows(plan.humanReviewStructuralSourceGroups, "close with the same source-page decision")}
925
+
926
+ ## All Human-Review Source Groups
927
+
928
+ These rows have low confidence or are explicitly marked needs-human-review.
929
+
930
+ | Legacy Source | Rows | Confidence Mix | Targets | Areas | Suggested status |
931
+ | --- | ---: | --- | --- | --- | --- |
932
+ ${renderSourceGroupRows(plan.humanReviewSourceGroups, "manual adopt, resolve, reject, or retarget")}
933
+
934
+ ## Generated Or Router Source Candidates
935
+
936
+ These sources often overlap with regenerated bootstrap/wiki operating documents. Do not discard them automatically; compare for project-specific truth first.
937
+
938
+ | Legacy Source | Rows | Confidence Mix | Targets | Areas | Suggested status |
939
+ | --- | ---: | --- | --- | --- | --- |
940
+ ${renderSourceGroupRows(plan.generatedSourceGroups, "superseded or resolved only after comparison")}
941
+ `;
942
+ }
259
943
  function timestampSuffix() {
260
944
  return new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "").replace("T", "_");
261
945
  }
@@ -287,13 +971,19 @@ function migrationTargetForKind(kind) {
287
971
  return "wiki/decisions/migration-inbox.md";
288
972
  if (kind === "source")
289
973
  return "wiki/sources/migration-inbox.md";
974
+ if (kind === "meta")
975
+ return "wiki/meta/migration-inbox.md";
290
976
  return "wiki/canonical/migration-inbox.md";
291
977
  }
292
978
  function runMigrationMode(migrationState) {
293
979
  const legacyPath = migrationState.legacyPath;
294
980
  const markdownFiles = legacyPath && (0, workspace_1.exists)(legacyPath) ? (0, wiki_files_1.walkMarkdownFiles)((0, workspace_1.abs)(legacyPath), [], (0, workspace_1.abs)(legacyPath)) : [];
295
- const items = markdownFiles.map((file) => {
981
+ const fileRecords = markdownFiles.map((file) => {
296
982
  const text = (0, workspace_1.read)(file.path);
983
+ return { file, text, formOnlyReason: formOnlyMigrationDocumentReason(file.basePath, text) };
984
+ });
985
+ const skippedFormOnlyFiles = fileRecords.filter((record) => record.formOnlyReason);
986
+ const items = fileRecords.filter((record) => !record.formOnlyReason).map(({ file, text }) => {
297
987
  return {
298
988
  path: file.path,
299
989
  legacyPath: file.basePath,
@@ -307,11 +997,15 @@ function runMigrationMode(migrationState) {
307
997
  canonical: items.filter((item) => item.kind === "canonical"),
308
998
  decision: items.filter((item) => item.kind === "decision"),
309
999
  source: items.filter((item) => item.kind === "source"),
1000
+ meta: items.filter((item) => item.kind === "meta"),
310
1001
  other: items.filter((item) => item.kind === "other"),
311
1002
  };
312
1003
  const inventoryRows = items.length === 0
313
1004
  ? "| none | - | - | 0 | - |\n"
314
- : items.map((item) => `| ${markdownTableCell(item.path)} | ${item.kind} | ${markdownTableCell(item.title)} | ${item.bytes} | ${markdownTableCell(item.summary)} |`).join("\n") + "\n";
1005
+ : items.map((item) => `| ${markdownTableCell(item.path)} | ${item.kind} | ${plainMarkdownTableCell(item.title)} | ${item.bytes} | ${plainMarkdownTableCell(item.summary)} |`).join("\n") + "\n";
1006
+ const skippedRows = skippedFormOnlyFiles.length === 0
1007
+ ? "| none | - | - |\n"
1008
+ : skippedFormOnlyFiles.map((record) => `| ${markdownTableCell(record.file.path)} | ${markdownTableCell(record.formOnlyReason)} | ${plainMarkdownTableCell((0, wiki_files_1.firstHeading)(record.text, record.file.path))} |`).join("\n") + "\n";
315
1009
  const inventory = `${(0, templates_1.metadata)("migration-inventory", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "migration scan is rerun")}
316
1010
  # Migration Inventory
317
1011
 
@@ -319,13 +1013,31 @@ function runMigrationMode(migrationState) {
319
1013
 
320
1014
  - Generated: ${workspace_1.today}
321
1015
  - Legacy root: ${legacyPath || "none"}
322
- - Markdown files: ${items.length}
1016
+ - Legacy markdown files: ${markdownFiles.length}
1017
+ - Migratable markdown files: ${items.length}
1018
+ - Skipped form-only/template files: ${skippedFormOnlyFiles.length}
323
1019
  - Legacy files are not copied directly into the new wiki; they are mapped to rewrite inboxes.
324
1020
 
1021
+ ## Migratable Files
1022
+
325
1023
  | Legacy Source | Classification | Title | Size (bytes) | Summary |
326
1024
  | --- | --- | --- | ---: | --- |
327
- ${inventoryRows}`;
1025
+ ${inventoryRows}
1026
+ ## Skipped Form-Only Files
1027
+
1028
+ | Legacy Source | Reason | Title |
1029
+ | --- | --- | --- |
1030
+ ${skippedRows}`;
328
1031
  const units = items.flatMap((item) => extractMigrationUnits(item.legacyPath, (0, workspace_1.read)(item.path)));
1032
+ const unitsByStorage = {
1033
+ canonical: units.filter((unit) => unit.classification.storage === "canonical"),
1034
+ decisions: units.filter((unit) => unit.classification.storage === "decisions"),
1035
+ sources: units.filter((unit) => unit.classification.storage === "sources"),
1036
+ meta: units.filter((unit) => unit.classification.storage === "meta"),
1037
+ };
1038
+ const batchScope = migrationBatchScope(legacyPath || "none");
1039
+ const initialBulkPlan = buildMigrationBulkReviewPlan(bulkReviewRowsFromCoverage(coverageRowsFromUnits(units)));
1040
+ const bulkReview = renderMigrationBulkReviewDocument(initialBulkPlan, batchScope);
329
1041
  const coverage = `${(0, templates_1.metadata)("migration-coverage", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "migration unit coverage statuses change")}
330
1042
  # Migration Coverage Ledger
331
1043
 
@@ -338,9 +1050,34 @@ ${inventoryRows}`;
338
1050
  - Status values: pending, adopted, merged, superseded, rejected, resolved, needs-human-review.
339
1051
  - \`adopted\` and \`merged\` rows require a new-wiki target under \`wiki/canonical/\`, \`wiki/decisions/\`, \`wiki/sources/\`, or \`wiki/meta/\`.
340
1052
 
341
- | Unit ID | Legacy Source | Type | Heading | Summary | Status | Target | Note |
342
- | --- | --- | --- | --- | --- | --- | --- | --- |
1053
+ | Unit ID | Legacy Source | Type | Heading | Summary | Status | Target | Note | Area | Confidence | Reason |
1054
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
343
1055
  ${coverageTableRows(units)}`;
1056
+ const unitMap = `${(0, templates_1.metadata)("migration-unit-map", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "migration classification or target suggestions change")}
1057
+ # Migration Unit Map
1058
+
1059
+ ## TL;DR
1060
+
1061
+ - Generated: ${workspace_1.today}
1062
+ - Legacy meaning units: ${units.length}
1063
+ - Each row classifies one legacy meaning unit against the service/product document taxonomy.
1064
+ - Low-confidence rows start as needs-human-review.
1065
+
1066
+ | Unit ID | Legacy Source | Heading Path | Area | Storage | Confidence | Suggested Target | Reason | Summary | Status |
1067
+ | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
1068
+ ${unitMapRows(units)}`;
1069
+ const splitPlan = `${(0, templates_1.metadata)("migration-split-plan", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "migration split grouping changes")}
1070
+ # Migration Split Plan
1071
+
1072
+ ## TL;DR
1073
+
1074
+ - Generated: ${workspace_1.today}
1075
+ - Suggested target pages: ${new Set(units.map((unit) => unit.classification.target)).size}
1076
+ - Use this as the rewrite plan when one legacy page mixes API specs, features, UX, policy, QA, operations, or source evidence.
1077
+
1078
+ | Suggested Target | Area | Storage | Lowest Confidence | Unit Count | Legacy Units |
1079
+ | --- | --- | --- | --- | ---: | --- |
1080
+ ${splitPlanRows(units)}`;
344
1081
  const plan = `${(0, templates_1.metadata)("migration-plan", "short", "wiki/meta/wiki-ops-v1-decisions.md", "migration procedure or status changes")}
345
1082
  # Migration Plan
346
1083
 
@@ -349,6 +1086,7 @@ ${coverageTableRows(units)}`;
349
1086
  - Generated: ${workspace_1.today}
350
1087
  - Preparation: ${migrationState.note}
351
1088
  - The new \`./wiki\` uses the standard structure.
1089
+ - Form-only/template files are recorded in inventory but excluded from meaning-unit migration.
352
1090
  - Next step: review inbox items and absorb useful meaning into canonical, decisions, sources, or meta docs.
353
1091
 
354
1092
  ## Counts
@@ -358,83 +1096,92 @@ ${coverageTableRows(units)}`;
358
1096
  | canonical candidates | ${byKind.canonical.length} |
359
1097
  | decision candidates | ${byKind.decision.length} |
360
1098
  | source candidates | ${byKind.source.length} |
1099
+ | meta candidates | ${byKind.meta.length} |
361
1100
  | other candidates | ${byKind.other.length} |
1101
+ | skipped form-only/template files | ${skippedFormOnlyFiles.length} |
1102
+ | canonical units | ${unitsByStorage.canonical.length} |
1103
+ | decision units | ${unitsByStorage.decisions.length} |
1104
+ | source units | ${unitsByStorage.sources.length} |
1105
+ | meta units | ${unitsByStorage.meta.length} |
362
1106
  `;
363
- const verificationRows = items.length === 0
1107
+ const verificationRows = units.length === 0
364
1108
  ? "| none | - | - | pass | - |\n"
365
- : items.map((item) => `| ${markdownTableCell(item.path)} | ${item.kind} | ${migrationTargetForKind(item.kind)} | mapped | pending semantic rewrite |`).join("\n") + "\n";
1109
+ : units.map((unit) => {
1110
+ const semanticStatus = unit.classification.confidence === "low" ? "needs-human-review" : "pending semantic rewrite";
1111
+ return `| ${markdownTableCell(unit.id)} | ${(0, taxonomy_1.storageToMigrationKind)(unit.classification.storage)} | ${markdownTableCell(unit.classification.target)} | mapped | ${semanticStatus} |`;
1112
+ }).join("\n") + "\n";
366
1113
  const verification = `${(0, templates_1.metadata)("migration-verification", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "migration inbox items are adopted, rejected, or rescanned")}
367
1114
  # Migration Verification
368
1115
 
369
1116
  ## TL;DR
370
1117
 
371
1118
  - legacy root: ${legacyPath || "none"}
372
- - legacy markdown files: ${items.length}
373
- - mapped files: ${items.length}
374
- - coverage: ${items.length === markdownFiles.length ? "pass" : "fail"}
375
- - This verifies file coverage only. Semantic completeness is confirmed after inbox statuses are resolved.
1119
+ - legacy markdown files: ${markdownFiles.length}
1120
+ - skipped form-only/template files: ${skippedFormOnlyFiles.length}
1121
+ - mapped units: ${units.length}
1122
+ - coverage: ${items.length + skippedFormOnlyFiles.length === markdownFiles.length ? "pass" : "fail"}
1123
+ - This verifies unit coverage. Semantic completeness is confirmed after coverage or inbox statuses are resolved.
376
1124
 
377
- ${completionScopeSection(migrationBatchScope(legacyPath || "none"))}
1125
+ ${completionScopeSection(batchScope)}
378
1126
 
379
1127
  | Legacy Source | Classification | New Wiki Target | Coverage | Semantic Status |
380
1128
  | --- | --- | --- | --- | --- |
381
1129
  ${verificationRows}`;
1130
+ const review = `${(0, templates_1.metadata)("migration-review", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "migration inbox statuses change")}
1131
+ # Migration Review
1132
+
1133
+ ## TL;DR
1134
+
1135
+ - generated: ${workspace_1.today}
1136
+ - total legacy rows: ${units.length}
1137
+ - skipped form-only/template files: ${skippedFormOnlyFiles.length}
1138
+ - semantic migration complete: no, the ${batchScope} still has unresolved rows
1139
+ - Run \`--review-migration\` after updating coverage or migration inbox statuses.
1140
+
1141
+ ${completionScopeSection(batchScope)}
1142
+
1143
+ ${bulkReviewSummarySection(initialBulkPlan)}
1144
+
1145
+ | Legacy Source | Classification | Inbox Status | Semantic Status | Evidence |
1146
+ | --- | --- | --- | --- | --- |
1147
+ ${units.length === 0 ? "| none | - | - | - | - |\n" : units.map((unit) => `| ${markdownTableCell(unit.id)} | ${(0, taxonomy_1.storageToMigrationKind)(unit.classification.storage)} | ${unit.classification.confidence === "low" ? "needs-human-review" : "pending"} | ${unit.classification.confidence === "low" ? "needs-human-review" : "pending semantic rewrite"} | ${plainMarkdownTableCell(unit.classification.reason)} |`).join("\n") + "\n"}`;
382
1148
  const migrationStartupBlock = `<!-- PROJECT-WIKI-MIGRATION:START -->
383
1149
  ## Migration State
384
1150
 
385
1151
  - ${workspace_1.today}: preserved existing wiki at \`${legacyPath || "no wiki_legacy"}\` and regenerated the standard wiki structure.
386
- - Scanned ${items.length} legacy markdown files and created migration inventory, plan, verification, and inbox files.
1152
+ - Scanned ${markdownFiles.length} legacy markdown files, skipped ${skippedFormOnlyFiles.length} form-only/template files, and mapped ${units.length} meaning units; created inventory, unit map, split plan, coverage, verification, review, and inbox files.
387
1153
  - Do not delete \`${legacyPath || "wiki_legacy"}\` until all migration inbox items are adopted/rejected/resolved and needs-human-review is 0.
388
1154
  - Migration completion status is scoped to this batch only. For a future fresh rebuild request, treat current \`wiki/\` as the legacy source unless the user says otherwise.
389
1155
  <!-- PROJECT-WIKI-MIGRATION:END -->`;
390
1156
  const migrationIndexBlock = `<!-- PROJECT-WIKI-MIGRATION:START -->
391
1157
  ## Migration
392
1158
 
393
- - [[migration/plan]]
394
- - Read when: migration procedure, fresh rebuild procedure, or status matters.
395
- - Update when: migration procedure or state changes.
396
- - Token budget: short.
397
- - [[migration/inventory]]
398
- - Read when: legacy markdown file list and classification matter.
399
- - Update when: migration is rescanned.
400
- - Token budget: on-demand.
401
- - [[migration/verification]]
402
- - Read when: legacy file coverage or semantic migration status matters.
403
- - Update when: migration inbox statuses change.
404
- - Token budget: on-demand.
405
- - [[migration/coverage]]
406
- - Read when: checking whether legacy meaning units were adopted, merged, superseded, rejected, resolved, or marked for review.
407
- - Update when: unit-level migration coverage statuses, targets, or notes change.
408
- - Token budget: on-demand.
409
- - [[migration/review]]
410
- - Read when: semantic migration review status matters.
411
- - Update when: \`--review-migration\` syncs migration state.
412
- - Token budget: on-demand.
413
- - [[canonical/migration-inbox]]
414
- - Read when: absorbing legacy canonical candidates.
415
- - Update when: candidates are adopted/rejected/resolved/needs-human-review.
416
- - Token budget: medium.
417
- - [[decisions/migration-inbox]]
418
- - Read when: absorbing legacy decision candidates.
419
- - Update when: candidates are adopted/rejected/resolved/needs-human-review.
420
- - Token budget: medium.
421
- - [[sources/migration-inbox]]
422
- - Read when: absorbing legacy source candidates.
423
- - Update when: candidates are adopted/rejected/resolved/needs-human-review.
424
- - Token budget: medium.
1159
+ - [[migration/plan]]: migration procedure, fresh rebuild procedure, and counts.
1160
+ - [[migration/inventory]]: legacy file list and file-level classification.
1161
+ - [[migration/unit-map]]: meaning-unit taxonomy classification and suggested target pages.
1162
+ - [[migration/split-plan]]: target-page grouping for mixed legacy pages.
1163
+ - [[migration/coverage]]: editable unit status, target, and note ledger.
1164
+ - [[migration/verification]]: current unit coverage and semantic status.
1165
+ - [[migration/review]]: regenerated summary from coverage/inbox status.
1166
+ - [[migration/bulk-review]]: batch review queues so humans do not inspect every unit one by one.
1167
+ - [[canonical/migration-inbox]], [[decisions/migration-inbox]], [[sources/migration-inbox]]: file-level adoption inboxes.
425
1168
  <!-- PROJECT-WIKI-MIGRATION:END -->`;
426
1169
  const results = [];
427
1170
  (0, workspace_1.mkdirp)("wiki/migration");
428
1171
  results.push(["wiki/migration/inventory.md", (0, workspace_1.writeManaged)("wiki/migration/inventory.md", inventory)]);
1172
+ results.push(["wiki/migration/unit-map.md", (0, workspace_1.writeManaged)("wiki/migration/unit-map.md", unitMap)]);
1173
+ results.push(["wiki/migration/split-plan.md", (0, workspace_1.writeManaged)("wiki/migration/split-plan.md", splitPlan)]);
429
1174
  results.push(["wiki/migration/coverage.md", (0, workspace_1.writeManaged)("wiki/migration/coverage.md", coverage)]);
430
1175
  results.push(["wiki/migration/plan.md", (0, workspace_1.writeManaged)("wiki/migration/plan.md", plan)]);
1176
+ results.push(["wiki/migration/review.md", (0, workspace_1.writeManaged)("wiki/migration/review.md", review)]);
431
1177
  results.push(["wiki/migration/verification.md", (0, workspace_1.writeManaged)("wiki/migration/verification.md", verification)]);
1178
+ results.push(["wiki/migration/bulk-review.md", (0, workspace_1.writeManaged)("wiki/migration/bulk-review.md", bulkReview)]);
432
1179
  results.push(["wiki/canonical/migration-inbox.md", (0, workspace_1.writeManaged)("wiki/canonical/migration-inbox.md", buildInbox("Canonical Migration Inbox", "Legacy content that may belong in current project truth.", byKind.canonical.concat(byKind.other)))]);
433
1180
  results.push(["wiki/decisions/migration-inbox.md", (0, workspace_1.writeManaged)("wiki/decisions/migration-inbox.md", buildInbox("Decision Migration Inbox", "Legacy content that may belong in project decision history.", byKind.decision))]);
434
1181
  results.push(["wiki/sources/migration-inbox.md", (0, workspace_1.writeManaged)("wiki/sources/migration-inbox.md", buildInbox("Source Migration Inbox", "Legacy content that may belong in source summaries.", byKind.source))]);
435
1182
  results.push(["wiki/startup.md migration state", (0, workspace_1.upsertMarkedSection)("wiki/startup.md", "<!-- PROJECT-WIKI-MIGRATION:START -->", "<!-- PROJECT-WIKI-MIGRATION:END -->", migrationStartupBlock)]);
436
1183
  results.push(["wiki/index.md migration router", (0, workspace_1.upsertMarkedSection)("wiki/index.md", "<!-- PROJECT-WIKI-MIGRATION:START -->", "<!-- PROJECT-WIKI-MIGRATION:END -->", migrationIndexBlock)]);
437
- return { results, total: items.length, legacyPath };
1184
+ return { results, total: markdownFiles.length, legacyPath };
438
1185
  }
439
1186
  function normalizeMigrationStatus(status) {
440
1187
  const value = String(status || "").trim().toLowerCase();
@@ -463,11 +1210,69 @@ function migrationInboxStatusMap() {
463
1210
  const source = cells[0];
464
1211
  if (!source)
465
1212
  continue;
466
- statuses.set(source, { status: normalizeMigrationStatus(cells[3]), inbox: file });
1213
+ const entry = { status: normalizeMigrationStatus(cells[3]), inbox: file };
1214
+ statuses.set(source, entry);
1215
+ const rootStripped = source.replace(/^wiki_legacy(?:_[^/]+)?\//, "");
1216
+ if (rootStripped !== source)
1217
+ statuses.set(rootStripped, entry);
467
1218
  }
468
1219
  }
469
1220
  return statuses;
470
1221
  }
1222
+ function normalizeCoverageStatusForReview(status) {
1223
+ const value = String(status || "").trim().toLowerCase();
1224
+ if (value === "merged")
1225
+ return "adopted";
1226
+ if (value === "superseded")
1227
+ return "resolved";
1228
+ if (isMigrationInboxStatus(value))
1229
+ return value;
1230
+ return "pending";
1231
+ }
1232
+ function migrationCoverageStatusMap() {
1233
+ const statuses = new Map();
1234
+ if (!(0, workspace_1.exists)("wiki/migration/coverage.md"))
1235
+ return statuses;
1236
+ for (const cells of (0, wiki_files_1.parseMarkdownTableRows)((0, workspace_1.read)("wiki/migration/coverage.md"), 8).filter((row) => row[0] !== "Unit ID")) {
1237
+ const source = cells[0];
1238
+ if (!source)
1239
+ continue;
1240
+ statuses.set(source, { status: normalizeCoverageStatusForReview(cells[5]), inbox: "wiki/migration/coverage.md" });
1241
+ }
1242
+ return statuses;
1243
+ }
1244
+ function legacySourceFromUnitId(value) {
1245
+ return value.split("#u", 1)[0] ?? value;
1246
+ }
1247
+ function sourcesSafeForFileLevelFallback(rows) {
1248
+ const targetsBySource = new Map();
1249
+ for (const row of rows) {
1250
+ const source = legacySourceFromUnitId(row.legacyPath);
1251
+ const targets = targetsBySource.get(source) ?? new Set();
1252
+ targets.add(row.target);
1253
+ targetsBySource.set(source, targets);
1254
+ }
1255
+ return new Set(Array.from(targetsBySource.entries()).filter(([, targets]) => targets.size === 1).map(([source]) => source));
1256
+ }
1257
+ function migrationStatusForRow(row, coverageStatuses, inboxStatuses, fileLevelFallbackSources) {
1258
+ const coverage = coverageStatuses.get(row.legacyPath);
1259
+ if (coverage && coverage.status !== "pending")
1260
+ return coverage;
1261
+ const exactInbox = inboxStatuses.get(row.legacyPath);
1262
+ if (exactInbox)
1263
+ return exactInbox;
1264
+ const source = legacySourceFromUnitId(row.legacyPath);
1265
+ const sourceInbox = inboxStatuses.get(source);
1266
+ if (sourceInbox && fileLevelFallbackSources.has(source))
1267
+ return sourceInbox;
1268
+ if (sourceInbox) {
1269
+ const note = `file-level inbox row ignored for mixed-target legacy source: ${sourceInbox.inbox}`;
1270
+ return coverage ? { status: coverage.status, inbox: `${note}; using ${coverage.inbox}` } : { status: "needs-human-review", inbox: note };
1271
+ }
1272
+ if (coverage)
1273
+ return coverage;
1274
+ return { status: "needs-human-review", inbox: "missing migration coverage or inbox row" };
1275
+ }
471
1276
  function semanticStatusForInboxStatus(status) {
472
1277
  if (["adopted", "rejected", "resolved", "needs-human-review"].includes(status))
473
1278
  return status;
@@ -485,11 +1290,12 @@ function runReviewMigrationMode() {
485
1290
  target: cells[2] ?? "",
486
1291
  coverage: cells[3] ?? "",
487
1292
  }));
1293
+ const coverageStatuses = migrationCoverageStatusMap();
488
1294
  const inboxStatuses = migrationInboxStatusMap();
1295
+ const fileLevelFallbackSources = sourcesSafeForFileLevelFallback(verificationRows);
489
1296
  const reviewedRows = verificationRows.map((row) => {
490
- const inbox = inboxStatuses.get(row.legacyPath);
491
- const status = inbox ? inbox.status : "needs-human-review";
492
- return { ...row, inboxStatus: status, semanticStatus: semanticStatusForInboxStatus(status), note: inbox ? inbox.inbox : "missing migration inbox row" };
1297
+ const statusEntry = migrationStatusForRow(row, coverageStatuses, inboxStatuses, fileLevelFallbackSources);
1298
+ return { ...row, inboxStatus: statusEntry.status, semanticStatus: semanticStatusForInboxStatus(statusEntry.status), note: statusEntry.inbox };
493
1299
  });
494
1300
  const counts = reviewedRows.reduce((acc, row) => {
495
1301
  acc[row.inboxStatus] = (acc[row.inboxStatus] || 0) + 1;
@@ -501,6 +1307,9 @@ function runReviewMigrationMode() {
501
1307
  const legacyRoot = (verificationText.match(/^- legacy root:\s*(.+)$/m) || [])[1] || "unknown";
502
1308
  const batchScope = migrationBatchScope(legacyRoot);
503
1309
  const completionValue = semanticCompletionValue(complete, batchScope);
1310
+ const coverageRows = (0, workspace_1.exists)("wiki/migration/coverage.md") ? parseMigrationCoverageRows((0, workspace_1.read)("wiki/migration/coverage.md")) : [];
1311
+ const bulkPlan = buildMigrationBulkReviewPlan(bulkReviewRowsFromCoverage(coverageRows, reviewedRows));
1312
+ const bulkReview = renderMigrationBulkReviewDocument(bulkPlan, batchScope);
504
1313
  const reviewRows = reviewedRows.length === 0
505
1314
  ? "| none | - | - | - | - |\n"
506
1315
  : reviewedRows.map((row) => `| ${markdownTableCell(row.legacyPath)} | ${row.kind} | ${row.inboxStatus} | ${row.semanticStatus} | ${markdownTableCell(row.note)} |`).join("\n") + "\n";
@@ -520,6 +1329,8 @@ function runReviewMigrationMode() {
520
1329
 
521
1330
  ${completionScopeSection(batchScope)}
522
1331
 
1332
+ ${bulkReviewSummarySection(bulkPlan)}
1333
+
523
1334
  | Legacy Source | Classification | Inbox Status | Semantic Status | Evidence |
524
1335
  | --- | --- | --- | --- | --- |
525
1336
  ${reviewRows}`;
@@ -532,8 +1343,8 @@ ${reviewRows}`;
532
1343
  ## TL;DR
533
1344
 
534
1345
  - legacy root: ${legacyRoot}
535
- - legacy markdown files: ${reviewedRows.length}
536
- - mapped files: ${reviewedRows.filter((row) => row.coverage === "mapped").length}
1346
+ - legacy rows: ${reviewedRows.length}
1347
+ - mapped rows: ${reviewedRows.filter((row) => row.coverage === "mapped").length}
537
1348
  - coverage: ${reviewedRows.every((row) => row.coverage === "mapped") ? "pass" : "fail"}
538
1349
  - semantic migration complete: ${completionValue}
539
1350
  - pending: ${pending}
@@ -547,7 +1358,11 @@ ${verificationRowsText}`;
547
1358
  const results = [
548
1359
  ["wiki/migration/review.md", (0, workspace_1.writeManaged)("wiki/migration/review.md", review)],
549
1360
  ["wiki/migration/verification.md", (0, workspace_1.writeManaged)("wiki/migration/verification.md", verification)],
1361
+ ["wiki/migration/bulk-review.md", (0, workspace_1.writeManaged)("wiki/migration/bulk-review.md", bulkReview)],
550
1362
  ];
1363
+ if (complete) {
1364
+ results.push(...pruneCompletedMigrationJunk(legacyRoot));
1365
+ }
551
1366
  console.log("Project wiki migration review complete.");
552
1367
  for (const [relativePath, status] of results)
553
1368
  console.log(`${String(status).padEnd(7)} ${relativePath}`);