@opum-ai/lore 0.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/bin/lore.cjs +109 -0
  4. package/package.json +67 -0
  5. package/src/adapters/backlog.ts +1084 -0
  6. package/src/adapters/git.ts +221 -0
  7. package/src/cli.ts +667 -0
  8. package/src/commands/agent.ts +301 -0
  9. package/src/commands/agents.ts +302 -0
  10. package/src/commands/args.ts +209 -0
  11. package/src/commands/changed.ts +70 -0
  12. package/src/commands/check.ts +1031 -0
  13. package/src/commands/codex-bridge.ts +49 -0
  14. package/src/commands/concurrency.ts +48 -0
  15. package/src/commands/context.ts +292 -0
  16. package/src/commands/discover.ts +89 -0
  17. package/src/commands/explorer.ts +253 -0
  18. package/src/commands/export.ts +93 -0
  19. package/src/commands/fswrite.ts +928 -0
  20. package/src/commands/graph.ts +291 -0
  21. package/src/commands/help.ts +151 -0
  22. package/src/commands/impact.ts +59 -0
  23. package/src/commands/init.ts +583 -0
  24. package/src/commands/instructions.ts +91 -0
  25. package/src/commands/link.ts +929 -0
  26. package/src/commands/new.ts +476 -0
  27. package/src/commands/orphans.ts +457 -0
  28. package/src/commands/path.ts +67 -0
  29. package/src/commands/provenance.ts +68 -0
  30. package/src/commands/query.ts +312 -0
  31. package/src/commands/reconcile-shared.ts +280 -0
  32. package/src/commands/rename.ts +585 -0
  33. package/src/commands/replace.ts +320 -0
  34. package/src/commands/scaffold.ts +346 -0
  35. package/src/commands/schema.ts +293 -0
  36. package/src/commands/snapshot.ts +130 -0
  37. package/src/commands/supersede.ts +400 -0
  38. package/src/commands/sync.ts +371 -0
  39. package/src/commands/tasks.ts +271 -0
  40. package/src/commands/traversal.ts +151 -0
  41. package/src/commands/validate.ts +226 -0
  42. package/src/config.ts +598 -0
  43. package/src/core/agent-bridge.ts +287 -0
  44. package/src/core/agent-context.ts +498 -0
  45. package/src/core/agent-profile.ts +447 -0
  46. package/src/core/bundle.ts +893 -0
  47. package/src/core/check.ts +853 -0
  48. package/src/core/codex-bridge.ts +100 -0
  49. package/src/core/concept.ts +597 -0
  50. package/src/core/consumer-scaffold.ts +433 -0
  51. package/src/core/context.ts +271 -0
  52. package/src/core/explorer-contract.ts +441 -0
  53. package/src/core/explorer-qualification.ts +58 -0
  54. package/src/core/explorer.ts +518 -0
  55. package/src/core/finding.ts +31 -0
  56. package/src/core/graph.ts +201 -0
  57. package/src/core/indexes.ts +436 -0
  58. package/src/core/instructions.ts +209 -0
  59. package/src/core/ladybug-driver.ts +1795 -0
  60. package/src/core/ladybug-lifecycle.ts +1178 -0
  61. package/src/core/ladybug-native.ts +95 -0
  62. package/src/core/ladybug-source.ts +667 -0
  63. package/src/core/links.ts +681 -0
  64. package/src/core/log.ts +253 -0
  65. package/src/core/managed-block.ts +540 -0
  66. package/src/core/manifest.ts +718 -0
  67. package/src/core/order.ts +13 -0
  68. package/src/core/profile.ts +1007 -0
  69. package/src/core/projection.ts +195 -0
  70. package/src/core/query.ts +542 -0
  71. package/src/core/reconcile.ts +236 -0
  72. package/src/core/replace.ts +419 -0
  73. package/src/core/retrieval.ts +213 -0
  74. package/src/core/rewrite.ts +940 -0
  75. package/src/core/scaffold.ts +255 -0
  76. package/src/core/schema.ts +366 -0
  77. package/src/core/snapshot-runtime.ts +52 -0
  78. package/src/core/snapshot-store.ts +287 -0
  79. package/src/core/snapshot.ts +711 -0
  80. package/src/core/template.ts +429 -0
  81. package/src/core/traversal.ts +487 -0
  82. package/src/core/validate.ts +517 -0
  83. package/src/core/workspace-contract.ts +473 -0
  84. package/src/core/workspace-projection.ts +365 -0
  85. package/src/core/workspace-retrieval.ts +196 -0
  86. package/src/core/workspace-source.ts +174 -0
  87. package/src/errors.ts +697 -0
  88. package/src/meta.ts +7 -0
  89. package/src/output.ts +589 -0
  90. package/src/scripts/upstream-backlog-watch.ts +288 -0
  91. package/src/state.ts +390 -0
@@ -0,0 +1,49 @@
1
+ import { dirname, join } from "node:path";
2
+ import type { BridgeAction } from "../core/agent-bridge";
3
+ import { AGENTS_MD_REL_PATH, CODEX_SKILL_REL_PATH, planCodexBridge } from "../core/codex-bridge";
4
+ import { readFileIfPresent } from "../errors";
5
+ import { assertNoSymlinkInAnyPath, ensureDir, writeFileAtomic } from "./fswrite";
6
+
7
+ export interface CodexBridgeResult {
8
+ root: string;
9
+ files: ReadonlyArray<{ path: string; action: BridgeAction }>;
10
+ }
11
+
12
+ export function applyCodexBridge(options: { root: string; force: boolean; check: boolean }): CodexBridgeResult {
13
+ const skillRaw = readFileIfPresent(join(options.root, CODEX_SKILL_REL_PATH), CODEX_SKILL_REL_PATH);
14
+ const agentsRaw = readFileIfPresent(join(options.root, AGENTS_MD_REL_PATH), AGENTS_MD_REL_PATH);
15
+ const skillOnDisk = normalize(skillRaw);
16
+ const agentsOnDisk = normalize(agentsRaw);
17
+ const agentsStyle = detectStyle(agentsRaw);
18
+ const plan = planCodexBridge({ skillOnDisk, agentsOnDisk, force: options.force, check: options.check });
19
+
20
+ if (!options.check) {
21
+ const targets = plan.files.filter((file) => file.contents !== null).map((file) => file.path);
22
+ assertNoSymlinkInAnyPath(options.root, targets);
23
+ for (const file of plan.files) {
24
+ if (file.contents === null) continue;
25
+ ensureDir(options.root, dirname(file.path));
26
+ const contents = file.path === AGENTS_MD_REL_PATH ? restyle(file.contents, agentsStyle) : file.contents;
27
+ writeFileAtomic(join(options.root, file.path), contents, file.path);
28
+ }
29
+ }
30
+
31
+ return { root: options.root, files: plan.files.map(({ path, action }) => ({ path, action })) };
32
+ }
33
+
34
+ function normalize(raw: string | undefined): string | null {
35
+ return raw === undefined ? null : raw.replace(/^\uFEFF+/, "").replace(/\r\n?/g, "\n");
36
+ }
37
+
38
+ function detectStyle(raw: string | undefined): { bom: boolean; eol: "\n" | "\r\n" | "\r" } {
39
+ if (raw === undefined) return { bom: false, eol: "\n" };
40
+ return {
41
+ bom: raw.startsWith("\uFEFF"),
42
+ eol: raw.includes("\r\n") ? "\r\n" : raw.includes("\r") ? "\r" : "\n",
43
+ };
44
+ }
45
+
46
+ function restyle(contents: string, style: { bom: boolean; eol: "\n" | "\r\n" | "\r" }): string {
47
+ const withEol = style.eol === "\n" ? contents : contents.replace(/\n/g, style.eol);
48
+ return style.bom ? `\uFEFF${withEol}` : withEol;
49
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * commands/concurrency.ts — {@link mapWithConcurrency}, a tiny worker-pool over a shared cursor,
3
+ * plus {@link TASK_DETAILS_CONCURRENCY}, the shared cap it bounds Backlog `viewTask` fan-out to.
4
+ *
5
+ * Pure and dependency-free (no import of any other `commands/` module) so it can be shared by
6
+ * `commands/link.ts` and `commands/reconcile-shared.ts` without either importing the other:
7
+ * `reconcile-shared.ts` already imports `verifiedViewTask`/`dedupeTaskIds`/`defaultAdapter` FROM
8
+ * `link.ts`, so `link.ts` importing anything back from `reconcile-shared.ts` would create a
9
+ * `link -> reconcile-shared -> link` cycle (LORE-233). Originally lived solely in
10
+ * `reconcile-shared.ts` (LORE-111, bounding `resolveTaskDetails`'s fan-out and `check.ts`'s
11
+ * `probeLiveness`); relocated here so `link.ts`'s own up-front task-existence-check fan-out
12
+ * (`runLink`) could reuse the identical helper and cap instead of duplicating either.
13
+ * `reconcile-shared.ts` re-exports both so its own existing importers (`check.ts`,
14
+ * `reconcile-shared.test.ts`) keep working unchanged.
15
+ */
16
+
17
+ /**
18
+ * How many `adapter.viewTask` calls a caller runs at once — bounded so a large task-id fan-out
19
+ * (a bundle linking many distinct ids, or a multi-id `lore link`) does not spawn one Backlog CLI
20
+ * subprocess per id fully concurrently (which can exhaust process/file-descriptor limits or
21
+ * overwhelm the Backlog CLI). Mirrors `check.ts`'s own `LIVENESS_CONCURRENCY` cap on external-URL
22
+ * liveness probes — same shape of problem (unbounded subprocess/socket fan-out), same fix.
23
+ * Exported so tests can assert against the real cap rather than duplicating (and risking drift
24
+ * from) a hardcoded copy.
25
+ */
26
+ export const TASK_DETAILS_CONCURRENCY = 8;
27
+
28
+ /**
29
+ * Run `fn` over `items` with at most `limit` in flight at once — a tiny worker-pool over a shared
30
+ * cursor. Shared by `reconcile-shared.ts`'s `resolveTaskDetails` (bounding concurrent
31
+ * `adapter.viewTask` Backlog subprocess spawns), `check.ts`'s `probeLiveness` (bounding concurrent
32
+ * external-URL fetches), and `link.ts`'s `runLink` (bounding its own up-front `verifiedViewTask`
33
+ * existence-check fan-out).
34
+ */
35
+ export async function mapWithConcurrency<T>(
36
+ items: readonly T[],
37
+ limit: number,
38
+ fn: (item: T) => Promise<void>,
39
+ ): Promise<void> {
40
+ let cursor = 0;
41
+ const worker = async (): Promise<void> => {
42
+ while (cursor < items.length) {
43
+ const item = items[cursor++] as T;
44
+ await fn(item);
45
+ }
46
+ };
47
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
48
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * commands/context.ts — `lore context <id> [--max-tokens <n>] [--depth <n>]`.
3
+ *
4
+ * The thin, read-only layer that emits a **token-budgeted context pack** for one
5
+ * concept (cli-surface §context; LORE-34). Commander supplies a verified indexed
6
+ * {@link BundleGraph} with automatic reference fallback; direct core callers
7
+ * retain the reference loader. It then hands the target id, neighbor radius
8
+ * (`--depth`, default {@link DEFAULT_DEPTH}), and budget (`--max-tokens`) to the
9
+ * pure {@link buildContext} shaper, which gathers the target's neighborhood via
10
+ * the shared {@link subgraph} traversal and compacts it to the target's full body
11
+ * plus one-line neighbor summaries.
12
+ *
13
+ * Output follows the uniform CLI modes: the `{schemaVersion, kind:
14
+ * "context.export", data}` envelope under the global `--json`, and otherwise a
15
+ * pasteable text pack — the target's body followed by the neighbor compaction —
16
+ * with a §3 truncation footer when the budget dropped neighbors. There is no
17
+ * command-specific JSON flag; machine consumers use the same `--json` they use
18
+ * everywhere. The pretty and plain renderers are identical (the pack is structural —
19
+ * no severities to color), so they share one renderer.
20
+ *
21
+ * The positional `<id>` is normalized through {@link idFromPath} exactly as `lore
22
+ * graph`/`rename`/`supersede` normalize theirs, so a path-form, `./`-prefixed, or
23
+ * `.md`-suffixed id resolves to the same bundle key.
24
+ *
25
+ * Validation lives here (the budget/compaction computation stays pure in
26
+ * `core/context.ts`): a missing or duplicate `<id>`, an unknown flag, a
27
+ * repeated/value-less/non-integer/too-large/non-positive `--max-tokens`, or a
28
+ * repeated/value-less/non-integer/too-large/negative `--depth` is a `usage` error
29
+ * (exit 2); an `<id>` absent from the bundle surfaces as the `not_found` error
30
+ * (exit 3) {@link buildContext} throws.
31
+ */
32
+
33
+ import { join } from "node:path";
34
+ import type { BacklogAdapter } from "../adapters/backlog";
35
+ import { loadBundle } from "../core/bundle";
36
+ import { idFromPath } from "../core/concept";
37
+ import { buildContext, type ContextExport, DEFAULT_DEPTH } from "../core/context";
38
+ import { loadProfile } from "../core/profile";
39
+ import { loadRetrievalGraph, type RetrievalGraphLoader } from "../core/retrieval";
40
+ import { DOCS_DIR } from "../core/scaffold";
41
+ import { parseQualifiedWorkspaceId, qualifyWorkspaceId } from "../core/workspace-contract";
42
+ import type { WorkspaceRetrievalContext, WorkspaceRetrievalSelection } from "../core/workspace-retrieval";
43
+ import { EXIT_OK, LoreError, WarningCollector, type Writer } from "../errors";
44
+ import { emit, type OutputContext, type Renderable, renderTruncationLine, truncation } from "../output";
45
+ import { parseCommandArgs, singleOptionValue, workspaceSelection } from "./args";
46
+
47
+ /** Options for {@link runContext}; `root` and the streams are injectable for tests. */
48
+ export interface ContextOptions {
49
+ /** The repo root the `docs/` bundle resolves against. */
50
+ root: string;
51
+ /** The resolved output mode/color (from `output.ts`). */
52
+ output: OutputContext;
53
+ /** The command's normalized positional + flag tokens from Commander. */
54
+ args: readonly string[];
55
+ /** stdout sink; defaults to `process.stdout`. */
56
+ stdout?: Writer;
57
+ /** stderr sink for advisory warnings; defaults to `process.stderr`. */
58
+ stderr?: Writer;
59
+ /** Backlog snapshot seam used only by indexed projection freshness/builds. */
60
+ adapter?: BacklogAdapter;
61
+ /** Indexed/reference selector injected by the Commander handler or conformance tests. */
62
+ retrieval?: RetrievalGraphLoader;
63
+ }
64
+
65
+ /** The parsed form of `lore context`'s arguments. */
66
+ interface ContextArgs {
67
+ /** The target concept id (positional, already {@link idFromPath}-normalized). */
68
+ id: string;
69
+ /** The token budget (`--max-tokens`); `undefined` means no size trim (bounded only by depth). */
70
+ maxTokens?: number;
71
+ /** The hop radius (`--depth`); `undefined` falls back to {@link DEFAULT_DEPTH}. */
72
+ depth?: number;
73
+ readonly workspace?: WorkspaceRetrievalSelection;
74
+ }
75
+
76
+ /**
77
+ * Run `lore context`: parse the arguments, load the bundle, build the context pack,
78
+ * emit the `context.export`, and return `0`. A bad flag/positional throws a `usage`
79
+ * {@link LoreError} (exit `2`); an `<id>` not in the bundle a `not_found` one (exit
80
+ * `3`).
81
+ */
82
+ export function runContext(options: ContextOptions): number | Promise<number> {
83
+ const parsed = parseContextArgs(options.args);
84
+ const advisories = new WarningCollector();
85
+ const retrieval = options.retrieval ?? (parsed.workspace !== undefined ? loadRetrievalGraph : undefined);
86
+ if (retrieval !== undefined) {
87
+ return retrieval({
88
+ root: options.root,
89
+ warnings: advisories,
90
+ adapter: options.adapter,
91
+ ...(parsed.workspace !== undefined ? { workspace: parsed.workspace } : {}),
92
+ }).then(async (loaded) => {
93
+ try {
94
+ const graph =
95
+ loaded.indexed === undefined
96
+ ? loaded.graph
97
+ : withConceptBody(loaded.graph, parsed.id, await loaded.indexed.readConceptBody(parsed.id));
98
+ return finishContext(options, parsed, graph, advisories, loaded.workspace);
99
+ } finally {
100
+ await loaded.dispose?.();
101
+ }
102
+ });
103
+ }
104
+ const profile = loadProfile({ root: options.root });
105
+ const graph = loadBundle(join(options.root, DOCS_DIR), { warnings: advisories, profile });
106
+ return finishContext(options, parsed, graph, advisories);
107
+ }
108
+
109
+ function withConceptBody(
110
+ graph: ReturnType<typeof loadBundle>,
111
+ id: string,
112
+ body: string | undefined,
113
+ ): ReturnType<typeof loadBundle> {
114
+ if (body === undefined) return graph;
115
+ const concept = graph.concepts.get(id);
116
+ if (concept === undefined) return graph;
117
+ const concepts = new Map(graph.concepts);
118
+ concepts.set(id, { ...concept, body });
119
+ return { ...graph, concepts };
120
+ }
121
+
122
+ function finishContext(
123
+ options: ContextOptions,
124
+ parsed: ContextArgs,
125
+ graph: ReturnType<typeof loadBundle>,
126
+ advisories: WarningCollector,
127
+ workspace?: WorkspaceRetrievalContext,
128
+ ): number {
129
+ // Flush load warnings before buildContext, which throws not_found for an unknown
130
+ // target — otherwise an advisory explaining *why* a file is not a concept would be
131
+ // discarded on exactly the path that most needs it (mirrors `lore graph`).
132
+ advisories.flush({ color: options.output.color, stderr: options.stderr });
133
+
134
+ const base = buildContext(graph, parsed.id, { depth: parsed.depth, maxTokens: parsed.maxTokens });
135
+ const data: ContextExport =
136
+ workspace === undefined
137
+ ? base
138
+ : {
139
+ ...base,
140
+ target: { ...base.target, provenance: workspace.provenanceById.get(base.target.id) },
141
+ neighbors: base.neighbors.map((neighbor) => ({
142
+ ...neighbor,
143
+ provenance: workspace.provenanceById.get(neighbor.id),
144
+ })),
145
+ workspace: workspace.scope,
146
+ };
147
+ emit(contextRenderable(data), options.output, options.stdout);
148
+ return EXIT_OK;
149
+ }
150
+
151
+ // ── Argument parsing ───────────────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Parse `context`'s tokens into the required `<id>` positional and the value flags
155
+ * `--max-tokens <n>` / `--depth <n>` (also accepting the `--flag=value` form). The
156
+ * Commander has already resolved Lore's global flags, so a `--`-prefixed token here is
157
+ * a command flag: an unrecognized one is a `usage` error, as is a repeated or
158
+ * value-less value flag, a non-integer/out-of-range value, a missing `<id>`, or a
159
+ * second positional. A `--` ends option parsing. The `<id>` is
160
+ * {@link idFromPath}-normalized so path/`.md`/`./` forms resolve.
161
+ */
162
+ function parseContextArgs(args: readonly string[]): ContextArgs {
163
+ const parsed = parseCommandArgs(args, "context");
164
+ const workspace = workspaceSelection(parsed);
165
+ const positionals = parsed.positionals;
166
+ const rawMaxTokens = singleOptionValue(parsed, "max-tokens");
167
+ const rawDepth = singleOptionValue(parsed, "depth");
168
+ if (rawMaxTokens === "") throw usage("--max-tokens needs a value", "pass a value, e.g. `--max-tokens 2`");
169
+ if (rawDepth === "") throw usage("--depth needs a value", "pass a value, e.g. `--depth 2`");
170
+ const maxTokens = rawMaxTokens === undefined ? undefined : parseCount("--max-tokens", rawMaxTokens, { min: 1 });
171
+ const depth = rawDepth === undefined ? undefined : parseCount("--depth", rawDepth, { min: 0 });
172
+ if (positionals.length === 0) {
173
+ throw usage(
174
+ "`lore context` needs a concept id",
175
+ "give the concept to build context for, e.g. `lore context stories/x`",
176
+ );
177
+ }
178
+ if (positionals.length > 1) {
179
+ throw usage(`unexpected argument "${positionals[1]}"`, "run `lore context <id> [--max-tokens <n>] [--depth <n>]`");
180
+ }
181
+ return {
182
+ id: normalizeContextId(positionals[0] as string, workspace !== undefined),
183
+ maxTokens,
184
+ depth,
185
+ workspace,
186
+ };
187
+ }
188
+
189
+ function normalizeContextId(raw: string, workspace: boolean): string {
190
+ if (!workspace) return idFromPath(raw);
191
+ try {
192
+ const parsed = parseQualifiedWorkspaceId(raw);
193
+ return qualifyWorkspaceId(parsed.memberId, idFromPath(parsed.sourceId));
194
+ } catch {
195
+ throw usage(`invalid workspace concept id "${raw}"`, "use the unambiguous <member-id>::<source-id> form");
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Parse a count flag's value as an integer at or above `min` (`--depth` allows `0`,
201
+ * `--max-tokens` requires a positive budget). Rejects a non-digit run
202
+ * (`Number()` would coerce `"1.5"`/`"0x2"`/`" 2 "`/`"1e3"`), a value below `min`,
203
+ * and a precision-losing `> 2^53` run — mirroring `lore graph`'s `--depth` guard so
204
+ * the two commands accept counts identically.
205
+ */
206
+ function parseCount(flag: string, value: string, opts: { min: number }): number {
207
+ if (!/^\d+$/.test(value)) {
208
+ throw usage(
209
+ `invalid ${flag} "${value}"`,
210
+ `pass an integer ≥ ${opts.min}, e.g. \`${flag} ${opts.min + 1}\`; ${flag} needs a value before a separate flag-looking token`,
211
+ );
212
+ }
213
+ const count = Number.parseInt(value, 10);
214
+ if (!Number.isSafeInteger(count)) {
215
+ throw usage(`${flag} "${value}" is too large`, "pass a smaller integer");
216
+ }
217
+ if (count < opts.min) {
218
+ throw usage(`invalid ${flag} "${value}"`, `pass an integer ≥ ${opts.min}, e.g. \`${flag} ${opts.min + 1}\``);
219
+ }
220
+ return count;
221
+ }
222
+
223
+ /**
224
+ * Read a value flag's argument: its inline `--flag=value` form when present, else
225
+ * the **next** token. A missing/empty value — or a next token that is itself an
226
+ * option (`--depth --max-tokens`) — is a `usage` error rather than a silently
227
+ * swallowed flag (mirroring `lore graph`'s value-flag guard).
228
+ */
229
+ // ── Output ─────────────────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * The per-result rendering bundle for `context` (output.ts dispatches on the mode).
233
+ * `--json` always carries the structured {@link ContextExport}; the pretty/plain
234
+ * text is the pasteable pack. The two text modes render identically (the data is
235
+ * structural — no severities to color), so pretty and plain share one renderer.
236
+ */
237
+ function contextRenderable(data: ContextExport): Renderable<ContextExport> {
238
+ return { kind: "context.export", data, pretty: renderText, plain: renderText };
239
+ }
240
+
241
+ /**
242
+ * A human/pipe-stable context pack: a header naming the target, its depth/budget and
243
+ * the pack's `~tokens`, then the target's full body, then a `neighbors (<shown> of
244
+ * <total>)` section with one `- <id> [<type>] — <summary>` line each (the `— …`
245
+ * dropped when a neighbor has no summary), and a trailing budget line: an
246
+ * over-budget warning when the always-included target alone exceeds `--max-tokens`,
247
+ * else the §3 truncation footer when the budget dropped neighbors. ANSI-free and
248
+ * deterministic.
249
+ */
250
+ function renderText(data: ContextExport): string {
251
+ const budget = data.maxTokens !== undefined ? `, budget ${data.maxTokens}` : "";
252
+ const lines = [
253
+ ...(data.workspace !== undefined
254
+ ? [`workspace: ${data.workspace.workspaceId} (${data.workspace.repositories.length} repositories)`]
255
+ : []),
256
+ `context: ${data.root} [${data.target.type}] — depth ${data.depth}${budget}, ~${data.tokenEstimate} tokens (chars/4)`,
257
+ "",
258
+ data.target.body.replace(/\n+$/, ""),
259
+ "",
260
+ `neighbors (${data.shown} of ${data.total}):`,
261
+ ];
262
+ for (const neighbor of data.neighbors) {
263
+ const summary = neighbor.summary !== undefined ? ` — ${neighbor.summary}` : "";
264
+ lines.push(` - ${neighbor.id} [${neighbor.type}]${summary}`);
265
+ }
266
+ const footer = budgetFooter(data);
267
+ if (footer !== "") {
268
+ lines.push(footer);
269
+ }
270
+ return lines.join("\n");
271
+ }
272
+
273
+ /**
274
+ * The trailing budget line for the pack, or `""` when it fully fit. Over budget (the
275
+ * mandatory target alone exceeds `--max-tokens`, so no neighbor could be dropped to
276
+ * help) gets an explicit warning; otherwise a dropped-neighbor count gets the §3
277
+ * truncation footer. The hint is **only** `raise --max-tokens` — lowering `--depth`
278
+ * cannot surface more neighbors (the included set is a budget-bound nearest-first
279
+ * prefix, so a smaller `--depth` only removes farther candidates that were never
280
+ * going to be included).
281
+ */
282
+ function budgetFooter(data: ContextExport): string {
283
+ if (data.maxTokens !== undefined && data.tokenEstimate > data.maxTokens) {
284
+ return `over budget: ~${data.tokenEstimate} tokens exceeds the ${data.maxTokens}-token limit — the target is always included; raise --max-tokens`;
285
+ }
286
+ return renderTruncationLine(truncation(data.total, data.shown, "raise --max-tokens to include more"));
287
+ }
288
+
289
+ /** A `usage` {@link LoreError} (exit `2`) with an actionable hint. */
290
+ function usage(message: string, hint: string): LoreError {
291
+ return new LoreError("usage", message, hint);
292
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * commands/discover.ts — the shared file-discovery + read seam for the command layer.
3
+ *
4
+ * Every command that operates over a set of bundle files — `validate`, `check`, `replace` (and the
5
+ * `rename`/`supersede` consumers next) — needs the same three primitives: read a file as UTF-8 with
6
+ * the project's `denied`/`not_found` error classification, derive a stable **canonical identity** for
7
+ * de-duplication, and render a file's **repo-relative** display path. They lived as near-identical
8
+ * copies in each command (a `/code-review max` finding); centralizing them here keeps one behavior, so
9
+ * two commands can't classify a read failure or fold a duplicate path differently. Pairs with
10
+ * {@link fswrite} (the write seam) — discovery/read here, write there, judgement in `core/`.
11
+ */
12
+
13
+ import { readFileSync, realpathSync } from "node:fs";
14
+ import { isAbsolute, join, posix, relative, sep } from "node:path";
15
+ import { walkMarkdown } from "../core/bundle";
16
+ import { DOCS_DIR } from "../core/scaffold";
17
+ import { ioError } from "../errors";
18
+
19
+ /** Read one file as UTF-8, mapping an I/O failure to a classified {@link LoreError} via the shared {@link ioError} policy. */
20
+ export function readSource(abs: string, display: string): string {
21
+ try {
22
+ return readFileSync(abs, "utf8");
23
+ } catch (cause) {
24
+ ioError(cause, {
25
+ denied: { message: `permission denied reading ${display}`, hint: `make ${display} readable` },
26
+ notFound: { message: `cannot read ${display}`, hint: "check the path exists and is readable" },
27
+ input: { path: display },
28
+ });
29
+ }
30
+ }
31
+
32
+ /** The reserved index file name (mirrors `core/indexes.ts`'s own private constant of the same name). */
33
+ const INDEX_FILE = "index.md";
34
+
35
+ /**
36
+ * Read every existing `index.md` under the bundle as `bundle-relative-path → raw bytes` — the
37
+ * determinism seam `core/indexes.ts`'s `generateIndexes` splices into. Shared by `rename.ts` and
38
+ * `sync.ts`, both of which regenerate index hubs against whatever is currently on disk.
39
+ */
40
+ export function readIndexBytes(docsRoot: string): Map<string, string> {
41
+ const bytes = new Map<string, string>();
42
+ for (const rel of walkMarkdown(docsRoot, undefined)) {
43
+ if (posix.basename(rel) === INDEX_FILE) {
44
+ bytes.set(rel, readSource(join(docsRoot, rel), `${DOCS_DIR}/${rel}`));
45
+ }
46
+ }
47
+ return bytes;
48
+ }
49
+
50
+ /**
51
+ * A stable de-duplication key for an absolute file path: its `realpath` when resolvable (which folds
52
+ * case on a case-insensitive filesystem and collapses symlinks, so two spellings of one physical file
53
+ * — or a symlink and its target — share a key), else the path verbatim (a file that vanished mid-walk
54
+ * still gets a key, and {@link readSource} raises the real I/O error).
55
+ */
56
+ export function canonicalIdentity(abs: string): string {
57
+ try {
58
+ return realpathSync.native(abs);
59
+ } catch {
60
+ return abs;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The repo-relative POSIX path for a discovered file, for stable display and de-duplication. A path
66
+ * inside the repo renders relative (`docs/adr/x.md`); a path **outside** the repo keeps its absolute
67
+ * form (the relative path would escape with `../`). The `..` escape is matched by path **segment**, so
68
+ * a real in-repo file whose first segment merely starts with `..` (e.g. `..notes/x.md`) is not pushed
69
+ * out to its absolute form.
70
+ */
71
+ export function toRepoRelative(root: string, abs: string): string {
72
+ const rel = relative(root, abs);
73
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
74
+ return abs;
75
+ }
76
+ return rel.split(sep).join("/");
77
+ }
78
+
79
+ /**
80
+ * Whether `abs` resolves **inside** the repo `root` — the single repo-containment predicate the
81
+ * commands share, so the bundle-escape decision is made one way everywhere (a `/code-review max`
82
+ * finding: `validate` and `replace` had drifted on the `..`-segment edge). Matches
83
+ * {@link toRepoRelative}'s segment rule: a path equal to `..`, beginning `../`, or absolute after
84
+ * relativizing is outside; anything else is inside.
85
+ */
86
+ export function withinRepo(root: string, abs: string): boolean {
87
+ const rel = relative(root, abs);
88
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
89
+ }