@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.
- package/CHANGELOG.md +118 -0
- package/README.md +2 -2
- package/SKILL.md +7 -0
- package/guide/cli.md +6 -4
- package/guide/consumers/index.md +106 -0
- package/guide/consumers/quickstart.md +96 -0
- package/guide/consumers/recipes/ci-gate.md +125 -0
- package/guide/consumers/recipes/dated-wiki.md +131 -0
- package/guide/consumers/recipes/format-gate.md +126 -0
- package/guide/consumers/recipes/post-write-heal.md +125 -0
- package/guide/consumers/recipes/skill-absent.md +111 -0
- package/guide/consumers/recipes/subject-wiki.md +110 -0
- package/guide/consumers/recipes/testing.md +149 -0
- package/guide/index.md +9 -0
- package/guide/substrate/operators.md +1 -1
- package/guide/substrate/tiered-ai.md +6 -5
- package/guide/ux/user-intent.md +6 -5
- package/package.json +9 -3
- package/scripts/cli.mjs +565 -15
- package/scripts/lib/balance.mjs +579 -0
- package/scripts/lib/cluster-detect.mjs +482 -4
- package/scripts/lib/contract.mjs +257 -0
- package/scripts/lib/decision-log.mjs +121 -15
- package/scripts/lib/heal.mjs +167 -0
- package/scripts/lib/init.mjs +210 -0
- package/scripts/lib/intent.mjs +370 -4
- package/scripts/lib/join-constants.mjs +22 -0
- package/scripts/lib/join.mjs +917 -0
- package/scripts/lib/json-envelope.mjs +190 -0
- package/scripts/lib/nest-applier.mjs +395 -32
- package/scripts/lib/operators.mjs +472 -38
- package/scripts/lib/orchestrator.mjs +419 -12
- package/scripts/lib/root-containment.mjs +351 -0
- package/scripts/lib/similarity-cache.mjs +115 -20
- package/scripts/lib/similarity.mjs +11 -0
- package/scripts/lib/soft-dag.mjs +726 -0
- package/scripts/lib/templates.mjs +78 -0
- package/scripts/lib/tiered.mjs +42 -18
- package/scripts/lib/validate.mjs +22 -0
- package/scripts/lib/where.mjs +71 -0
- package/scripts/testkit/assert-frontmatter.mjs +171 -0
- package/scripts/testkit/cli-run.mjs +95 -0
- package/scripts/testkit/make-wiki-fixture.mjs +301 -0
- package/scripts/testkit/stub-skill.mjs +107 -0
- package/templates/adrs.llmwiki.layout.yaml +33 -0
- package/templates/plans.llmwiki.layout.yaml +34 -0
- package/templates/regressions.llmwiki.layout.yaml +34 -0
- package/templates/reports.llmwiki.layout.yaml +33 -0
- package/templates/runbooks.llmwiki.layout.yaml +33 -0
- 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
|
|
126
|
-
// debugging a broken install can still get
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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
|
|
131
|
-
_argvDP[0] === "--version" ||
|
|
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 (!
|
|
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|
|
|
329
|
+
--quality-mode tiered-fast|claude-first|deterministic
|
|
310
330
|
Default: tiered-fast (TF-IDF → embeddings
|
|
311
|
-
→ Claude ladder).
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
819
|
-
|
|
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(
|
|
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);
|