@catheadowl/ukp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -135,7 +135,7 @@ Endpoint commands:
135
135
  | `ukp search` | Indexed search through the endpoint's provider (QMD today); `--recursive` expands endpoints explicitly declared as dependencies (authority/context). Results hand off via `ukp://` references. |
136
136
  | `ukp read` | Reads an endpoint-scoped resource — exact path, `ukp://` URI, or `docid` handoff key. |
137
137
  | `ukp nav` | Markdown outline of an endpoint (`--depth`, respects `.gitignore`); on by default, configurable via `[capabilities.nav]`. |
138
- | `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; results become `read`-ready `ukp://` references. A missing rg binary degrades to a skip, never a fault. |
138
+ | `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; results become `read`-ready `ukp://` references. A missing rg binary skips the endpoint with a warning (a single-endpoint run exits non-zero). |
139
139
  | `ukp propose` | Submits an idempotent change proposal (suggestion box — the owner decides what happens next). Created/unchanged/updated; the revision bumps only on `updated`. |
140
140
 
141
141
  Registry commands:
@@ -204,5 +204,6 @@ and the hardening posture — lives in the
204
204
  SSH/HTTPS, the host door, Caddy and Tailscale fronting, the certificate
205
205
  pinning lifecycle, and the hardening posture.
206
206
  - [Contributing](https://github.com/CatheadOwl/ukp/blob/main/CONTRIBUTING.md) — development setup and release packaging.
207
+ - [Changelog](https://github.com/CatheadOwl/ukp/blob/main/CHANGELOG.md) — what changed per release.
207
208
  - Repository: https://github.com/CatheadOwl/ukp ·
208
209
  Issues: https://github.com/CatheadOwl/ukp/issues
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catheadowl/ukp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3.14"
@@ -441,7 +441,8 @@ export function runPropose(request: ProposeRequest, context: ProposeContext): Pr
441
441
  ok: false,
442
442
  failure: {
443
443
  errorClass: "capability-undeclared",
444
- message: `endpoint '${binding.name}' does not declare the propose capability`,
444
+ message: `endpoint '${binding.name}' does not declare the propose capability`
445
+ + ` — declare [capabilities.propose] in the Service Manifest (.ukp/service.toml); 'ukp guide propose' walks through it`,
445
446
  },
446
447
  };
447
448
  }
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { buildQmdInvocation, defaultQmdCommand, providerTimeoutMs } from "./qmd.ts";
6
- import { endpointRelativePathOf, providerLocationOf } from "./search.ts";
6
+ import { buildEndpointFileIndex, docidOf, providerLocationOf, verifiedEndpointRouteOf } from "./search.ts";
7
7
 
8
8
  // ADR 0020: read-time layered rename recovery. This module owns the
9
9
  // miss-path descent (L1 git-derived -> L2 search re-anchor -> exhausted) and
@@ -160,13 +160,17 @@ function reanchorViaSearch(request: RecoveryRequest): string[] {
160
160
  return [];
161
161
  }
162
162
  if (!Array.isArray(nativeResults)) return [];
163
+ // ADR 0025: recalled routes are content-verified against each hit's docid
164
+ // (same verified mapping as search emission) — a wrong or drifted
165
+ // candidate never enters the recovery candidate list.
166
+ const index = buildEndpointFileIndex(request.serviceFolder);
163
167
  const routes = new Set<string>();
164
168
  for (const item of nativeResults) {
165
169
  const location = providerLocationOf(item);
166
170
  if (!location) continue;
167
- const route = endpointRelativePathOf(location, request.serviceFolder);
168
- if (route && route.split("/").pop() === basename && route !== request.route) {
169
- routes.add(route);
171
+ const { relPath } = verifiedEndpointRouteOf(location, request.serviceFolder, docidOf(item), index);
172
+ if (relPath && relPath.split("/").pop() === basename && relPath !== request.route) {
173
+ routes.add(relPath);
170
174
  }
171
175
  }
172
176
  return [...routes].sort();
@@ -291,10 +291,12 @@ export function runRg(parsed: ParsedRg, context: RgContext): RgResult {
291
291
  continue;
292
292
  }
293
293
 
294
- // Explicit search root: an implicit-cwd search is environment-sensitive
295
- // (first Linux CI run: rg 14.1.1 walked zero bytes with no path operand
296
- // while an explicit "." matched). "." keeps rg's rendered paths
297
- // endpoint-relative; the "./" prefix is stripped at both intakes below.
294
+ // Explicit search root: with no path operand rg searches stdin instead
295
+ // of the cwd whenever stdin is not a tty (ISSUE-013 root cause,
296
+ // confirmed on the ali repro host: spawned rg read an empty pipe and
297
+ // exited 1 with zero traversal; tty and /dev/null stdin walk the cwd).
298
+ // "." keeps rg's rendered paths endpoint-relative; the "./" prefix is
299
+ // stripped at both intakes below.
298
300
  const result = spawnSync(command[0]!, [...command.slice(1), ...rgToolArgs(parsed), "."], {
299
301
  cwd: endpoint.folder,
300
302
  encoding: "utf8",
@@ -1,8 +1,10 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import {
3
4
  closeSync,
4
5
  existsSync,
5
6
  openSync,
7
+ readdirSync,
6
8
  readFileSync,
7
9
  realpathSync,
8
10
  statSync,
@@ -457,6 +459,12 @@ function runHumanMode(
457
459
  continue;
458
460
  }
459
461
  outcome.status = providerOutput ? "succeeded" : "no_matches";
462
+ if (outcome.status === "no_matches") {
463
+ // An empty provider result is indistinguishable from an unconfigured
464
+ // provider to a first-run caller — surface the setup path as guidance
465
+ // (search's own contract stays: no matches is a result, not a failure).
466
+ warnings.push(`endpoint '${endpoint.name}' returned no matches; if the QMD provider has not been set up for this Service yet, see 'ukp guide service qmd'`);
467
+ }
460
468
  }
461
469
  return {
462
470
  query: parsed.request.query,
@@ -490,6 +498,8 @@ interface QmdReferenceMapping {
490
498
  status: "read_ready" | "provider_only";
491
499
  read_adapter?: "qmd";
492
500
  ukp_uri?: string;
501
+ /** REF-4 (ADR 0025): why no `ukp_uri` was emitted for this result. */
502
+ ukp_uri_omission_reason?: UriOmissionReason;
493
503
  reason?: string;
494
504
  }
495
505
 
@@ -504,7 +514,10 @@ function maybeLine(line: number | undefined): { line?: number } {
504
514
  return line ? { line } : {};
505
515
  }
506
516
 
507
- function docidOf(result: unknown): string | undefined {
517
+ /** The bare 6-hex content fingerprint of one QMD result (`#` stripped and
518
+ * body-validated); exported for the rename-recovery L2 re-anchor, which
519
+ * verifies recalled candidates against the same fingerprint (ADR 0025). */
520
+ export function docidOf(result: unknown): string | undefined {
508
521
  if (typeof result !== "object" || result === null || !("docid" in result)) return undefined;
509
522
  const docid = (result as { docid?: unknown }).docid;
510
523
  if (typeof docid !== "string") return undefined;
@@ -539,91 +552,223 @@ function encodeUkpUriSegment(segment: string): string {
539
552
  }
540
553
 
541
554
  /**
542
- * Derive the endpoint-relative path of one QMD result location (ADR 0019).
543
- *
544
- * The provider location is either path-shaped (`qmd://<absolute path>` — the
545
- * collection root is a filesystem path) or collection-shaped
546
- * (`qmd://<collection>/<rel>`). Only a location that resolves *inside* the
547
- * Service folder to an existing regular file yields a `ukp://` URI: the URI is
548
- * a slot promise (`ukp read` resolves it exactly), so it must never be emitted
549
- * for provider-managed or out-of-folder resources. Collection names are never
550
- * assumed to equal endpoint names — the resolved-containment + existence check,
551
- * not name matching, decides (lexical containment plus a realpath pass so
552
- * symlink escapes cannot yield an unreadable slot, mirroring the read hit path).
555
+ * Verified emission (ADR 0025 / D-082; REF-1 + REF-2 in
556
+ * `docs/concepts/reference-integrity.md`): a `ukp://` URI is emitted only
557
+ * when a candidate endpoint-local file's content hash matches the hit's
558
+ * docid — `sha256(raw on-disk bytes)[0:6]`, the provider's content
559
+ * fingerprint (empirically pinned 2026-09-17). Existence alone never
560
+ * suffices (REF-2: existence ≠ identity); enumeration may guess
561
+ * (normalization-insensitive matching — the provider location vocabulary is
562
+ * not filesystem-literal, e.g. `session_format` → `session-format`), the
563
+ * hash decides; every failure degrades to no-uri with an auditable reason
564
+ * (REF-4), never to a wrong uri. If the provider ever changes its hash
565
+ * scheme, verification fails closed: coverage drops, correctness never
566
+ * breaks.
553
567
  */
554
- /** Exported for the rename-recovery L2 re-anchor (ADR 0020): maps a provider
555
- * location to an endpoint-relative route when it safely resolves inside the
556
- * Service folder to an existing regular file. */
557
- export function endpointRelativePathOf(providerLocation: string, endpointFolder: string): string | undefined {
558
- if (!providerLocation.startsWith("qmd://")) return undefined;
568
+
569
+ /** REF-4: why a `ukp_uri` was not emitted for a result. */
570
+ export type UriOmissionReason =
571
+ | "no-docid"
572
+ | "no-candidate"
573
+ | "hash-mismatch"
574
+ | "ambiguous"
575
+ | "out-of-folder";
576
+
577
+ export interface VerifiedRoute {
578
+ relPath?: string;
579
+ reason?: UriOmissionReason;
580
+ }
581
+
582
+ /** Enumeration-only normalization (deliberately loose — the hash is the
583
+ * authority): folds the provider's observed `_`→`-` location vocabulary and
584
+ * case so wrong guesses stay cheap and harmless. */
585
+ function normalizeSegmentForEnumeration(segment: string): string {
586
+ return segment.toLowerCase().replaceAll("_", "-");
587
+ }
588
+
589
+ /** Endpoint file index: normalized endpoint-relative path → real rel paths.
590
+ * Built once per emission call (per endpoint). Symlinked files resolving
591
+ * back inside the folder are included (mirroring the read hit path);
592
+ * symlinked directories are not descended; `.git`/`.qmd` internals are
593
+ * skipped. The index only *generates candidates* — the resolved-containment
594
+ * safety gate and the content hash below decide. */
595
+ export type EndpointFileIndex = Map<string, string[]>;
596
+
597
+ /** Structural shape of a `readdirSync(withFileTypes)` entry as the index
598
+ * uses it (avoids the Dirent<Buffer> overload inference). */
599
+ interface IndexDirent {
600
+ name: string;
601
+ isDirectory(): boolean;
602
+ isFile(): boolean;
603
+ isSymbolicLink(): boolean;
604
+ }
605
+
606
+ export function buildEndpointFileIndex(endpointFolder: string): EndpointFileIndex {
607
+ const index: EndpointFileIndex = new Map();
608
+ let realRoot: string;
609
+ try {
610
+ realRoot = realpathSync(endpointFolder);
611
+ } catch {
612
+ return index;
613
+ }
614
+ const walk = (directory: string, prefix: string): void => {
615
+ let entries: IndexDirent[];
616
+ try {
617
+ entries = readdirSync(directory, { withFileTypes: true }) as unknown as IndexDirent[];
618
+ } catch {
619
+ return;
620
+ }
621
+ for (const entry of entries) {
622
+ if (entry.name === ".git" || entry.name === ".qmd") continue;
623
+ const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
624
+ if (entry.isDirectory()) {
625
+ walk(join(directory, entry.name), rel);
626
+ continue;
627
+ }
628
+ if (entry.isSymbolicLink()) {
629
+ // Include only symlinked files that resolve back inside the root
630
+ // (G5: a symlink escaping the folder must never yield a slot).
631
+ try {
632
+ if (!statSync(join(directory, entry.name)).isFile()) continue;
633
+ if (!isInsideRealRoot(realRoot, realpathSync(join(directory, entry.name)))) continue;
634
+ } catch {
635
+ continue;
636
+ }
637
+ } else if (!entry.isFile()) {
638
+ continue;
639
+ }
640
+ const normalized = rel.split("/").map(normalizeSegmentForEnumeration).join("/");
641
+ const bucket = index.get(normalized);
642
+ if (bucket === undefined) index.set(normalized, [rel]);
643
+ else bucket.push(rel);
644
+ }
645
+ };
646
+ walk(realRoot, "");
647
+ return index;
648
+ }
649
+
650
+ function sha256Prefix6Of(file: string): string | undefined {
651
+ try {
652
+ return createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 6);
653
+ } catch {
654
+ return undefined;
655
+ }
656
+ }
657
+
658
+ /**
659
+ * ADR 0025 core: map a provider location to an endpoint-relative route only
660
+ * when a candidate file verifies as the hit's content. Layered policy over
661
+ * the shared primitives (ADR 0023): candidate generation (path-shaped by
662
+ * folder-relative containment, collection-shaped by normalization-insensitive
663
+ * suffix match — the collection-root assumption that broke ISSUE-014 is
664
+ * gone) → resolved containment + isFile safety gate → sha256-prefix
665
+ * adjudication against the docid. Zero or multiple verified matches decline
666
+ * (ambiguity never emits).
667
+ */
668
+ export function verifiedEndpointRouteOf(
669
+ providerLocation: string,
670
+ endpointFolder: string,
671
+ docid: string | undefined,
672
+ index: EndpointFileIndex,
673
+ ): VerifiedRoute {
674
+ if (!providerLocation.startsWith("qmd://")) return {};
675
+ if (docid === undefined) return { reason: "no-docid" };
559
676
  const rest = providerLocation.slice("qmd://".length);
560
- let candidate: string;
677
+ let candidates: string[];
561
678
  if (isAbsoluteShapedPath(rest)) {
679
+ // Path-shaped (collection name is the full on-disk path): folder-relative
680
+ // containment, with the cross-drive / UNC escape rejected explicitly
681
+ // (`relative()` returns the absolute target itself there).
562
682
  const rel = relative(resolve(endpointFolder), resolve(rest));
563
- // Cross-drive / UNC↔drive targets: `relative()` returns the absolute
564
- // target itself, which never starts with `..` — reject explicitly so the
565
- // containment boundary cannot be bypassed (ADR 0019 emission rule).
566
- if (isAbsoluteShapedPath(rel)) return undefined;
567
- candidate = rel.split(/[\\/]/).join("/");
683
+ if (isAbsoluteShapedPath(rel)) return { reason: "out-of-folder" };
684
+ const candidate = rel.split(/[\\/]/).join("/");
685
+ if (candidate === "" || candidate.split("/").includes("..")) return { reason: "out-of-folder" };
686
+ candidates = [candidate];
568
687
  } else {
688
+ // Collection-shaped: strip the collection name, then match the remainder
689
+ // against the folder's normalized paths as an exact-or-suffix key. The
690
+ // suffix arm is what recovers subfolder-rooted collections (ISSUE-014
691
+ // false negatives) without knowing any collection root.
569
692
  const firstSlash = rest.indexOf("/");
570
- if (firstSlash <= 0) return undefined;
571
- candidate = rest.slice(firstSlash + 1).split(/[\\/]/).join("/");
693
+ if (firstSlash <= 0) return { reason: "no-candidate" };
694
+ const rel = rest.slice(firstSlash + 1).split(/[\\/]/).join("/");
695
+ if (rel === "" || rel.split("/").includes("..")) return { reason: "no-candidate" };
696
+ const normalizedRel = rel.split("/").map(normalizeSegmentForEnumeration).join("/");
697
+ candidates = [];
698
+ for (const [normalized, paths] of index) {
699
+ if (normalized === normalizedRel || normalized.endsWith(`/${normalizedRel}`)) {
700
+ candidates.push(...paths);
701
+ }
702
+ }
703
+ if (candidates.length === 0) return { reason: "no-candidate" };
572
704
  }
573
- if (candidate === "" || candidate.split("/").includes("..")) return undefined;
574
- const absolute = resolve(endpointFolder, ...candidate.split("/"));
575
- if (!isInsideRealRoot(resolve(endpointFolder), absolute)) return undefined;
576
- // Resolved containment, aligned with the read hit path (G5 defect closure):
577
- // a folder-internal symlink escaping the Service folder passes the lexical
578
- // checks above but must not yield a URI the exact slot route would refuse.
579
- let resolved: string;
580
- let resolvedFolder: string;
705
+ // Safety gate (aligned with the read hit path, G5 defect closure): lexical
706
+ // containment, then realpath containment, then isFile.
707
+ const folderRoot = resolve(endpointFolder);
708
+ let realFolderRoot: string;
581
709
  try {
582
- resolved = realpathSync(absolute);
583
- resolvedFolder = realpathSync(endpointFolder);
710
+ realFolderRoot = realpathSync(folderRoot);
584
711
  } catch {
585
- return undefined;
712
+ return { reason: "no-candidate" };
586
713
  }
587
- if (!isInsideRealRoot(resolvedFolder, resolved)) return undefined;
588
- let stat;
589
- try {
590
- stat = statSync(resolved);
591
- } catch {
592
- return undefined;
714
+ const safe: string[] = [];
715
+ for (const candidate of candidates) {
716
+ const absolute = resolve(folderRoot, ...candidate.split("/"));
717
+ if (!isInsideRealRoot(realFolderRoot, absolute)) continue;
718
+ let realAbsolute: string;
719
+ try {
720
+ realAbsolute = realpathSync(absolute);
721
+ } catch {
722
+ continue;
723
+ }
724
+ if (!isInsideRealRoot(realFolderRoot, realAbsolute)) continue;
725
+ try {
726
+ if (!statSync(realAbsolute).isFile()) continue;
727
+ } catch {
728
+ continue;
729
+ }
730
+ safe.push(candidate);
593
731
  }
594
- if (!stat.isFile()) return undefined;
595
- return candidate;
732
+ if (safe.length === 0) return { reason: "out-of-folder" };
733
+ const matches: string[] = [];
734
+ for (const candidate of safe) {
735
+ if (sha256Prefix6Of(resolve(folderRoot, ...candidate.split("/"))) === docid) matches.push(candidate);
736
+ }
737
+ if (matches.length === 1) return { relPath: matches[0] };
738
+ if (matches.length === 0) return { reason: "hash-mismatch" };
739
+ return { reason: "ambiguous" };
596
740
  }
597
741
 
598
- function ukpUriOf(providerLocation: string, endpointName: string, endpointFolder: string): string | undefined {
599
- const relPath = endpointRelativePathOf(providerLocation, endpointFolder);
600
- if (!relPath) return undefined;
742
+ function ukpUriFromRelPath(endpointName: string, relPath: string): string {
601
743
  return `ukp://${endpointName}/${relPath.split("/").map(encodeUkpUriSegment).join("/")}`;
602
744
  }
603
745
 
604
746
  /**
605
- * Map one QMD search result to a UKP read-ready reference (ADR 0011).
606
- *
607
- * QMD-indexed results carry a stable `docid` content fingerprint. `search`
608
- * emits it as a bare handoff key (`#` stripped), not the weak `qmd://`/path
609
- * name; `read` re-adds the `#` and resolves by fingerprint exactly. Name, title,
610
- * and path become display-only provenance (`provider_location`). A result with
611
- * no usable docid has no UKP read route and is `provider_only`.
747
+ * Map one QMD search result to a UKP read-ready reference (ADR 0011) under
748
+ * the ADR 0025 verified-emission rule: the durable `ukp_uri` is emitted only
749
+ * when a candidate file's sha256 prefix matches the docid; otherwise the
750
+ * omission reason lands in the sidecar (REF-4). Name, title, and path stay
751
+ * display-only provenance (`provider_location`). A result with no usable
752
+ * docid has no UKP read route and no verifiable uri — `provider_only`.
612
753
  */
613
754
  function mapQmdResultToReference(
614
755
  endpointName: string,
615
756
  endpointFolder: string,
616
757
  result: unknown,
758
+ index: EndpointFileIndex,
617
759
  ): QmdReferenceMapping {
618
760
  const providerLocation = providerLocationOf(result);
619
- const ukpUri = ukpUriOf(providerLocation, endpointName, endpointFolder);
620
761
  const docid = docidOf(result);
762
+ const route = providerLocation !== ""
763
+ ? verifiedEndpointRouteOf(providerLocation, endpointFolder, docid, index)
764
+ : {};
765
+ const ukpUri = route.relPath !== undefined ? ukpUriFromRelPath(endpointName, route.relPath) : undefined;
621
766
  if (!docid) {
622
767
  return {
623
768
  provider_location: providerLocation,
624
769
  endpoint: endpointName,
625
770
  status: "provider_only",
626
- ...(ukpUri ? { ukp_uri: ukpUri } : {}),
771
+ ...(route.reason !== undefined ? { ukp_uri_omission_reason: route.reason } : {}),
627
772
  reason: "qmd result has no usable docid",
628
773
  };
629
774
  }
@@ -634,7 +779,11 @@ function mapQmdResultToReference(
634
779
  ...maybeLine(lineOf(result)),
635
780
  status: "read_ready",
636
781
  read_adapter: "qmd",
637
- ...(ukpUri ? { ukp_uri: ukpUri } : {}),
782
+ ...(ukpUri !== undefined
783
+ ? { ukp_uri: ukpUri }
784
+ : route.reason !== undefined
785
+ ? { ukp_uri_omission_reason: route.reason }
786
+ : {}),
638
787
  };
639
788
  }
640
789
 
@@ -684,6 +833,7 @@ function renderResultUnit(
684
833
  endpointName: string,
685
834
  endpointFolder: string,
686
835
  result: unknown,
836
+ resolveIndex: () => EndpointFileIndex,
687
837
  remoteUri?: string,
688
838
  remoteEndpoint = false,
689
839
  ): string {
@@ -692,8 +842,13 @@ function renderResultUnit(
692
842
  const line = lineOf(result);
693
843
  // Remote endpoints carry the server-declared ukp_uri (RQ-06/RQ-09): it is
694
844
  // the handoff key, so the read line addresses the URI directly and the
695
- // local docid serves provenance only.
696
- const ukpUri = remoteUri ?? ukpUriOf(providerLocation, endpointName, endpointFolder);
845
+ // local docid serves provenance only. Local emission is ADR 0025 verified:
846
+ // the uri line appears only when a candidate file's hash matches the docid.
847
+ const verified = remoteUri !== undefined || endpointFolder === ""
848
+ ? {}
849
+ : verifiedEndpointRouteOf(providerLocation, endpointFolder, docid, resolveIndex());
850
+ const ukpUri = remoteUri
851
+ ?? (verified.relPath !== undefined ? ukpUriFromRelPath(endpointName, verified.relPath) : undefined);
697
852
  const title = rawTitleOf(result);
698
853
  const base = basenameOf(providerLocation);
699
854
  const location = base ? (line ? `${base}:${line}` : base) : "";
@@ -744,8 +899,14 @@ function renderResultUnits(
744
899
  }
745
900
  if (!Array.isArray(nativeResults)) return null;
746
901
  if (nativeResults.length === 0) return "(no matches)";
902
+ // One endpoint-file index per endpoint render (ADR 0025): built lazily so
903
+ // empty result sets and pure-remote endpoints pay no walk.
904
+ let index: EndpointFileIndex | undefined;
905
+ const resolveIndex = (): EndpointFileIndex => index ??= buildEndpointFileIndex(endpointFolder);
747
906
  return nativeResults
748
- .map((result, index) => renderResultUnit(index + 1, endpointName, endpointFolder, result, remoteUris?.[index], remoteUris !== undefined))
907
+ .map((result, index_) =>
908
+ renderResultUnit(index_ + 1, endpointName, endpointFolder, result, resolveIndex, remoteUris?.[index_], remoteUris !== undefined)
909
+ )
749
910
  .join("\n\n");
750
911
  }
751
912
 
@@ -790,13 +951,15 @@ function buildQmdReferenceSidecar(
790
951
  if (!Array.isArray(nativeResults)) {
791
952
  throw new Error("qmd-json output is not an array");
792
953
  }
954
+ // One endpoint-file index per sidecar build (ADR 0025).
955
+ const index = nativeResults.length > 0 ? buildEndpointFileIndex(endpointFolder) : new Map();
793
956
  return {
794
957
  schema: "ukp.search.references.v1",
795
958
  endpoint: endpointName,
796
959
  source_artifact: sourceArtifact,
797
- results: nativeResults.map((result, index) => ({
798
- index,
799
- ...mapQmdResultToReference(endpointName, endpointFolder, result),
960
+ results: nativeResults.map((result, index_) => ({
961
+ index: index_,
962
+ ...mapQmdResultToReference(endpointName, endpointFolder, result, index),
800
963
  })),
801
964
  };
802
965
  }
@@ -837,12 +1000,15 @@ export function buildInlineReferences(
837
1000
  return undefined;
838
1001
  }
839
1002
  if (!Array.isArray(nativeResults)) return undefined;
1003
+ // One endpoint-file index per inline build (ADR 0025) — same verified
1004
+ // mapping as the sidecar, so remote inline references stay in lockstep.
1005
+ const index = nativeResults.length > 0 ? buildEndpointFileIndex(endpointFolder) : new Map();
840
1006
  return {
841
1007
  schema: "ukp.search.references.v1",
842
1008
  endpoint: endpointName,
843
- results: nativeResults.map((result, index) => ({
844
- index,
845
- ...mapQmdResultToReference(endpointName, endpointFolder, result),
1009
+ results: nativeResults.map((result, index_) => ({
1010
+ index: index_,
1011
+ ...mapQmdResultToReference(endpointName, endpointFolder, result, index),
846
1012
  })),
847
1013
  };
848
1014
  }
@@ -18,10 +18,15 @@ Fast path for a simple knowledge folder:
18
18
  1. Initialize QMD (qmd init)
19
19
  2. Add the searchable collections (qmd collection add)
20
20
  For a pure knowledge base, add the folder root; otherwise add the folder(s)
21
- whose content should be searchable.
21
+ whose content should be searchable. Check what already exists first
22
+ (qmd collection list): re-adding an already-indexed folder creates a
23
+ duplicate collection, and add does not warn about it.
22
24
  3. Use a stable short collection name (qmd collection rename)
23
- QMD names a collection from the indexed folder; rename it to a short
24
- stable name so provider-native artifact URIs stay clean.
25
+ By default add registers the collection under the folder's full path as
26
+ its name; the short name comes from this rename, never from add itself.
27
+ rename matches the exact current name only, so re-check collection list
28
+ before renaming — the short name you had in mind may already belong to an
29
+ existing collection.
25
30
  4. Update the index (qmd update, qmd status)
26
31
  5. Verify the provider before returning to UKP (qmd search / qmd get)
27
32
  If a search returns matches, the provider is ready; return to
@@ -193,6 +193,13 @@ function executeInitServiceCommand(args: readonly string[], context: InitCommand
193
193
  `location: ${result.folder}`,
194
194
  "capability: search",
195
195
  "provider: qmd",
196
+ // Folder-existence probe only (no provider state read): an existing
197
+ // provider index means collections may already be registered — route
198
+ // to the provider-scoped topic instead of naming provider commands
199
+ // (provider-agnostic stdout, same pattern as diagnose's hint).
200
+ ...(existsSync(join(result.folder, ".qmd"))
201
+ ? ["note: existing provider index detected (.qmd/); align collection naming before adding new ones (see 'ukp guide service qmd')"]
202
+ : []),
196
203
  "next: ukp guide service qmd",
197
204
  "next: ukp diagnose",
198
205
  "next: ukp register",
@@ -159,6 +159,10 @@ export function executeRegisterCommand(
159
159
  `registered: ${report.service.effectiveName}`,
160
160
  ...(report.service.manifest.description ? [`description: ${report.service.manifest.description}`] : []),
161
161
  `location: ${report.service.folder}`,
162
+ // Register never edits Client Config (guide client owns that); this is
163
+ // a pointer for the cross-workspace consumption case, which is the
164
+ // standard follow-up after registering a Service others will reference.
165
+ "next: to use this endpoint by default from another workspace, see 'ukp guide client' (workspace .ukp/client.toml)",
162
166
  ];
163
167
  return {
164
168
  exitCode: 0,