@ctxr/skill-llm-wiki 1.0.1 → 1.1.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +7 -0
  4. package/guide/cli.md +6 -4
  5. package/guide/consumers/index.md +106 -0
  6. package/guide/consumers/quickstart.md +96 -0
  7. package/guide/consumers/recipes/ci-gate.md +125 -0
  8. package/guide/consumers/recipes/dated-wiki.md +131 -0
  9. package/guide/consumers/recipes/format-gate.md +126 -0
  10. package/guide/consumers/recipes/post-write-heal.md +125 -0
  11. package/guide/consumers/recipes/skill-absent.md +111 -0
  12. package/guide/consumers/recipes/subject-wiki.md +110 -0
  13. package/guide/consumers/recipes/testing.md +149 -0
  14. package/guide/index.md +9 -0
  15. package/guide/substrate/operators.md +1 -1
  16. package/guide/substrate/tiered-ai.md +6 -5
  17. package/guide/ux/user-intent.md +6 -5
  18. package/package.json +9 -3
  19. package/scripts/cli.mjs +565 -15
  20. package/scripts/lib/balance.mjs +579 -0
  21. package/scripts/lib/cluster-detect.mjs +482 -4
  22. package/scripts/lib/contract.mjs +257 -0
  23. package/scripts/lib/decision-log.mjs +121 -15
  24. package/scripts/lib/heal.mjs +167 -0
  25. package/scripts/lib/init.mjs +210 -0
  26. package/scripts/lib/intent.mjs +370 -4
  27. package/scripts/lib/join-constants.mjs +22 -0
  28. package/scripts/lib/join.mjs +917 -0
  29. package/scripts/lib/json-envelope.mjs +190 -0
  30. package/scripts/lib/nest-applier.mjs +395 -32
  31. package/scripts/lib/operators.mjs +472 -38
  32. package/scripts/lib/orchestrator.mjs +419 -12
  33. package/scripts/lib/root-containment.mjs +351 -0
  34. package/scripts/lib/similarity-cache.mjs +115 -20
  35. package/scripts/lib/similarity.mjs +11 -0
  36. package/scripts/lib/soft-dag.mjs +726 -0
  37. package/scripts/lib/templates.mjs +78 -0
  38. package/scripts/lib/tiered.mjs +42 -18
  39. package/scripts/lib/validate.mjs +22 -0
  40. package/scripts/lib/where.mjs +71 -0
  41. package/scripts/testkit/assert-frontmatter.mjs +171 -0
  42. package/scripts/testkit/cli-run.mjs +95 -0
  43. package/scripts/testkit/make-wiki-fixture.mjs +301 -0
  44. package/scripts/testkit/stub-skill.mjs +107 -0
  45. package/templates/adrs.llmwiki.layout.yaml +33 -0
  46. package/templates/plans.llmwiki.layout.yaml +34 -0
  47. package/templates/regressions.llmwiki.layout.yaml +34 -0
  48. package/templates/reports.llmwiki.layout.yaml +33 -0
  49. package/templates/runbooks.llmwiki.layout.yaml +33 -0
  50. package/templates/sessions.llmwiki.layout.yaml +34 -0
package/scripts/cli.mjs CHANGED
@@ -122,15 +122,23 @@ function _depPreflightFailMessage(missing) {
122
122
  );
123
123
  }
124
124
 
125
- // Skip the dep check entirely for --version and --help so an operator
126
- // debugging a broken install can still get version/usage output. Every
127
- // other invocation (including `--help` placed AFTER another arg, which
128
- // is a malformed invocation we don't need to coddle) runs the check.
125
+ // Skip the dep check entirely for --version, --help, `contract`, and
126
+ // `where` so an operator debugging a broken install can still get
127
+ // version/usage output, and so consumers can probe the contract
128
+ // before the runtime dependencies are necessarily resolved. Every
129
+ // other invocation (including `--help` placed AFTER another arg,
130
+ // which is a malformed invocation we don't need to coddle) runs the
131
+ // check.
129
132
  const _argvDP = process.argv.slice(2);
130
- const _isVersionOrHelpDP =
131
- _argvDP[0] === "--version" || _argvDP[0] === "--help" || _argvDP[0] === "-h";
133
+ const _isPreflightExemptDP =
134
+ _argvDP[0] === "--version" ||
135
+ _argvDP[0] === "--help" ||
136
+ _argvDP[0] === "-h" ||
137
+ _argvDP[0] === "contract" ||
138
+ _argvDP[0] === "where" ||
139
+ _argvDP[0] === "testkit-stub";
132
140
 
133
- if (!_isVersionOrHelpDP) {
141
+ if (!_isPreflightExemptDP) {
134
142
  let _missingDP = _depPreflightCheck();
135
143
  if (_missingDP.length > 0) {
136
144
  process.stderr.write(_depPreflightFailMessage(_missingDP));
@@ -288,6 +296,18 @@ Remote mirroring (explicit user-invoked only, never auto-pushes):
288
296
  remote <wiki> list List configured remotes
289
297
  sync <wiki> [--remote <name>] Fetch + push tag refs explicitly
290
298
 
299
+ Consumer-facing scaffolding:
300
+ init <topic> --kind dated|subject [--template <name>] [--force] [--json]
301
+ Seed a topic directory with a shipped
302
+ layout contract. Prints the exact build
303
+ command to run next. Replaces the
304
+ cp + edit + build-flag dance.
305
+ heal <wiki> [--json] Classify validate findings into
306
+ ok / fixable / needs-rebuild / broken
307
+ and name the next command to run.
308
+ Routes consumers through the right
309
+ mutating op without guessing.
310
+
291
311
  Low-level script helpers (deterministic, called by Claude):
292
312
  ingest <source> Walk source, emit candidate JSON
293
313
  draft-leaf <candidate-file> Script-first frontmatter draft for one candidate
@@ -306,18 +326,65 @@ Layout-mode flags (build/extend/rebuild/fix/join):
306
326
  --target <path> Explicit destination (required for hosted)
307
327
 
308
328
  Tiered-AI flags:
309
- --quality-mode tiered-fast|claude-first|tier0-only
329
+ --quality-mode tiered-fast|claude-first|deterministic
310
330
  Default: tiered-fast (TF-IDF → embeddings
311
- → Claude ladder). See guide/tiered-ai.md.
331
+ → Claude ladder). The 'deterministic' mode
332
+ resolves every decision algorithmically
333
+ (no Tier 2 calls) for byte-reproducible
334
+ builds. See guide/tiered-ai.md.
335
+
336
+ Post-convergence enforcement flags (build/rebuild):
337
+ --fanout-target <N> Post-convergence phase ATTEMPTS to
338
+ sub-cluster any directory whose movable
339
+ leaf count exceeds N × 1.5 (subdirs aren't
340
+ counted — the rebalance can only carve
341
+ clusters from leaves, so subdir-heavy
342
+ dirs are un-actionable). An overfull dir
343
+ is left unchanged when no coherent
344
+ cluster is detected (leaves too diverse
345
+ or too few). N must be an integer in
346
+ [2, 100]. No-op when omitted.
347
+ --max-depth <D> Post-convergence phase flattens any
348
+ single-child passthrough deeper than D. D
349
+ must be an integer in [1, 10]. No-op when
350
+ omitted.
351
+ --soft-dag-parents Post-convergence phase synthesises
352
+ soft-parent pointers in each leaf's
353
+ parents[] based on TF-IDF cosine
354
+ similarity against candidate category
355
+ directories. Leaves appear in multiple
356
+ index.md entries[] (DAG view) rather
357
+ than only their direct parent. Uses
358
+ SOFT_PARENT_AFFINITY_THRESHOLD (0.35)
359
+ and caps at SOFT_PARENT_MAX_PER_LEAF (3)
360
+ per leaf. No-op when omitted.
312
361
 
313
362
  UX flags:
314
363
  --no-prompt Never prompt; fail loud on ambiguity
315
- --json-errors Emit ambiguity errors as JSON
364
+ --json Emit JSON on stdout. Operational
365
+ commands (validate, init, heal, rollback)
366
+ use the envelope schema
367
+ skill-llm-wiki/v1 for both success and
368
+ usage-error paths. Probe commands
369
+ (contract, where) emit their own
370
+ command-specific schemas
371
+ (skill-llm-wiki/contract/v1 and
372
+ skill-llm-wiki/where/v1).
373
+ --json-errors Legacy alias for --json, kept for
374
+ backwards compatibility.
316
375
  --accept-dirty Operate on a dirty user git repo
317
376
 
318
377
  Rollback flags:
319
378
  --to <ref> genesis | <op-id> | pre-<op-id> | HEAD~N
320
379
 
380
+ Consumer probes (exempt from runtime-dep preflight):
381
+ contract [--json] Print machine-readable format + CLI surface
382
+ contract. Consumers gate on format_version
383
+ instead of drift-testing SKILL.md.
384
+ where [--json] Print absolute paths to the skill root,
385
+ SKILL.md, guide/, templates/, and testkit/.
386
+ Resolves the install path without kit lookup.
387
+
321
388
  Global:
322
389
  --version Print CLI version
323
390
  --help, -h Show this help
@@ -340,13 +407,18 @@ const FLAG_WITH_VALUE = new Set([
340
407
  "--to",
341
408
  "--canonical",
342
409
  "--quality-mode",
410
+ "--fanout-target",
411
+ "--max-depth",
412
+ "--id-collision",
343
413
  ]);
344
414
  const FLAG_BOOLEAN = new Set([
345
415
  "--no-prompt",
416
+ "--json",
346
417
  "--json-errors",
347
418
  "--accept-dirty",
348
419
  "--accept-foreign-target",
349
420
  "--review",
421
+ "--soft-dag-parents",
350
422
  ]);
351
423
 
352
424
  function parseSubArgv(raw) {
@@ -437,6 +509,74 @@ async function main() {
437
509
  return;
438
510
  }
439
511
 
512
+ // `contract` and `where` are exempt from the dep preflight (see
513
+ // the preflight-skip list above) so consumers can probe the
514
+ // skill's surface before the runtime deps are resolvable. Both
515
+ // pull only pure-data from scripts/lib/* and do not touch any
516
+ // wiki state.
517
+ if (argv[0] === "contract") {
518
+ const { getContract, renderContractText } = await import("./lib/contract.mjs");
519
+ const { hasJsonFlag } = await import("./lib/json-envelope.mjs");
520
+ const wantJson = hasJsonFlag(argv.slice(1));
521
+ const contract = getContract();
522
+ if (wantJson) {
523
+ process.stdout.write(JSON.stringify(contract, null, 2) + "\n");
524
+ } else {
525
+ process.stdout.write(renderContractText(contract));
526
+ }
527
+ return;
528
+ }
529
+ if (argv[0] === "where") {
530
+ const { getWhere, renderWhereText } = await import("./lib/where.mjs");
531
+ const { hasJsonFlag } = await import("./lib/json-envelope.mjs");
532
+ const wantJson = hasJsonFlag(argv.slice(1));
533
+ const info = getWhere();
534
+ if (wantJson) {
535
+ process.stdout.write(JSON.stringify(info, null, 2) + "\n");
536
+ } else {
537
+ process.stdout.write(renderWhereText(info));
538
+ }
539
+ return;
540
+ }
541
+ if (argv[0] === "testkit-stub") {
542
+ // Shell shorthand for scripts/testkit/stub-skill.mjs. Consumers
543
+ // whose test suites shell out (e.g. zero-deps agent bundles that
544
+ // can't import from node_modules) use this to seed a stub skill
545
+ // install under an arbitrary base directory. Exempt from the
546
+ // runtime-dep preflight because testkit helpers must work in
547
+ // test environments that deliberately lack the runtime deps.
548
+ const rest = argv.slice(1);
549
+ let atDir = null;
550
+ let layout = "claude-skills";
551
+ for (let i = 0; i < rest.length; i++) {
552
+ const tok = rest[i];
553
+ if (tok === "--at") {
554
+ atDir = rest[++i];
555
+ } else if (tok.startsWith("--at=")) {
556
+ atDir = tok.slice("--at=".length);
557
+ } else if (tok === "--layout") {
558
+ layout = rest[++i];
559
+ } else if (tok.startsWith("--layout=")) {
560
+ layout = tok.slice("--layout=".length);
561
+ } else {
562
+ process.stderr.write(
563
+ `testkit-stub: unknown argument "${tok}"\n`,
564
+ );
565
+ process.exit(1);
566
+ }
567
+ }
568
+ if (!atDir) {
569
+ process.stderr.write(
570
+ "testkit-stub: --at <dir> is required (base directory to seed)\n",
571
+ );
572
+ process.exit(1);
573
+ }
574
+ const { stubSkill } = await import("./testkit/stub-skill.mjs");
575
+ const r = await stubSkill({ home: atDir, layout });
576
+ process.stdout.write(`${r.skillMd}\n`);
577
+ return;
578
+ }
579
+
440
580
  // The dependency preflight has already run in the pre-import block
441
581
  // at the top of this file. By the time we reach this point, every
442
582
  // required runtime dep has been verified or the process has exited
@@ -587,7 +727,8 @@ async function main() {
587
727
  if (INTENT_SUBCOMMANDS.has(cmd)) {
588
728
  const parsed = parseSubArgv(args);
589
729
  if (parsed.error) {
590
- const jsonMode = args.includes("--json-errors");
730
+ const { hasJsonFlag } = await import("./lib/json-envelope.mjs");
731
+ const jsonMode = hasJsonFlag(args);
591
732
  emitIntentError(
592
733
  {
593
734
  code: "INT-11",
@@ -599,7 +740,9 @@ async function main() {
599
740
  );
600
741
  }
601
742
  const { positionals, flags } = parsed;
602
- const jsonMode = Boolean(flags.json_errors);
743
+ // `--json` is the canonical flag; `--json-errors` is the legacy
744
+ // alias kept for existing consumers. Either enables JSON output.
745
+ const jsonMode = Boolean(flags.json_errors) || Boolean(flags.json);
603
746
 
604
747
  // `migrate` has its own resolution path — the intent resolver would
605
748
  // reject the legacy folder shape as ambiguous.
@@ -667,6 +810,29 @@ async function main() {
667
810
 
668
811
  if (cmd === "rollback") {
669
812
  const result = rollbackOperation(plan.target, flags.to);
813
+ if (jsonMode) {
814
+ const { makeEnvelope, writeEnvelope } = await import(
815
+ "./lib/json-envelope.mjs"
816
+ );
817
+ writeEnvelope(
818
+ makeEnvelope({
819
+ command: "rollback",
820
+ target: plan.target,
821
+ verdict: "ok",
822
+ exit: 0,
823
+ diagnostics: [
824
+ {
825
+ code: "ROLLBACK-01",
826
+ severity: "info",
827
+ path: plan.target,
828
+ message: `rolled back to ${result.ref} (sha=${result.sha ?? "n/a"})`,
829
+ },
830
+ ],
831
+ artifacts: { modified: [plan.target] },
832
+ }),
833
+ );
834
+ return;
835
+ }
670
836
  process.stdout.write(
671
837
  `rolled back ${plan.target} to ${result.ref} (${result.sha ?? "n/a"})\n`,
672
838
  );
@@ -682,12 +848,69 @@ async function main() {
682
848
  }
683
849
  const opId = newOpId(cmd);
684
850
  const startedIso = new Date().toISOString();
851
+ // X.9 progress: stream each orchestrator phase's `record()`
852
+ // output to stderr as it fires. On by default for interactive
853
+ // use; suppressed in JSON mode so stderr stays reserved for
854
+ // structured JSON diagnostics/errors (build/rebuild/fix/join
855
+ // still print their human-readable summary to stdout under
856
+ // --json, but a consumer tailing stderr under --json expects
857
+ // it to be empty on success or carry only structured
858
+ // diagnostics — progress breadcrumbs would mix into that
859
+ // channel). Also suppressed via `LLM_WIKI_NO_PROGRESS=1` for
860
+ // CI / hermetic runs that want the pre-X.9 silent shape.
861
+ // Progress goes to stderr (never stdout) so consumers piping
862
+ // the command's stdout don't conflate phase chatter with the
863
+ // final op-summary payload.
864
+ const suppressProgress =
865
+ jsonMode || process.env.LLM_WIKI_NO_PROGRESS === "1";
866
+ // A closed-stderr scenario (downstream pipe consumer that
867
+ // drops the stderr pipe, e.g. `… | head`) surfaces two ways:
868
+ // a synchronous throw on `.write()` (try/catch below), and
869
+ // an async `'error'` event with code EPIPE that the default
870
+ // Node handler escalates to an unhandled exception. Attach a
871
+ // best-effort one-time 'error' listener to swallow EPIPE
872
+ // while progress is enabled; other stream errors still
873
+ // surface via Node's default handler so we aren't masking
874
+ // bugs unrelated to the progress stream.
875
+ let stderrEpipeSilenced = false;
876
+ const onProgress = suppressProgress
877
+ ? null
878
+ : (phase) => {
879
+ if (!stderrEpipeSilenced) {
880
+ stderrEpipeSilenced = true;
881
+ try {
882
+ process.stderr.on("error", (err) => {
883
+ if (err && err.code === "EPIPE") return;
884
+ // Re-emit non-EPIPE errors so we don't hide real
885
+ // problems — Node's default handler will decide.
886
+ throw err;
887
+ });
888
+ } catch {
889
+ /* attach failed — progress reporter will best-effort */
890
+ }
891
+ }
892
+ // `.destroyed` / `.writableEnded` guards let us skip the
893
+ // write before it would throw, which is cheaper than
894
+ // catching the exception and matches the behaviour on
895
+ // an already-closed stream.
896
+ if (process.stderr.destroyed || process.stderr.writableEnded) {
897
+ return;
898
+ }
899
+ try {
900
+ process.stderr.write(
901
+ `[${opId} ${phase.index}] ${phase.name}: ${phase.summary}\n`,
902
+ );
903
+ } catch {
904
+ /* Best-effort — a closed stderr shouldn't halt the op. */
905
+ }
906
+ };
685
907
  let result;
686
908
  try {
687
909
  result = await runOperation(plan, {
688
910
  opId,
689
911
  source: plan.source,
690
912
  startedIso,
913
+ onProgress,
691
914
  });
692
915
  } catch (err) {
693
916
  if (err instanceof NonInteractiveError) {
@@ -815,10 +1038,59 @@ async function main() {
815
1038
  break;
816
1039
  }
817
1040
  case "validate": {
818
- if (args.length < 1) usageError("validate requires <wiki>");
819
- const wiki = resolve(args[0]);
1041
+ // Parse flags so that `validate --json <wiki>` works as well
1042
+ // as `validate <wiki> --json`, and unknown flags are rejected
1043
+ // loudly. Pre-scan --json first so usage errors emitted below
1044
+ // respect the JSON contract.
1045
+ const { hasJsonFlag } = await import("./lib/json-envelope.mjs");
1046
+ const wantJson = hasJsonFlag(args);
1047
+ const positionals = [];
1048
+ for (const tok of args) {
1049
+ if (tok === "--json" || tok === "--json-errors") continue;
1050
+ if (typeof tok === "string" && tok.startsWith("--")) {
1051
+ await emitConsumerUsageError(
1052
+ "validate",
1053
+ "VALIDATE-USAGE",
1054
+ `validate does not support option ${tok}`,
1055
+ wantJson,
1056
+ );
1057
+ }
1058
+ positionals.push(tok);
1059
+ }
1060
+ if (positionals.length < 1)
1061
+ await emitConsumerUsageError(
1062
+ "validate",
1063
+ "VALIDATE-USAGE",
1064
+ "validate requires <wiki>",
1065
+ wantJson,
1066
+ );
1067
+ if (positionals.length > 1)
1068
+ await emitConsumerUsageError(
1069
+ "validate",
1070
+ "VALIDATE-USAGE",
1071
+ "validate accepts exactly one <wiki>",
1072
+ wantJson,
1073
+ );
1074
+ const wiki = resolve(positionals[0]);
1075
+ const startMs = Date.now();
820
1076
  const findings = validateWiki(wiki);
821
1077
  const summary = summariseFindings(findings);
1078
+ const exit = summary.errors > 0 ? 2 : 0;
1079
+ if (wantJson) {
1080
+ const { findingToDiagnostic, makeEnvelope, writeEnvelope } =
1081
+ await import("./lib/json-envelope.mjs");
1082
+ writeEnvelope(
1083
+ makeEnvelope({
1084
+ command: "validate",
1085
+ target: wiki,
1086
+ verdict: exit === 0 ? "ok" : "broken",
1087
+ exit,
1088
+ diagnostics: findings.map(findingToDiagnostic),
1089
+ timing_ms: Date.now() - startMs,
1090
+ }),
1091
+ );
1092
+ process.exit(exit);
1093
+ }
822
1094
  for (const f of findings) {
823
1095
  const tag =
824
1096
  f.severity === "error"
@@ -832,7 +1104,7 @@ async function main() {
832
1104
  console.log(
833
1105
  `\n${summary.errors} error(s), ${summary.warnings} warning(s)`,
834
1106
  );
835
- process.exit(summary.errors > 0 ? 2 : 0);
1107
+ process.exit(exit);
836
1108
  break;
837
1109
  }
838
1110
  case "shape-check": {
@@ -875,12 +1147,290 @@ async function main() {
875
1147
  process.stdout.write(`current → ${args[1]}\n`);
876
1148
  break;
877
1149
  }
1150
+ case "init": {
1151
+ // `init` seeds a topic directory with a shipped layout contract
1152
+ // so consumers can start building hosted wikis without the
1153
+ // cp-then-build dance. No orchestrator invocation here — that
1154
+ // remains the consumer's explicit `build` call so error paths
1155
+ // stay where they already are.
1156
+ await cmdInit(args);
1157
+ break;
1158
+ }
1159
+ case "heal": {
1160
+ // `heal` classifies validate findings into one of ok, fixable,
1161
+ // needs-rebuild, broken and names the next command to run.
1162
+ // Mutating action stays the consumer's explicit call so the
1163
+ // orchestrator error surface does not leak into this
1164
+ // classify-only path.
1165
+ await cmdHeal(args);
1166
+ break;
1167
+ }
878
1168
  default:
879
1169
  printUsage();
880
1170
  process.exit(1);
881
1171
  }
882
1172
  }
883
1173
 
1174
+ async function cmdInit(args) {
1175
+ const { runInit, InitError, renderInitText } = await import("./lib/init.mjs");
1176
+ const {
1177
+ hasJsonFlag,
1178
+ makeEnvelope,
1179
+ makeErrorEnvelope,
1180
+ writeEnvelope,
1181
+ } = await import("./lib/json-envelope.mjs");
1182
+ const wantJson = hasJsonFlag(args);
1183
+ const fail = (code, message, topic = null) =>
1184
+ initError(code, message, wantJson, topic, {
1185
+ makeErrorEnvelope,
1186
+ writeEnvelope,
1187
+ });
1188
+
1189
+ // Minimal flag parse for init. Positional is <topic>; flags are
1190
+ // --kind <dated|subject>, --template <name>, --force, --json.
1191
+ let topic = null;
1192
+ let kind = null;
1193
+ let template = null;
1194
+ let force = false;
1195
+ for (let i = 0; i < args.length; i++) {
1196
+ const tok = args[i];
1197
+ if (tok === "--force") {
1198
+ force = true;
1199
+ continue;
1200
+ }
1201
+ if (tok === "--json" || tok === "--json-errors") {
1202
+ continue;
1203
+ }
1204
+ if (tok === "--kind") {
1205
+ kind = args[++i];
1206
+ if (kind === undefined || kind === "" || kind.startsWith("--")) {
1207
+ fail("INIT-00", `init: flag "--kind" requires a value`);
1208
+ }
1209
+ continue;
1210
+ }
1211
+ if (tok.startsWith("--kind=")) {
1212
+ kind = tok.slice("--kind=".length);
1213
+ if (kind === "") {
1214
+ fail("INIT-00", `init: flag "--kind" requires a value`);
1215
+ }
1216
+ continue;
1217
+ }
1218
+ if (tok === "--template") {
1219
+ template = args[++i];
1220
+ if (
1221
+ template === undefined ||
1222
+ template === "" ||
1223
+ template.startsWith("--")
1224
+ ) {
1225
+ fail("INIT-00", `init: flag "--template" requires a value`);
1226
+ }
1227
+ continue;
1228
+ }
1229
+ if (tok.startsWith("--template=")) {
1230
+ template = tok.slice("--template=".length);
1231
+ if (template === "") {
1232
+ fail("INIT-00", `init: flag "--template" requires a value`);
1233
+ }
1234
+ continue;
1235
+ }
1236
+ if (tok.startsWith("--")) {
1237
+ fail("INIT-00", `init: unknown flag "${tok}"`);
1238
+ }
1239
+ if (topic === null) {
1240
+ topic = tok;
1241
+ continue;
1242
+ }
1243
+ fail("INIT-00", `init: unexpected positional "${tok}"`);
1244
+ }
1245
+ if (!topic) {
1246
+ fail("INIT-01", "init requires a <topic> path");
1247
+ }
1248
+
1249
+ const startMs = Date.now();
1250
+ let result;
1251
+ try {
1252
+ result = runInit({ topic, kind, template, force, cwd: process.cwd() });
1253
+ } catch (err) {
1254
+ if (err instanceof InitError) {
1255
+ fail(err.code, err.message);
1256
+ }
1257
+ throw err;
1258
+ }
1259
+
1260
+ if (wantJson) {
1261
+ writeEnvelope(
1262
+ makeEnvelope({
1263
+ command: "init",
1264
+ target: result.topic,
1265
+ verdict: "initialised",
1266
+ exit: 0,
1267
+ diagnostics: [
1268
+ {
1269
+ code: "NEXT-01",
1270
+ severity: "info",
1271
+ path: result.topic,
1272
+ message:
1273
+ `contract seeded; next step: ` +
1274
+ result.build_command.join(" "),
1275
+ },
1276
+ ],
1277
+ artifacts: { created: [result.contract_path] },
1278
+ next: result.next,
1279
+ timing_ms: Date.now() - startMs,
1280
+ }),
1281
+ );
1282
+ return;
1283
+ }
1284
+ process.stdout.write(renderInitText(result));
1285
+ }
1286
+
1287
+ async function cmdHeal(args) {
1288
+ const { runHeal, renderHealText } = await import("./lib/heal.mjs");
1289
+ const { findingToDiagnostic, hasJsonFlag, makeEnvelope, writeEnvelope } =
1290
+ await import("./lib/json-envelope.mjs");
1291
+
1292
+ const wantJson = hasJsonFlag(args);
1293
+ let wikiPath = null;
1294
+ // `--dry-run` is accepted for contract compatibility. heal is
1295
+ // already classify-only (never mutates) so --dry-run is a no-op
1296
+ // label today; the flag will become meaningful once a follow-up
1297
+ // adds --apply for inline fix/rebuild routing.
1298
+ for (const tok of args) {
1299
+ if (tok === "--json" || tok === "--json-errors") continue;
1300
+ if (tok === "--dry-run") continue;
1301
+ if (tok.startsWith("--")) {
1302
+ await emitConsumerUsageError(
1303
+ "heal",
1304
+ "HEAL-USAGE",
1305
+ `heal: unknown flag "${tok}"`,
1306
+ wantJson,
1307
+ );
1308
+ }
1309
+ if (wikiPath === null) {
1310
+ wikiPath = tok;
1311
+ continue;
1312
+ }
1313
+ await emitConsumerUsageError(
1314
+ "heal",
1315
+ "HEAL-USAGE",
1316
+ `heal: unexpected positional "${tok}"`,
1317
+ wantJson,
1318
+ );
1319
+ }
1320
+ if (!wikiPath) {
1321
+ await emitConsumerUsageError(
1322
+ "heal",
1323
+ "HEAL-USAGE",
1324
+ "heal requires <wiki> as its first argument",
1325
+ wantJson,
1326
+ );
1327
+ }
1328
+
1329
+ const absWiki = resolve(wikiPath);
1330
+ const startMs = Date.now();
1331
+ const result = runHeal(absWiki);
1332
+
1333
+ const diagnostics = result.findings.map(findingToDiagnostic);
1334
+ if (result.next_command) {
1335
+ diagnostics.push({
1336
+ code: "NEXT-01",
1337
+ severity: "info",
1338
+ path: result.target,
1339
+ message: `next: ${result.next_command.join(" ")}`,
1340
+ });
1341
+ }
1342
+ if (result.error) {
1343
+ diagnostics.unshift({
1344
+ code: "HEAL-00",
1345
+ severity: "error",
1346
+ path: result.target,
1347
+ message: result.error,
1348
+ });
1349
+ }
1350
+
1351
+ // heal is advisory, not a validator. Successful classification is
1352
+ // exit 0 regardless of verdict (ok / fixable / needs-rebuild) — the
1353
+ // envelope's `verdict` carries the state. Only a genuinely broken
1354
+ // or unclassifiable wiki is exit 6 (matches the documented "wiki
1355
+ // corrupt" meaning). Consumers gate on the envelope, not exit 2.
1356
+ const exit =
1357
+ result.verdict === "broken" || result.verdict === "ambiguous" ? 6 : 0;
1358
+ const nextField = result.next_command
1359
+ ? {
1360
+ command: result.next_command[0],
1361
+ args: result.next_command.slice(1),
1362
+ }
1363
+ : null;
1364
+ if (wantJson) {
1365
+ writeEnvelope(
1366
+ makeEnvelope({
1367
+ command: "heal",
1368
+ target: result.target,
1369
+ verdict: result.verdict,
1370
+ exit,
1371
+ diagnostics,
1372
+ next: nextField,
1373
+ timing_ms: Date.now() - startMs,
1374
+ }),
1375
+ );
1376
+ process.exit(exit);
1377
+ }
1378
+
1379
+ process.stdout.write(renderHealText(result));
1380
+ process.exit(exit);
1381
+ }
1382
+
1383
+ // Shared usage-error emitter for operational subcommands that
1384
+ // need to honour --json on the error path as well as success.
1385
+ // Emits the envelope (verdict="ambiguous", exit=1 for usage)
1386
+ // when wantJson, otherwise a plain-text stderr line + exit.
1387
+ // Used by validate, heal, and any future consumer-facing op
1388
+ // whose usage errors must stay parseable.
1389
+ async function emitConsumerUsageError(command, code, message, wantJson) {
1390
+ if (wantJson) {
1391
+ const { makeErrorEnvelope, writeEnvelope } = await import(
1392
+ "./lib/json-envelope.mjs"
1393
+ );
1394
+ writeEnvelope(
1395
+ makeErrorEnvelope({ command, code, message, exit: 1 }),
1396
+ );
1397
+ process.exit(1);
1398
+ }
1399
+ process.stderr.write(`error: ${message}\n`);
1400
+ process.exit(1);
1401
+ }
1402
+
1403
+ // Map init error codes to the skill's documented exit scheme.
1404
+ // INIT-00 / INIT-01 are CLI usage errors (missing flag, unknown
1405
+ // flag) → exit 1. INIT-02..08 are validation / ambiguity conditions
1406
+ // (bad --kind, template mismatch, contract collision, topic-as-
1407
+ // file, symlink refusal) → exit 2, matching how build/extend etc.
1408
+ // surface validation failures. INIT-08 specifically covers the
1409
+ // symlink-guard path (scripts/lib/init.mjs): the input path exists
1410
+ // but is a symbolic link we refuse to write through; consistent
1411
+ // with INIT-06 ("exists but not a directory") in both severity and
1412
+ // consumer recovery (fix the path, retry).
1413
+ const INIT_USAGE_CODES = new Set(["INIT-00", "INIT-01"]);
1414
+
1415
+ function initError(code, message, wantJson, topic, envelopeHelpers) {
1416
+ const exit = INIT_USAGE_CODES.has(code) ? 1 : 2;
1417
+ if (wantJson && envelopeHelpers) {
1418
+ const { makeErrorEnvelope, writeEnvelope } = envelopeHelpers;
1419
+ writeEnvelope(
1420
+ makeErrorEnvelope({
1421
+ command: "init",
1422
+ code,
1423
+ message,
1424
+ target: topic,
1425
+ exit,
1426
+ }),
1427
+ );
1428
+ process.exit(exit);
1429
+ }
1430
+ process.stderr.write(`error: ${code} ${message}\n`);
1431
+ process.exit(exit);
1432
+ }
1433
+
884
1434
  function usageError(msg) {
885
1435
  process.stderr.write(`error: ${msg}\n`);
886
1436
  printUsage(process.stderr);