mdkg 0.3.3 → 0.3.4

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/CHANGELOG.md CHANGED
@@ -12,6 +12,45 @@ mdkg is pre-v1 public alpha software. Command, graph, cache, bundle, and DAL con
12
12
 
13
13
  - No changes yet.
14
14
 
15
+ ## 0.3.4 - 2026-06-17
16
+
17
+ ### Added
18
+
19
+ - Added IDs-family repair apply support with `mdkg fix apply --family ids`
20
+ and the focused `mdkg fix ids [--apply]` convenience command.
21
+ - Added `--base-ref` support for duplicate-ID repair planning so mainline IDs
22
+ can be preserved while incoming duplicate nodes receive the next unused
23
+ canonical numeric ID.
24
+ - Added unresolved Git add/add conflict-stage repair for mdkg Markdown files:
25
+ stage 2 remains at the conflicted path, stage 3 is rewritten to a new
26
+ canonical ID/path, and the Git index conflict stages are resolved with a
27
+ receipt.
28
+ - Added packed `smoke:id-repair` coverage that installs mdkg from a tarball in
29
+ a temp prefix, validates clean duplicate repair, base-ref link preservation,
30
+ unresolved Git conflict-stage repair, and final graph validation.
31
+
32
+ ### Changed
33
+
34
+ - Updated `mdkg fix plan` receipts so duplicate-ID findings advertise
35
+ `apply_supported: true` with an explicit `apply_kind`, while index/cache and
36
+ graph-reference findings remain review-only.
37
+ - Updated command help, README, init assets, command matrix, generated command
38
+ contract metadata, and publish-readiness assertions to document the new
39
+ IDs-only apply boundary.
40
+ - Updated branch-conflict and fix-plan smokes to distinguish non-mutating plan
41
+ behavior from the newly apply-capable duplicate-ID repair family.
42
+
43
+ ### Security
44
+
45
+ - `fix apply` refuses unsupported families, blocked plans, and non-IDs repair
46
+ findings instead of silently applying partial graph/reference/index repairs.
47
+ - Duplicate-ID apply writes Markdown atomically under the mdkg mutation lock,
48
+ rebuilds derived indexes, and emits receipt evidence with touched paths,
49
+ source plan hash, and ambiguous reference notes.
50
+ - Base-ref link rewriting is conservative: references in files absent from the
51
+ base ref can be rewritten to the repaired incoming ID, while base-existing
52
+ references remain on the mainline ID and ambiguous references are reported.
53
+
15
54
  ## 0.3.3 - 2026-06-16
16
55
 
17
56
  ### Added
@@ -1,7 +1,7 @@
1
1
  # CLI Command Matrix
2
2
 
3
3
  as_of: 2026-06-06
4
- package_version_in_source: 0.3.1
4
+ package_version_in_source: 0.3.4
5
5
  source: live help from `src/cli.ts`, runtime command handlers, and `dec-15`..`dec-18`
6
6
  status: canonical single-source command and flag reference for mdkg
7
7
 
@@ -61,7 +61,7 @@ Recursive long-running objective contracts are accessed through `mdkg goal ...`.
61
61
  Fresh init workspaces default to the SQLite access cache backend; existing migrated configs stay on JSON until opted in.
62
62
  Project application database foundation commands are accessed through `mdkg db ...`; `mdkg index` remains the compatibility shortcut for graph index rebuilds.
63
63
  Operator health summaries are accessed through read-only `mdkg status ...`; deeper diagnostics remain under `mdkg doctor ...`.
64
- Repair planning is accessed through read-only `mdkg fix plan ...`; apply behavior is intentionally deferred.
64
+ Repair planning is accessed through read-only `mdkg fix plan ...`; duplicate-ID graph repairs can be applied through `mdkg fix apply --family ids ...` or the convenience `mdkg fix ids --apply ...`. Index/cache and graph-reference findings remain plan/manual-review only.
65
65
 
66
66
  ## Global usage
67
67
 
@@ -1049,27 +1049,38 @@ JSON receipt shape:
1049
1049
  ### `mdkg fix`
1050
1050
 
1051
1051
  When to use:
1052
- - plan reviewable graph/index repairs before any apply command exists
1052
+ - plan reviewable graph/index repairs before applying supported duplicate-ID rewrites
1053
1053
  - get a receipt-shaped JSON plan for automation and agent review
1054
+ - repair branch-merge duplicate IDs while preserving main/base IDs where possible
1054
1055
 
1055
1056
  Usage:
1056
- - `mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--json]`
1057
+ - `mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--base-ref <ref>] [--json]`
1058
+ - `mdkg fix apply [--family ids] [--target <id-or-qid>] [--base-ref <ref>] [--json]`
1059
+ - `mdkg fix ids [--target <id-or-qid>] [--base-ref <ref>] [--apply] [--json]`
1057
1060
 
1058
1061
  Flags:
1059
1062
  - `--family <family>`
1060
1063
  - `--target <id-or-qid>`
1064
+ - `--base-ref <ref>`
1065
+ - `--apply`
1061
1066
  - `--json`
1062
1067
 
1063
1068
  Boundaries:
1064
- - dry-run only and writes nothing
1065
- - does not rebuild indexes, edit graph files, rename ids, or update references
1066
- - `fix apply` is intentionally not available in the first repair-planning slice
1069
+ - `fix plan` is dry-run only and writes nothing
1070
+ - `fix apply` currently supports only IDs-family duplicate-ID graph rewrites
1071
+ - `fix ids` without `--apply` is equivalent to `fix plan --family ids`
1072
+ - `fix ids --apply` is equivalent to `fix apply --family ids`
1073
+ - apply rewrites graph Markdown atomically, rebuilds derived indexes, and emits a receipt
1074
+ - unresolved Git add/add conflict stages are handled by keeping stage 2 at the conflicted path and writing stage 3 to a new canonical ID/path
1075
+ - graph-reference and index/cache findings remain review-only guidance
1067
1076
  - initial families are index/cache, graph refs, and duplicate ids
1068
1077
 
1069
1078
  JSON receipt:
1070
1079
  - `{ action: "fix.plan", ok, schema_version, plan_id, plan_hash, generated_at, root, family, target, dirty, families, risk_counts, proposed_changes, blocked_changes, summary }`
1071
- - each proposed change includes family, risk, status, reason, paths, refs, optional before/after values, command hint, and `apply_supported: false`
1072
- - `summary.apply_deferred` remains true until a future apply design is approved
1080
+ - each proposed change includes family, risk, status, reason, paths, refs, optional before/after values, command hint, and `apply_supported`
1081
+ - duplicate-ID changes include candidate ID/path details and `apply_kind`
1082
+ - `{ action: "fix.apply", ok, schema_version, receipt_hash, root, family, target, base_ref, plan_id, plan_hash, applied_changes, touched_paths, ambiguous_reference_rewrites, index, summary }`
1083
+ - `summary.apply_deferred` remains true when the selected plan includes index/cache, graph-ref, blocked, or otherwise unsupported findings
1073
1084
 
1074
1085
  ### `mdkg doctor`
1075
1086
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ mdkg stays deliberately boring:
14
14
  - first-class rebuildable SQLite cache through built-in `node:sqlite`
15
15
  - no daemon, hosted index, or vector DB
16
16
 
17
- Current package version in source: `0.3.1`
17
+ Current package version in source: `0.3.4`
18
18
 
19
19
  mdkg is still pre-v1 public alpha software. The public package is usable, but graph, cache, bundle, and DAL contracts may continue to change quickly while the project converges on a stable v1 surface.
20
20
 
@@ -341,9 +341,13 @@ warnings unless their underlying check fails.
341
341
  Use `mdkg fix plan --json` when you want repair guidance without mutation. It
342
342
  emits a receipt-shaped plan for generated index/cache repair, missing graph
343
343
  references, and duplicate local ids. Planned changes include affected paths,
344
- risk, reason codes, command hints, and `apply_supported: false`. `fix apply` is
345
- not exposed; apply behavior is deferred until the dry-run plan contract has
346
- enough evidence.
344
+ risk, reason codes, command hints, and per-change `apply_supported` metadata.
345
+ Duplicate-ID graph repairs can be applied with
346
+ `mdkg fix apply --family ids --json` or `mdkg fix ids --apply --json`; use
347
+ `--base-ref main` when mainline IDs should win. Index/cache and graph-reference
348
+ findings remain review-only. For unresolved Git add/add conflicts, `fix ids`
349
+ keeps stage 2 at the conflicted path, rewrites stage 3 to the next unused
350
+ canonical ID/path, and records a receipt.
347
351
 
348
352
  ## Skills
349
353
 
package/dist/cli.js CHANGED
@@ -886,24 +886,58 @@ function printFixHelp(log, subcommand) {
886
886
  switch ((subcommand ?? "").toLowerCase()) {
887
887
  case "plan":
888
888
  log("Usage:");
889
- log(" mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--json]");
889
+ log(" mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--base-ref <ref>] [--json]");
890
890
  log("\nBoundaries:");
891
891
  log(" - read-only repair planning; writes no files and does not rebuild indexes");
892
892
  log(" - emits a deterministic receipt-shaped JSON plan with paths, risks, and reason codes");
893
893
  log(" - initial families are index/cache, graph refs, and duplicate ids");
894
- log(" - `fix apply` is intentionally not available in this release slice");
894
+ log(" - ids-family duplicate-id repairs can be applied with `mdkg fix apply --family ids`");
895
+ log(" - index/cache and graph-ref findings remain review-only guidance");
895
896
  log("\nOptions:");
896
897
  log(" --family <family> Select index, refs, ids, or all (default all)");
897
898
  log(" --target <id-or-qid> Optional node target for family planners");
899
+ log(" --base-ref <ref> Prefer IDs that already exist at a Git base ref");
900
+ log(" --json Emit machine-readable JSON output");
901
+ printGlobalOptions(log);
902
+ return;
903
+ case "apply":
904
+ log("Usage:");
905
+ log(" mdkg fix apply [--family ids] [--target <id-or-qid>] [--base-ref <ref>] [--json]");
906
+ log("\nBoundaries:");
907
+ log(" - applies only supported ids-family duplicate-ID rewrites");
908
+ log(" - refuses index/cache, graph-ref, all-family, blocked, and unsupported repairs");
909
+ log(" - writes graph Markdown atomically and rebuilds derived indexes");
910
+ log(" - emits a receipt with plan hash, touched paths, and manual-review reference notes");
911
+ log("\nOptions:");
912
+ log(" --family ids Explicit apply family; ids is the only supported apply family");
913
+ log(" --target <id-or-qid> Optional duplicate ID target");
914
+ log(" --base-ref <ref> Prefer IDs that already exist at a Git base ref");
915
+ log(" --json Emit machine-readable JSON output");
916
+ printGlobalOptions(log);
917
+ return;
918
+ case "ids":
919
+ log("Usage:");
920
+ log(" mdkg fix ids [--target <id-or-qid>] [--base-ref <ref>] [--apply] [--json]");
921
+ log("\nBoundaries:");
922
+ log(" - convenience command for duplicate-ID planning and application");
923
+ log(" - without --apply it is equivalent to `mdkg fix plan --family ids`");
924
+ log(" - with --apply it is equivalent to `mdkg fix apply --family ids`");
925
+ log("\nOptions:");
926
+ log(" --target <id-or-qid> Optional duplicate ID target");
927
+ log(" --base-ref <ref> Prefer IDs that already exist at a Git base ref");
928
+ log(" --apply Apply supported duplicate-ID rewrites");
898
929
  log(" --json Emit machine-readable JSON output");
899
930
  printGlobalOptions(log);
900
931
  return;
901
932
  default:
902
933
  log("Usage:");
903
- log(" mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--json]");
934
+ log(" mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--base-ref <ref>] [--json]");
935
+ log(" mdkg fix apply [--family ids] [--target <id-or-qid>] [--base-ref <ref>] [--json]");
936
+ log(" mdkg fix ids [--target <id-or-qid>] [--base-ref <ref>] [--apply] [--json]");
904
937
  log("\nNotes:");
905
- log(" - fix planning is dry-run only and writes nothing");
906
- log(" - apply behavior is deferred until the receipt contract is proven");
938
+ log(" - fix plan is dry-run only and writes nothing");
939
+ log(" - fix apply is limited to duplicate-ID graph repairs with receipt evidence");
940
+ log(" - index/cache and graph-ref repairs remain plan/manual-review only");
907
941
  printGlobalOptions(log);
908
942
  }
909
943
  }
@@ -2670,16 +2704,26 @@ function runCommand(parsed, root, runtime) {
2670
2704
  if (!sub) {
2671
2705
  throw new errors_1.UsageError("fix requires a subcommand");
2672
2706
  }
2673
- if (sub !== "plan") {
2707
+ if (!["plan", "apply", "ids"].includes(sub)) {
2674
2708
  throw new errors_1.UsageError(`unknown fix subcommand: ${sub}`);
2675
2709
  }
2676
2710
  if (parsed.positionals.length > 2) {
2677
- throw new errors_1.UsageError("fix plan does not accept positional arguments");
2711
+ throw new errors_1.UsageError(`fix ${sub} does not accept positional arguments`);
2678
2712
  }
2679
2713
  const family = requireFlagValue("--family", parsed.flags["--family"]);
2680
2714
  const target = requireFlagValue("--target", parsed.flags["--target"]);
2715
+ const baseRef = requireFlagValue("--base-ref", parsed.flags["--base-ref"]);
2681
2716
  const json = parseBooleanFlag("--json", parsed.flags["--json"]);
2682
- (0, fix_1.runFixPlanCommand)({ root, family, target, json });
2717
+ if (sub === "plan") {
2718
+ (0, fix_1.runFixPlanCommand)({ root, family, target, baseRef, json });
2719
+ return 0;
2720
+ }
2721
+ if (sub === "apply") {
2722
+ (0, fix_1.runFixApplyCommand)({ root, family, target, baseRef, json });
2723
+ return 0;
2724
+ }
2725
+ const apply = parseBooleanFlag("--apply", parsed.flags["--apply"]);
2726
+ (0, fix_1.runFixIdsCommand)({ root, target, baseRef, json, apply });
2683
2727
  return 0;
2684
2728
  }
2685
2729
  case "format":
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "tool": "mdkg",
4
- "package_version": "0.3.3",
4
+ "package_version": "0.3.4",
5
5
  "source": {
6
6
  "help_targets": "scripts/cli_help_targets.js",
7
7
  "command_matrix": "CLI_COMMAND_MATRIX.md"
@@ -2050,7 +2050,9 @@
2050
2050
  "visibility": "public",
2051
2051
  "summary": "mdkg fix command",
2052
2052
  "usage": [
2053
- " mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--json]"
2053
+ " mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--base-ref <ref>] [--json]",
2054
+ " mdkg fix apply [--family ids] [--target <id-or-qid>] [--base-ref <ref>] [--json]",
2055
+ " mdkg fix ids [--target <id-or-qid>] [--base-ref <ref>] [--apply] [--json]"
2054
2056
  ],
2055
2057
  "args": [],
2056
2058
  "flags": [
@@ -2104,13 +2106,232 @@
2104
2106
  }
2105
2107
  ],
2106
2108
  "examples": [
2107
- " mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--json]"
2109
+ " mdkg fix apply [--family ids] [--target <id-or-qid>] [--base-ref <ref>] [--json]",
2110
+ " mdkg fix ids [--target <id-or-qid>] [--base-ref <ref>] [--apply] [--json]",
2111
+ " mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--base-ref <ref>] [--json]"
2108
2112
  ],
2109
2113
  "docs": {
2110
2114
  "help_target": "mdkg help fix",
2111
2115
  "command_matrix": "CLI_COMMAND_MATRIX.md"
2112
2116
  }
2113
2117
  },
2118
+ {
2119
+ "key": "fix apply",
2120
+ "path": [
2121
+ "fix",
2122
+ "apply"
2123
+ ],
2124
+ "category": "fix",
2125
+ "status": "stable",
2126
+ "visibility": "public",
2127
+ "summary": "mdkg fix apply command",
2128
+ "usage": [
2129
+ " mdkg fix apply [--family ids] [--target <id-or-qid>] [--base-ref <ref>] [--json]"
2130
+ ],
2131
+ "args": [],
2132
+ "flags": [
2133
+ {
2134
+ "name": "--base-ref",
2135
+ "value": "<ref>",
2136
+ "required": false,
2137
+ "description": "--base-ref <ref> Prefer IDs that already exist at a Git base ref"
2138
+ },
2139
+ {
2140
+ "name": "--family",
2141
+ "value": "ids",
2142
+ "required": false,
2143
+ "description": "--family ids Explicit apply family; ids is the only supported apply family"
2144
+ },
2145
+ {
2146
+ "name": "--help",
2147
+ "value": null,
2148
+ "required": false,
2149
+ "description": "--help, -h Show help"
2150
+ },
2151
+ {
2152
+ "name": "--json",
2153
+ "value": null,
2154
+ "required": false,
2155
+ "description": "--json Emit machine-readable JSON output"
2156
+ },
2157
+ {
2158
+ "name": "--root",
2159
+ "value": null,
2160
+ "required": false,
2161
+ "description": "--root, -r <path> Run against a specific repo root"
2162
+ },
2163
+ {
2164
+ "name": "--target",
2165
+ "value": "<id-or-qid>",
2166
+ "required": false,
2167
+ "description": "--target <id-or-qid> Optional duplicate ID target"
2168
+ },
2169
+ {
2170
+ "name": "--version",
2171
+ "value": null,
2172
+ "required": false,
2173
+ "description": "--version, -V Show version"
2174
+ }
2175
+ ],
2176
+ "output_formats": [
2177
+ "text",
2178
+ "json"
2179
+ ],
2180
+ "json_schema_ref": "mdkg.fix_apply.v1",
2181
+ "side_effects": [
2182
+ "rebuild-derived-indexes",
2183
+ "rewrite-duplicate-node-ids"
2184
+ ],
2185
+ "read_paths": [
2186
+ ".mdkg/**"
2187
+ ],
2188
+ "write_paths": [
2189
+ ".mdkg/**/*.md",
2190
+ ".mdkg/index/**"
2191
+ ],
2192
+ "dry_run": {
2193
+ "supported": false,
2194
+ "apply_supported": true,
2195
+ "apply_family": "ids"
2196
+ },
2197
+ "lock_policy": "mutation-lock-required",
2198
+ "atomic_write_policy": "atomic-file-writes",
2199
+ "receipts": [
2200
+ "fix-apply-receipt"
2201
+ ],
2202
+ "danger_level": "high",
2203
+ "aliases": [],
2204
+ "exit_codes": [
2205
+ {
2206
+ "code": 0,
2207
+ "meaning": "success"
2208
+ },
2209
+ {
2210
+ "code": 1,
2211
+ "meaning": "validation-or-runtime-error"
2212
+ }
2213
+ ],
2214
+ "examples": [
2215
+ " mdkg fix apply [--family ids] [--target <id-or-qid>] [--base-ref <ref>] [--json]"
2216
+ ],
2217
+ "docs": {
2218
+ "help_target": "mdkg help fix apply",
2219
+ "command_matrix": "CLI_COMMAND_MATRIX.md"
2220
+ }
2221
+ },
2222
+ {
2223
+ "key": "fix ids",
2224
+ "path": [
2225
+ "fix",
2226
+ "ids"
2227
+ ],
2228
+ "category": "fix",
2229
+ "status": "stable",
2230
+ "visibility": "public",
2231
+ "summary": "mdkg fix ids command",
2232
+ "usage": [
2233
+ " mdkg fix ids [--target <id-or-qid>] [--base-ref <ref>] [--apply] [--json]"
2234
+ ],
2235
+ "args": [],
2236
+ "flags": [
2237
+ {
2238
+ "name": "--apply",
2239
+ "value": "it",
2240
+ "required": false,
2241
+ "description": "- without --apply it is equivalent to `mdkg fix plan --family ids`"
2242
+ },
2243
+ {
2244
+ "name": "--base-ref",
2245
+ "value": "<ref>",
2246
+ "required": false,
2247
+ "description": "--base-ref <ref> Prefer IDs that already exist at a Git base ref"
2248
+ },
2249
+ {
2250
+ "name": "--family",
2251
+ "value": "ids`",
2252
+ "required": false,
2253
+ "description": "- without --apply it is equivalent to `mdkg fix plan --family ids`"
2254
+ },
2255
+ {
2256
+ "name": "--help",
2257
+ "value": null,
2258
+ "required": false,
2259
+ "description": "--help, -h Show help"
2260
+ },
2261
+ {
2262
+ "name": "--json",
2263
+ "value": null,
2264
+ "required": false,
2265
+ "description": "--json Emit machine-readable JSON output"
2266
+ },
2267
+ {
2268
+ "name": "--root",
2269
+ "value": null,
2270
+ "required": false,
2271
+ "description": "--root, -r <path> Run against a specific repo root"
2272
+ },
2273
+ {
2274
+ "name": "--target",
2275
+ "value": "<id-or-qid>",
2276
+ "required": false,
2277
+ "description": "--target <id-or-qid> Optional duplicate ID target"
2278
+ },
2279
+ {
2280
+ "name": "--version",
2281
+ "value": null,
2282
+ "required": false,
2283
+ "description": "--version, -V Show version"
2284
+ }
2285
+ ],
2286
+ "output_formats": [
2287
+ "text",
2288
+ "json"
2289
+ ],
2290
+ "json_schema_ref": "mdkg.fix_ids.v1",
2291
+ "side_effects": [
2292
+ "plan-or-rewrite-duplicate-node-ids",
2293
+ "rebuild-derived-indexes-when-apply"
2294
+ ],
2295
+ "read_paths": [
2296
+ ".mdkg/**"
2297
+ ],
2298
+ "write_paths": [
2299
+ ".mdkg/**/*.md",
2300
+ ".mdkg/index/**"
2301
+ ],
2302
+ "dry_run": {
2303
+ "supported": true,
2304
+ "default": true,
2305
+ "apply_flag": "--apply",
2306
+ "apply_supported": true,
2307
+ "apply_family": "ids"
2308
+ },
2309
+ "lock_policy": "mutation-lock-required-when-apply",
2310
+ "atomic_write_policy": "atomic-file-writes-when-apply",
2311
+ "receipts": [
2312
+ "fix-apply-receipt",
2313
+ "fix-plan-receipt"
2314
+ ],
2315
+ "danger_level": "high",
2316
+ "aliases": [],
2317
+ "exit_codes": [
2318
+ {
2319
+ "code": 0,
2320
+ "meaning": "success"
2321
+ },
2322
+ {
2323
+ "code": 1,
2324
+ "meaning": "validation-or-runtime-error"
2325
+ }
2326
+ ],
2327
+ "examples": [
2328
+ " mdkg fix ids [--target <id-or-qid>] [--base-ref <ref>] [--apply] [--json]"
2329
+ ],
2330
+ "docs": {
2331
+ "help_target": "mdkg help fix ids",
2332
+ "command_matrix": "CLI_COMMAND_MATRIX.md"
2333
+ }
2334
+ },
2114
2335
  {
2115
2336
  "key": "fix plan",
2116
2337
  "path": [
@@ -2122,15 +2343,21 @@
2122
2343
  "visibility": "public",
2123
2344
  "summary": "mdkg fix plan command",
2124
2345
  "usage": [
2125
- " mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--json]"
2346
+ " mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--base-ref <ref>] [--json]"
2126
2347
  ],
2127
2348
  "args": [],
2128
2349
  "flags": [
2350
+ {
2351
+ "name": "--base-ref",
2352
+ "value": "<ref>",
2353
+ "required": false,
2354
+ "description": "--base-ref <ref> Prefer IDs that already exist at a Git base ref"
2355
+ },
2129
2356
  {
2130
2357
  "name": "--family",
2131
- "value": "<family>",
2358
+ "value": "ids`",
2132
2359
  "required": false,
2133
- "description": "--family <family> Select index, refs, ids, or all (default all)"
2360
+ "description": "- ids-family duplicate-id repairs can be applied with `mdkg fix apply --family ids`"
2134
2361
  },
2135
2362
  {
2136
2363
  "name": "--help",
@@ -2178,7 +2405,8 @@
2178
2405
  "dry_run": {
2179
2406
  "supported": true,
2180
2407
  "default": true,
2181
- "apply_supported": false
2408
+ "apply_supported": true,
2409
+ "apply_family": "ids"
2182
2410
  },
2183
2411
  "lock_policy": "none-read-only",
2184
2412
  "atomic_write_policy": "none-read-only",
@@ -2198,7 +2426,7 @@
2198
2426
  }
2199
2427
  ],
2200
2428
  "examples": [
2201
- " mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--json]"
2429
+ " mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--base-ref <ref>] [--json]"
2202
2430
  ],
2203
2431
  "docs": {
2204
2432
  "help_target": "mdkg help fix plan",
@@ -7636,5 +7864,5 @@
7636
7864
  }
7637
7865
  }
7638
7866
  ],
7639
- "contract_hash": "be42c29b89c1c3e3d059a8f9cbc564908d4dd694d848cf3d1b1800e8b30705e5"
7867
+ "contract_hash": "a2e027cdfdb590f3882cb824b9c8b1cf153e39ba63738ab0fad2d92277794325"
7640
7868
  }
@@ -4,7 +4,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.collectFixPlan = collectFixPlan;
7
+ exports.collectFixApply = collectFixApply;
7
8
  exports.runFixPlanCommand = runFixPlanCommand;
9
+ exports.runFixApplyCommand = runFixApplyCommand;
10
+ exports.runFixIdsCommand = runFixIdsCommand;
8
11
  const crypto_1 = __importDefault(require("crypto"));
9
12
  const child_process_1 = require("child_process");
10
13
  const fs_1 = __importDefault(require("fs"));
@@ -23,6 +26,9 @@ const template_schema_1 = require("../graph/template_schema");
23
26
  const workspace_files_1 = require("../graph/workspace_files");
24
27
  const errors_1 = require("../util/errors");
25
28
  const refs_1 = require("../util/refs");
29
+ const atomic_1 = require("../util/atomic");
30
+ const lock_1 = require("../util/lock");
31
+ const index_1 = require("./index");
26
32
  const FAMILY_VALUES = new Set(["index", "refs", "ids", "all"]);
27
33
  const CONCRETE_FAMILIES = ["index", "refs", "ids"];
28
34
  function stableValue(value) {
@@ -668,14 +674,143 @@ function planRefRepairs(root, target) {
668
674
  return { proposed: changes, blocked: [] };
669
675
  }
670
676
  function candidateDuplicateId(baseId, used) {
671
- for (let index = 2;; index += 1) {
672
- const candidate = `${baseId}-dup-${index}`;
677
+ const match = /^([a-z]+)-([0-9]+)$/.exec(baseId);
678
+ if (!match) {
679
+ throw new errors_1.UsageError(`duplicate id ${baseId} cannot be repaired automatically because it is not a canonical numeric id`);
680
+ }
681
+ const prefix = match[1];
682
+ const start = Number.parseInt(match[2], 10) + 1;
683
+ for (let index = start;; index += 1) {
684
+ const candidate = `${prefix}-${index}`;
673
685
  if (!used.has(candidate)) {
674
686
  used.add(candidate);
675
687
  return candidate;
676
688
  }
677
689
  }
678
690
  }
691
+ function gitShow(root, refPath) {
692
+ const result = (0, child_process_1.spawnSync)("git", ["show", refPath], { cwd: root, encoding: "utf8" });
693
+ if (result.status !== 0) {
694
+ return undefined;
695
+ }
696
+ return result.stdout;
697
+ }
698
+ function runGitStrict(root, args) {
699
+ const result = (0, child_process_1.spawnSync)("git", args, { cwd: root, encoding: "utf8" });
700
+ if (result.status !== 0) {
701
+ throw new errors_1.UsageError(`git ${args.join(" ")} failed: ${(result.stderr || result.stdout).trim() || "unknown error"}`);
702
+ }
703
+ }
704
+ function baseRefIdPaths(root, baseRef, files, config) {
705
+ const pathsById = new Map();
706
+ if (!baseRef) {
707
+ return pathsById;
708
+ }
709
+ const templateSchemas = (0, template_schema_1.loadTemplateSchemas)(root, config, node_1.ALLOWED_TYPES);
710
+ for (const absPath of files) {
711
+ const relativePath = rel(root, absPath);
712
+ const content = gitShow(root, `${baseRef}:${relativePath}`);
713
+ if (content === undefined) {
714
+ continue;
715
+ }
716
+ try {
717
+ const node = (0, node_1.parseNode)(content, absPath, {
718
+ workStatusEnum: config.work.status_enum,
719
+ priorityMin: config.work.priority_min,
720
+ priorityMax: config.work.priority_max,
721
+ templateSchemas,
722
+ });
723
+ if (!pathsById.has(node.id)) {
724
+ pathsById.set(node.id, new Set());
725
+ }
726
+ pathsById.get(node.id)?.add(relativePath);
727
+ }
728
+ catch {
729
+ continue;
730
+ }
731
+ }
732
+ return pathsById;
733
+ }
734
+ function rewriteIdInNodeContent(content, fromId, toId) {
735
+ const lines = content.split(/\n/);
736
+ let inFrontmatter = false;
737
+ let frontmatterClosed = false;
738
+ let idRewritten = false;
739
+ const rewritten = lines.map((line, index) => {
740
+ if (index === 0 && line.trim() === "---") {
741
+ inFrontmatter = true;
742
+ return line;
743
+ }
744
+ if (inFrontmatter && !frontmatterClosed && line.trim() === "---") {
745
+ frontmatterClosed = true;
746
+ inFrontmatter = false;
747
+ return line;
748
+ }
749
+ if (inFrontmatter && !idRewritten && line === `id: ${fromId}`) {
750
+ idRewritten = true;
751
+ return `id: ${toId}`;
752
+ }
753
+ return line.split(fromId).join(toId);
754
+ });
755
+ if (!idRewritten) {
756
+ throw new errors_1.UsageError(`unable to rewrite id ${fromId}; frontmatter id line not found`);
757
+ }
758
+ return rewritten.join("\n");
759
+ }
760
+ function isInsideRoot(root, filePath) {
761
+ const realRoot = `${relativeRoot(root)}${path_1.default.sep}`;
762
+ return path_1.default.resolve(filePath).startsWith(realRoot);
763
+ }
764
+ function replaceIdInRelativePath(relativePath, fromId, toId, usedPaths) {
765
+ const parsed = path_1.default.posix.parse(relativePath);
766
+ const baseName = parsed.base.includes(fromId)
767
+ ? parsed.base.replace(fromId, toId)
768
+ : `${toId}-${parsed.base}`;
769
+ let candidate = path_1.default.posix.join(parsed.dir, baseName);
770
+ if (!usedPaths.has(candidate)) {
771
+ usedPaths.add(candidate);
772
+ return candidate;
773
+ }
774
+ for (let index = 2;; index += 1) {
775
+ const suffixed = path_1.default.posix.join(parsed.dir, `${parsed.name}-${toId}-${index}${parsed.ext}`);
776
+ if (!usedPaths.has(suffixed)) {
777
+ usedPaths.add(suffixed);
778
+ return suffixed;
779
+ }
780
+ }
781
+ }
782
+ function gitConflictStages(root) {
783
+ const output = runGit(root, ["ls-files", "-u", "--", ".mdkg"]) ?? "";
784
+ const groups = new Map();
785
+ for (const line of output.split(/\r?\n/).filter(Boolean)) {
786
+ const match = /^([0-7]+) ([0-9a-f]+) ([123])\t(.+)$/.exec(line);
787
+ if (!match || !match[4].endsWith(".md")) {
788
+ continue;
789
+ }
790
+ const entry = {
791
+ mode: match[1],
792
+ object: match[2],
793
+ stage: Number.parseInt(match[3], 10),
794
+ path: match[4],
795
+ };
796
+ groups.set(entry.path, [...(groups.get(entry.path) ?? []), entry]);
797
+ }
798
+ return groups;
799
+ }
800
+ function workspaceAliasForPath(root, config, relativePath) {
801
+ const absPath = path_1.default.resolve(root, relativePath);
802
+ for (const alias of Object.keys(config.workspaces).sort()) {
803
+ const entry = config.workspaces[alias];
804
+ if (!entry.enabled) {
805
+ continue;
806
+ }
807
+ const wsRoot = path_1.default.resolve(root, entry.path, entry.mdkg_dir);
808
+ if (absPath === wsRoot || absPath.startsWith(`${wsRoot}${path_1.default.sep}`)) {
809
+ return alias;
810
+ }
811
+ }
812
+ return undefined;
813
+ }
679
814
  function filesContaining(root, files, needle) {
680
815
  return files
681
816
  .filter((filePath) => {
@@ -728,18 +863,118 @@ function referenceRewriteItems(root, files, from, to) {
728
863
  .filter((item) => Boolean(item))
729
864
  .sort((a, b) => a.path.localeCompare(b.path));
730
865
  }
731
- function planDuplicateIdRepairs(root, target) {
866
+ function planGitStageDuplicateIdRepairs(root, target, config, usedIdsByAlias, usedPaths, startIndex) {
867
+ const templateSchemas = (0, template_schema_1.loadTemplateSchemas)(root, config, node_1.ALLOWED_TYPES);
868
+ const proposed = [];
869
+ let matchedTarget = false;
870
+ for (const [relativePath, stages] of [...gitConflictStages(root).entries()].sort(([a], [b]) => a.localeCompare(b))) {
871
+ const ours = stages.find((entry) => entry.stage === 2);
872
+ const theirs = stages.find((entry) => entry.stage === 3);
873
+ if (!ours || !theirs) {
874
+ continue;
875
+ }
876
+ const alias = workspaceAliasForPath(root, config, relativePath);
877
+ if (!alias) {
878
+ continue;
879
+ }
880
+ const oursContent = gitShow(root, `:2:${relativePath}`);
881
+ const theirsContent = gitShow(root, `:3:${relativePath}`);
882
+ if (oursContent === undefined || theirsContent === undefined) {
883
+ continue;
884
+ }
885
+ try {
886
+ const absPath = path_1.default.resolve(root, relativePath);
887
+ const oursNode = (0, node_1.parseNode)(oursContent, absPath, {
888
+ workStatusEnum: config.work.status_enum,
889
+ priorityMin: config.work.priority_min,
890
+ priorityMax: config.work.priority_max,
891
+ templateSchemas,
892
+ });
893
+ const theirsNode = (0, node_1.parseNode)(theirsContent, absPath, {
894
+ workStatusEnum: config.work.status_enum,
895
+ priorityMin: config.work.priority_min,
896
+ priorityMax: config.work.priority_max,
897
+ templateSchemas,
898
+ });
899
+ if (oursNode.id !== theirsNode.id) {
900
+ continue;
901
+ }
902
+ const duplicateId = oursNode.id;
903
+ const qid = `${alias}:${duplicateId}`;
904
+ const targetMatches = !target || target.toLowerCase() === duplicateId || target.toLowerCase() === qid || target === relativePath;
905
+ if (!targetMatches) {
906
+ continue;
907
+ }
908
+ matchedTarget = true;
909
+ const usedIds = usedIdsByAlias.get(alias) ?? new Set();
910
+ usedIds.add(oursNode.id);
911
+ usedIds.add(theirsNode.id);
912
+ usedIdsByAlias.set(alias, usedIds);
913
+ const candidate = candidateDuplicateId(duplicateId, usedIds);
914
+ const candidatePath = replaceIdInRelativePath(relativePath, duplicateId, candidate, usedPaths);
915
+ proposed.push({
916
+ id: `ids.${String(startIndex + proposed.length + 1).padStart(3, "0")}`,
917
+ family: "ids",
918
+ risk: "high",
919
+ status: "planned",
920
+ reason: "git_stage_duplicate_id",
921
+ paths: [relativePath, candidatePath],
922
+ refs: [qid, `${alias}:${candidate}`].sort(),
923
+ evidence: {
924
+ conflict_kind: "git_index_unresolved_duplicate_id",
925
+ workspace: alias,
926
+ duplicate_id: duplicateId,
927
+ conflict_path: relativePath,
928
+ canonical_stage: 2,
929
+ duplicate_stage: 3,
930
+ current_blob: ours.object,
931
+ incoming_blob: theirs.object,
932
+ deterministic_rule: "keep stage 2 at the conflicted path, rewrite stage 3 to the next unused canonical numeric id and path, then git add both files",
933
+ },
934
+ before: {
935
+ duplicate_id: duplicateId,
936
+ workspace: alias,
937
+ conflict_path: relativePath,
938
+ canonical_stage: 2,
939
+ duplicate_stage: 3,
940
+ current_blob: ours.object,
941
+ incoming_blob: theirs.object,
942
+ },
943
+ after: {
944
+ candidate_id: candidate,
945
+ candidate_qid: `${alias}:${candidate}`,
946
+ candidate_path: candidatePath,
947
+ canonical_path: relativePath,
948
+ collision_free: true,
949
+ deterministic_rule: "keep stage 2 at the conflicted path, rewrite stage 3 to the next unused canonical numeric id and path, then git add both files",
950
+ },
951
+ command_hint: `mdkg fix ids --target ${duplicateId} --apply --json`,
952
+ apply_supported: true,
953
+ apply_kind: "git_stage_duplicate_id_rewrite",
954
+ });
955
+ }
956
+ catch {
957
+ continue;
958
+ }
959
+ }
960
+ return { proposed, matchedTarget };
961
+ }
962
+ function planDuplicateIdRepairs(root, target, baseRef) {
732
963
  const config = (0, config_1.loadConfig)(root);
733
964
  const templateSchemas = (0, template_schema_1.loadTemplateSchemas)(root, config, node_1.ALLOWED_TYPES);
734
965
  const docsByAlias = (0, workspace_files_1.listWorkspaceDocFilesByAlias)(root, config);
735
966
  const proposed = [];
736
967
  const blocked = [];
968
+ const usedIdsByAlias = new Map();
969
+ const usedPaths = new Set();
737
970
  let matchedTarget = !target;
738
971
  for (const alias of Object.keys(docsByAlias).sort()) {
739
972
  const records = [];
740
973
  const usedIds = new Set();
741
974
  const files = docsByAlias[alias].sort();
975
+ const basePathsById = baseRefIdPaths(root, baseRef, files, config);
742
976
  for (const filePath of files) {
977
+ usedPaths.add(rel(root, filePath));
743
978
  if (path_1.default.basename(filePath) === "core.md" && path_1.default.basename(path_1.default.dirname(filePath)) === "core") {
744
979
  continue;
745
980
  }
@@ -762,6 +997,7 @@ function planDuplicateIdRepairs(root, target) {
762
997
  continue;
763
998
  }
764
999
  }
1000
+ usedIdsByAlias.set(alias, usedIds);
765
1001
  const groups = new Map();
766
1002
  for (const record of records) {
767
1003
  groups.set(record.id, [...(groups.get(record.id) ?? []), record]);
@@ -778,18 +1014,29 @@ function planDuplicateIdRepairs(root, target) {
778
1014
  continue;
779
1015
  }
780
1016
  matchedTarget = true;
781
- const canonical = group[0];
1017
+ const basePaths = basePathsById.get(id);
1018
+ const baseCanonical = basePaths ? group.find((record) => basePaths.has(record.path)) : undefined;
1019
+ const canonical = baseCanonical ?? group[0];
782
1020
  const referencePaths = filesContaining(root, files, id);
783
- const duplicateRecords = group.slice(1);
1021
+ const duplicateRecords = group.filter((record) => record.path !== canonical.path);
784
1022
  const groupPaths = group.map((record) => record.path).sort();
785
- const deterministicRule = "keep the lexicographically first path unchanged; propose <id>-dup-<n> for each later path";
1023
+ const deterministicRule = baseCanonical
1024
+ ? "keep the path that already existed at --base-ref unchanged; propose the next unused canonical numeric id for each other path"
1025
+ : "keep the lexicographically first path unchanged; propose the next unused canonical numeric id for each later path";
786
1026
  for (const duplicate of duplicateRecords) {
787
1027
  const candidate = candidateDuplicateId(id, usedIds);
1028
+ const selfReferenceRewrites = referenceRewriteItems(root, [duplicate.absPath], id, candidate);
1029
+ const externalReferenceRewrites = referenceRewriteItems(root, files.filter((filePath) => filePath !== duplicate.absPath), id, candidate);
1030
+ const safeReferenceRewrites = baseRef
1031
+ ? externalReferenceRewrites.filter((item) => gitShow(root, `${baseRef}:${item.path}`) === undefined)
1032
+ : [];
1033
+ const safeReferencePaths = new Set(safeReferenceRewrites.map((item) => item.path));
1034
+ const ambiguousReferenceRewrites = externalReferenceRewrites.filter((item) => !safeReferencePaths.has(item.path));
788
1035
  proposed.push({
789
1036
  id: `ids.${String(proposed.length + 1).padStart(3, "0")}`,
790
1037
  family: "ids",
791
1038
  risk: "high",
792
- status: "manual_review",
1039
+ status: "planned",
793
1040
  reason: "duplicate_id",
794
1041
  paths: [duplicate.path],
795
1042
  refs: Array.from(new Set([canonical.qid, duplicate.qid])).sort(),
@@ -797,6 +1044,8 @@ function planDuplicateIdRepairs(root, target) {
797
1044
  conflict_kind: "duplicate_local_id",
798
1045
  branch_merge_suspected: true,
799
1046
  workspace: alias,
1047
+ base_ref: baseRef ?? null,
1048
+ base_ref_canonical: Boolean(baseCanonical),
800
1049
  duplicate_id: id,
801
1050
  group_size: group.length,
802
1051
  group_paths: groupPaths,
@@ -813,6 +1062,7 @@ function planDuplicateIdRepairs(root, target) {
813
1062
  before: {
814
1063
  duplicate_id: id,
815
1064
  workspace: alias,
1065
+ base_ref: baseRef ?? null,
816
1066
  canonical_path: canonical.path,
817
1067
  duplicate_path: duplicate.path,
818
1068
  duplicate_group: {
@@ -827,14 +1077,23 @@ function planDuplicateIdRepairs(root, target) {
827
1077
  collision_free: true,
828
1078
  deterministic_rule: deterministicRule,
829
1079
  reference_paths: referencePaths,
830
- reference_rewrite_plan: referenceRewriteItems(root, files, id, candidate),
1080
+ self_reference_rewrites: selfReferenceRewrites,
1081
+ safe_reference_rewrites: safeReferenceRewrites,
1082
+ ambiguous_reference_rewrites: ambiguousReferenceRewrites,
1083
+ reference_rewrite_plan: [...selfReferenceRewrites, ...safeReferenceRewrites, ...ambiguousReferenceRewrites].sort((a, b) => a.path.localeCompare(b.path)),
831
1084
  },
832
- command_hint: `review ${duplicate.path} and update id ${id} to ${candidate}`,
833
- apply_supported: false,
1085
+ command_hint: `mdkg fix apply --family ids --target ${id}${baseRef ? ` --base-ref ${baseRef}` : ""} --json`,
1086
+ apply_supported: true,
1087
+ apply_kind: "duplicate_id_rewrite",
834
1088
  });
835
1089
  }
836
1090
  }
837
1091
  }
1092
+ const stageRepairs = planGitStageDuplicateIdRepairs(root, target, config, usedIdsByAlias, usedPaths, proposed.length);
1093
+ proposed.push(...stageRepairs.proposed);
1094
+ if (stageRepairs.matchedTarget) {
1095
+ matchedTarget = true;
1096
+ }
838
1097
  if (!matchedTarget && target) {
839
1098
  blocked.push({
840
1099
  id: "ids.target.001",
@@ -881,15 +1140,20 @@ function collectFixPlan(options) {
881
1140
  const root = relativeRoot(options.root);
882
1141
  const indexRepairs = selected.includes("index") ? planIndexRepairs(root) : { proposed: [], blocked: [] };
883
1142
  const refRepairs = selected.includes("refs") ? planRefRepairs(root, options.target) : { proposed: [], blocked: [] };
884
- const idRepairs = selected.includes("ids") ? planDuplicateIdRepairs(root, options.target) : { proposed: [], blocked: [] };
1143
+ const idRepairs = selected.includes("ids")
1144
+ ? planDuplicateIdRepairs(root, options.target, options.baseRef)
1145
+ : { proposed: [], blocked: [] };
885
1146
  const proposedChanges = sortChanges([...indexRepairs.proposed, ...refRepairs.proposed, ...idRepairs.proposed]);
886
1147
  const blockedChanges = sortChanges([...indexRepairs.blocked, ...refRepairs.blocked, ...idRepairs.blocked]);
1148
+ const supportedApplyCount = proposedChanges.filter((change) => change.apply_supported).length;
1149
+ const unsupportedApplyCount = proposedChanges.filter((change) => !change.apply_supported).length;
887
1150
  const body = {
888
1151
  action: "fix.plan",
889
1152
  schema_version: 1,
890
1153
  root,
891
1154
  family,
892
1155
  target: options.target ?? null,
1156
+ base_ref: options.baseRef ?? null,
893
1157
  dirty: collectDirtyState(root),
894
1158
  families: emptyFamilySummaries(selected).map((entry) => ({
895
1159
  ...entry,
@@ -903,9 +1167,13 @@ function collectFixPlan(options) {
903
1167
  selected_families: selected,
904
1168
  proposed_count: proposedChanges.length,
905
1169
  blocked_count: blockedChanges.length,
906
- apply_supported: false,
907
- apply_deferred: true,
908
- message: "fix apply is not available; this command is review-only and writes no files",
1170
+ apply_supported: supportedApplyCount > 0,
1171
+ apply_deferred: unsupportedApplyCount > 0 || blockedChanges.length > 0,
1172
+ supported_apply_count: supportedApplyCount,
1173
+ unsupported_apply_count: unsupportedApplyCount,
1174
+ message: supportedApplyCount > 0
1175
+ ? "ids-family duplicate-id repairs can be applied with mdkg fix apply --family ids or mdkg fix ids --apply"
1176
+ : "this command is review-only for the selected findings and writes no files",
909
1177
  },
910
1178
  };
911
1179
  const planHash = sha256(body);
@@ -917,6 +1185,171 @@ function collectFixPlan(options) {
917
1185
  plan_id: `fix-plan-${planHash.slice("sha256:".length, "sha256:".length + 16)}`,
918
1186
  };
919
1187
  }
1188
+ function normalizeApplyFamily(value) {
1189
+ const family = normalizeFamily(value ?? "ids");
1190
+ if (family !== "ids") {
1191
+ throw new errors_1.UsageError("fix apply currently supports only --family ids");
1192
+ }
1193
+ return "ids";
1194
+ }
1195
+ function applyDuplicateIdChange(root, change) {
1196
+ if (change.family !== "ids" || change.reason !== "duplicate_id" || change.apply_kind !== "duplicate_id_rewrite") {
1197
+ throw new errors_1.UsageError(`unsupported fix apply change ${change.id}`);
1198
+ }
1199
+ const relativePath = change.paths[0];
1200
+ if (!relativePath) {
1201
+ throw new errors_1.UsageError(`fix apply change ${change.id} is missing a path`);
1202
+ }
1203
+ const absPath = path_1.default.resolve(root, relativePath);
1204
+ if (!isInsideRoot(root, absPath)) {
1205
+ throw new errors_1.UsageError(`fix apply refused path outside repo: ${relativePath}`);
1206
+ }
1207
+ const before = change.before;
1208
+ const after = change.after;
1209
+ const fromId = typeof before.duplicate_id === "string" ? before.duplicate_id : undefined;
1210
+ const toId = typeof after.candidate_id === "string" ? after.candidate_id : undefined;
1211
+ if (!fromId || !toId) {
1212
+ throw new errors_1.UsageError(`fix apply change ${change.id} is missing duplicate id rewrite details`);
1213
+ }
1214
+ const current = fs_1.default.readFileSync(absPath, "utf8");
1215
+ const rewritten = rewriteIdInNodeContent(current, fromId, toId);
1216
+ if (rewritten === current) {
1217
+ throw new errors_1.UsageError(`fix apply change ${change.id} produced no file changes`);
1218
+ }
1219
+ (0, atomic_1.atomicWriteFile)(absPath, rewritten);
1220
+ const afterDetails = change.after;
1221
+ const safeReferenceRewrites = Array.isArray(afterDetails.safe_reference_rewrites)
1222
+ ? afterDetails.safe_reference_rewrites
1223
+ : [];
1224
+ const touchedPaths = new Set([relativePath]);
1225
+ for (const rewrite of safeReferenceRewrites) {
1226
+ if (typeof rewrite.path !== "string" || typeof rewrite.from !== "string" || typeof rewrite.to !== "string") {
1227
+ continue;
1228
+ }
1229
+ const rewriteAbs = path_1.default.resolve(root, rewrite.path);
1230
+ if (!isInsideRoot(root, rewriteAbs) || !fs_1.default.existsSync(rewriteAbs)) {
1231
+ continue;
1232
+ }
1233
+ const rewriteCurrent = fs_1.default.readFileSync(rewriteAbs, "utf8");
1234
+ const rewriteNext = rewriteCurrent.split(rewrite.from).join(rewrite.to);
1235
+ if (rewriteNext !== rewriteCurrent) {
1236
+ (0, atomic_1.atomicWriteFile)(rewriteAbs, rewriteNext);
1237
+ touchedPaths.add(rewrite.path);
1238
+ }
1239
+ }
1240
+ return {
1241
+ id: change.id,
1242
+ family: "ids",
1243
+ reason: change.reason,
1244
+ apply_kind: "duplicate_id_rewrite",
1245
+ path: relativePath,
1246
+ touched_paths: Array.from(touchedPaths).sort(),
1247
+ refs: change.refs,
1248
+ before: change.before,
1249
+ after: change.after,
1250
+ };
1251
+ }
1252
+ function applyGitStageDuplicateIdChange(root, change) {
1253
+ if (change.family !== "ids" || change.reason !== "git_stage_duplicate_id" || change.apply_kind !== "git_stage_duplicate_id_rewrite") {
1254
+ throw new errors_1.UsageError(`unsupported git-stage fix apply change ${change.id}`);
1255
+ }
1256
+ const before = change.before;
1257
+ const after = change.after;
1258
+ const fromId = typeof before.duplicate_id === "string" ? before.duplicate_id : undefined;
1259
+ const conflictPath = typeof before.conflict_path === "string" ? before.conflict_path : undefined;
1260
+ const candidateId = typeof after.candidate_id === "string" ? after.candidate_id : undefined;
1261
+ const candidatePath = typeof after.candidate_path === "string" ? after.candidate_path : undefined;
1262
+ if (!fromId || !conflictPath || !candidateId || !candidatePath) {
1263
+ throw new errors_1.UsageError(`fix apply change ${change.id} is missing git-stage rewrite details`);
1264
+ }
1265
+ const canonicalContent = gitShow(root, `:2:${conflictPath}`);
1266
+ const duplicateContent = gitShow(root, `:3:${conflictPath}`);
1267
+ if (canonicalContent === undefined || duplicateContent === undefined) {
1268
+ throw new errors_1.UsageError(`fix apply change ${change.id} could not read Git conflict stages for ${conflictPath}`);
1269
+ }
1270
+ const canonicalAbs = path_1.default.resolve(root, conflictPath);
1271
+ const candidateAbs = path_1.default.resolve(root, candidatePath);
1272
+ if (!isInsideRoot(root, canonicalAbs) || !isInsideRoot(root, candidateAbs)) {
1273
+ throw new errors_1.UsageError(`fix apply refused path outside repo while resolving ${conflictPath}`);
1274
+ }
1275
+ const rewrittenDuplicate = rewriteIdInNodeContent(duplicateContent, fromId, candidateId);
1276
+ (0, atomic_1.atomicWriteFile)(canonicalAbs, canonicalContent);
1277
+ (0, atomic_1.atomicWriteFile)(candidateAbs, rewrittenDuplicate);
1278
+ runGitStrict(root, ["add", "--", conflictPath, candidatePath]);
1279
+ return {
1280
+ id: change.id,
1281
+ family: "ids",
1282
+ reason: change.reason,
1283
+ apply_kind: "git_stage_duplicate_id_rewrite",
1284
+ path: conflictPath,
1285
+ touched_paths: [conflictPath, candidatePath],
1286
+ refs: change.refs,
1287
+ before: change.before,
1288
+ after: change.after,
1289
+ };
1290
+ }
1291
+ function collectFixApply(options) {
1292
+ const family = normalizeApplyFamily(options.family);
1293
+ const root = relativeRoot(options.root);
1294
+ const config = (0, config_1.loadConfig)(root);
1295
+ return (0, lock_1.withMutationLock)(root, config.index.lock_timeout_ms, () => {
1296
+ const plan = collectFixPlan({ ...options, root, family });
1297
+ const applicable = plan.proposed_changes.filter((change) => change.apply_supported);
1298
+ const unsupported = plan.proposed_changes.filter((change) => !change.apply_supported);
1299
+ if (plan.blocked_changes.length > 0) {
1300
+ throw new errors_1.UsageError("fix apply refused because the plan contains blocked changes");
1301
+ }
1302
+ if (applicable.length === 0) {
1303
+ throw new errors_1.UsageError("fix apply found no supported ids-family changes to apply");
1304
+ }
1305
+ const appliedChanges = applicable.map((change) => change.apply_kind === "git_stage_duplicate_id_rewrite"
1306
+ ? applyGitStageDuplicateIdChange(root, change)
1307
+ : applyDuplicateIdChange(root, change));
1308
+ const indexReceipt = (0, index_1.rebuildDerivedIndexCaches)({ root, tolerant: true });
1309
+ const body = {
1310
+ action: "fix.apply",
1311
+ ok: true,
1312
+ schema_version: 1,
1313
+ root,
1314
+ family,
1315
+ target: options.target ?? null,
1316
+ base_ref: options.baseRef ?? null,
1317
+ plan_id: plan.plan_id,
1318
+ plan_hash: plan.plan_hash,
1319
+ applied_changes: appliedChanges,
1320
+ blocked_changes: plan.blocked_changes,
1321
+ unsupported_changes: unsupported,
1322
+ touched_paths: Array.from(new Set(appliedChanges.flatMap((change) => change.touched_paths))).sort(),
1323
+ ambiguous_reference_rewrites: applicable.flatMap((change) => {
1324
+ const after = change.after;
1325
+ return Array.isArray(after.ambiguous_reference_rewrites) ? after.ambiguous_reference_rewrites : [];
1326
+ }),
1327
+ index: {
1328
+ rebuilt: true,
1329
+ paths: {
1330
+ nodes: rel(root, indexReceipt.paths.nodes),
1331
+ skills: rel(root, indexReceipt.paths.skills),
1332
+ capabilities: rel(root, indexReceipt.paths.capabilities),
1333
+ subgraphs: rel(root, indexReceipt.paths.subgraphs),
1334
+ sqlite: indexReceipt.paths.sqlite ? rel(root, indexReceipt.paths.sqlite) : null,
1335
+ },
1336
+ },
1337
+ summary: {
1338
+ applied_count: appliedChanges.length,
1339
+ unsupported_count: unsupported.length,
1340
+ blocked_count: plan.blocked_changes.length,
1341
+ message: unsupported.length > 0
1342
+ ? "applied supported ids-family changes; unsupported findings remain review-only"
1343
+ : "applied supported ids-family duplicate-id repairs",
1344
+ },
1345
+ };
1346
+ return {
1347
+ ...body,
1348
+ generated_at: new Date().toISOString(),
1349
+ receipt_hash: sha256(body),
1350
+ };
1351
+ });
1352
+ }
920
1353
  function runFixPlanCommand(options) {
921
1354
  const payload = collectFixPlan(options);
922
1355
  if (options.json) {
@@ -929,6 +1362,25 @@ function runFixPlanCommand(options) {
929
1362
  console.log(`family: ${payload.family}`);
930
1363
  console.log(`proposed_changes: ${payload.proposed_changes.length}`);
931
1364
  console.log(`blocked_changes: ${payload.blocked_changes.length}`);
932
- console.log("apply_supported: false");
933
- console.log("note: fix apply is not available; rerun with --json for the machine-readable receipt");
1365
+ console.log(`apply_supported: ${payload.summary.apply_supported}`);
1366
+ console.log("note: use --json for the machine-readable receipt");
1367
+ }
1368
+ function runFixApplyCommand(options) {
1369
+ const payload = collectFixApply(options);
1370
+ if (options.json) {
1371
+ console.log(JSON.stringify(payload, null, 2));
1372
+ return;
1373
+ }
1374
+ console.log("fix apply");
1375
+ console.log(`receipt_hash: ${payload.receipt_hash}`);
1376
+ console.log(`family: ${payload.family}`);
1377
+ console.log(`applied_changes: ${payload.applied_changes.length}`);
1378
+ console.log(`touched_paths: ${payload.touched_paths.join(", ")}`);
1379
+ }
1380
+ function runFixIdsCommand(options) {
1381
+ if (options.apply) {
1382
+ runFixApplyCommand({ ...options, family: "ids" });
1383
+ return;
1384
+ }
1385
+ runFixPlanCommand({ ...options, family: "ids" });
934
1386
  }
@@ -27,14 +27,20 @@ Primary commands:
27
27
  - `mdkg task`
28
28
  - `mdkg validate`
29
29
  - `mdkg status [--json]`
30
- - `mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--json]`
30
+ - `mdkg fix plan [--family index|refs|ids|all] [--target <id-or-qid>] [--base-ref <ref>] [--json]`
31
+ - `mdkg fix apply [--family ids] [--target <id-or-qid>] [--base-ref <ref>] [--json]`
32
+ - `mdkg fix ids [--target <id-or-qid>] [--base-ref <ref>] [--apply] [--json]`
31
33
 
32
34
  Operator health:
33
35
  - `mdkg status [--json]` is a read-only summary for scripts and agents
34
36
  - reports mdkg version/config, git state, graph/index freshness, selected-goal state, project DB verification summary, and generated cache status
35
37
  - does not rebuild indexes, run migrations, repair files, mutate graph nodes, or change selected-goal state
36
- - `mdkg fix plan ...` is dry-run repair planning only; it writes nothing and `fix apply` is not exposed
37
- - `fix plan --json` returns a receipt-shaped plan with selected families, risk counts, paths, reason codes, and `apply_supported: false`
38
+ - `mdkg fix plan ...` is dry-run repair planning only; it writes nothing
39
+ - duplicate-ID graph repairs can be applied with `mdkg fix apply --family ids` or `mdkg fix ids --apply`
40
+ - use `--base-ref main` when mainline IDs should win branch-merge repair
41
+ - unresolved Git add/add conflict stages are split by keeping stage 2 at the conflicted path and writing stage 3 to a new canonical ID/path
42
+ - graph-reference and index/cache findings remain review-only guidance
43
+ - `fix plan --json` returns a receipt-shaped plan with selected families, risk counts, paths, reason codes, and per-change `apply_supported` metadata
38
44
 
39
45
  Index backend:
40
46
  - fresh mdkg workspaces default to `index.backend: sqlite`
@@ -45,8 +45,13 @@ fresh init, run `mdkg index` first so strict doctor can load generated caches.
45
45
 
46
46
  Use `mdkg fix plan --json` for dry-run repair guidance. It reports generated
47
47
  index/cache repair hints, missing graph references, and duplicate local ids as
48
- receipt-shaped planned changes with risk levels and `apply_supported: false`.
49
- `fix apply` is not exposed; repair application is intentionally deferred.
48
+ receipt-shaped planned changes with risk levels and per-change
49
+ `apply_supported` metadata. Duplicate-ID graph repairs can be applied with
50
+ `mdkg fix apply --family ids --json` or `mdkg fix ids --apply --json`; use
51
+ `--base-ref main` when mainline IDs should win. Index/cache and graph-reference
52
+ findings remain review-only. For unresolved Git add/add conflicts, `fix ids`
53
+ keeps stage 2 at the conflicted path, rewrites stage 3 to the next unused
54
+ canonical ID/path, and records a receipt.
50
55
 
51
56
  Use research spikes for investigation and planning work that should produce a
52
57
  reviewable recommendation before implementation:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "tool": "mdkg",
4
- "mdkg_version": "0.3.3",
4
+ "mdkg_version": "0.3.4",
5
5
  "files": [
6
6
  {
7
7
  "path": ".mdkg/config.json",
@@ -61,7 +61,7 @@
61
61
  {
62
62
  "path": ".mdkg/README.md",
63
63
  "category": "mdkg_doc",
64
- "sha256": "68b5f4d2d04a4dc9a062e54a594d684b3816c0b614079eb6b08f0dca16fd0b47"
64
+ "sha256": "5962993e8dffd53ccbceaaedd0b8f2a868421a3a1cc16d7002b661b39b7ebf79"
65
65
  },
66
66
  {
67
67
  "path": ".mdkg/skills/build-pack-and-execute-task/SKILL.md",
@@ -261,7 +261,7 @@
261
261
  {
262
262
  "path": "CLI_COMMAND_MATRIX.md",
263
263
  "category": "startup_doc",
264
- "sha256": "7f27715427a6762ef987fb872c9e09976075c76fddac68c4df89387e5b8a6909"
264
+ "sha256": "7322e6a2c47261f87dacd15ba0d93eba0d1e32eec0900a1e9f447ad13aaae860"
265
265
  },
266
266
  {
267
267
  "path": "llms.txt",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdkg",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Markdown Knowledge Graph",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "smoke:operator-health": "npm run build && node scripts/smoke-operator-health.js",
29
29
  "smoke:fix-plan": "npm run build && node scripts/smoke-fix-plan.js",
30
30
  "smoke:branch-conflicts": "npm run build && node scripts/smoke-branch-conflicts.js",
31
+ "smoke:id-repair": "npm run build && node scripts/smoke-id-repair.js",
31
32
  "smoke:command-docs": "npm run build && node scripts/smoke-command-docs.js",
32
33
  "smoke:spike": "npm run build && node scripts/smoke-spike.js",
33
34
  "smoke:goal-lifecycle": "npm run build && node scripts/smoke-goal-lifecycle.js",
@@ -41,7 +42,7 @@
41
42
  "cli:check": "npm run build && node scripts/cli_help_snapshot.js --check",
42
43
  "cli:contract": "npm run build && node scripts/generate-command-contract.js --check",
43
44
  "prepack": "npm run build && node scripts/assert-publish-ready.js",
44
- "prepublishOnly": "npm run test && npm run cli:check && npm run cli:contract && node dist/cli.js validate && npm run smoke:consumer && npm run smoke:matrix && npm run smoke:upgrade && npm run smoke:init && npm run smoke:capabilities && npm run smoke:db && npm run smoke:db-queue && npm run smoke:db-queue-cli && npm run smoke:db-events && npm run smoke:db-materializer && npm run smoke:db-snapshot && npm run smoke:archive-work && npm run smoke:work-invocation && npm run smoke:cli-ux-polish && npm run smoke:operator-health && npm run smoke:fix-plan && npm run smoke:branch-conflicts && npm run smoke:command-docs && npm run smoke:spike && npm run smoke:goal-lifecycle && npm run smoke:bundle && npm run smoke:subgraph && npm run smoke:visibility && npm run smoke:sqlite && npm run smoke:parallel && npm run smoke:goal && node scripts/assert-publish-ready.js",
45
+ "prepublishOnly": "npm run test && npm run cli:check && npm run cli:contract && node dist/cli.js validate && npm run smoke:consumer && npm run smoke:matrix && npm run smoke:upgrade && npm run smoke:init && npm run smoke:capabilities && npm run smoke:db && npm run smoke:db-queue && npm run smoke:db-queue-cli && npm run smoke:db-events && npm run smoke:db-materializer && npm run smoke:db-snapshot && npm run smoke:archive-work && npm run smoke:work-invocation && npm run smoke:cli-ux-polish && npm run smoke:operator-health && npm run smoke:fix-plan && npm run smoke:branch-conflicts && npm run smoke:id-repair && npm run smoke:command-docs && npm run smoke:spike && npm run smoke:goal-lifecycle && npm run smoke:bundle && npm run smoke:subgraph && npm run smoke:visibility && npm run smoke:sqlite && npm run smoke:parallel && npm run smoke:goal && node scripts/assert-publish-ready.js",
45
46
  "postinstall": "node scripts/postinstall.js",
46
47
  "smoke:subgraph": "npm run build && node scripts/smoke-subgraph.js"
47
48
  },