@opum-ai/lore 0.1.0 → 0.2.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 (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,457 +0,0 @@
1
- /**
2
- * commands/orphans.ts — `lore orphans [--tasks-only | --docs-only] [--limit <n>]` (cli-surface §orphans).
3
- *
4
- * The read-only, **bidirectional** doc↔task coupling report: the CI/agent signal that the
5
- * coupling `lore link`/`sync` maintain has gaps. Two directions, one envelope:
6
- *
7
- * - **orphanTasks** — tasks with **no owning doc**: no concept lists the task in its `tasks:`
8
- * frontmatter AND the task carries no `doc:<conceptId>` back-reference label AND (LORE-261) no
9
- * ancestor in its Backlog `parentTaskId` chain is owned either — a subtask of an already-linked
10
- * parent task is not reported, since linking the parent does not stamp each subtask with its own
11
- * back-reference (see {@link hasOwnedAncestor}). Work that exists in Backlog but is documented
12
- * nowhere, at any level of its parent/subtask hierarchy.
13
- * - **danglingLinks** — docs whose linked task **vanished**: a `tasks:` id the current-branch
14
- * Backlog snapshot no longer knows. A doc pointing at a task that has been deleted or renamed.
15
- *
16
- * ## One snapshot, pure set arithmetic — not a per-task probe
17
- *
18
- * Unlike `lore tasks` (which `viewTask`s each of *one* concept's linked ids), `orphans` spans the
19
- * whole bundle, so it reads Backlog **once**: `adapter.listTasks()` returns the current-branch
20
- * on-disk truth across every status (backlog-cli-contract.md §: `task list --json` — "Never
21
- * `backlog board`"), and both directions fall out of set arithmetic against the loaded graph:
22
- * `orphanTasks` from the snapshot minus the forward `tasks:` set (and the `doc:` labels the snapshot
23
- * itself carries), `danglingLinks` from each concept's `tasks:` minus the snapshot's known ids. No
24
- * N+1 per-id reads.
25
- *
26
- * ## A report, never a gate
27
- *
28
- * The Backlog capability probe runs UP FRONT (fail-fast: a missing binary is `not_found`/exit 3, a
29
- * stock non-`--json` binary is `validation`/exit 6), and the single `listTasks()` either returns the
30
- * snapshot or throws hard drift (exit 6) — there is no soft-null path here, because a dangling link
31
- * is the *content* of the report, not an advisory swallowed on the way to a rollup. Everything after
32
- * the snapshot is pure, so `orphans` always returns exit `0` (ADR-0007: `orphans` is a detection
33
- * report, not a coherence gate — even a non-empty report is exit 0). Only a *usage* error (a bad
34
- * flag / a stray positional), a *bundle* failure (unreadable/malformed `docs/`), or the *probe/read*
35
- * failure above throws, funneling through the router's one error seam like every command.
36
- *
37
- * ## Scope boundary
38
- *
39
- * "No owning doc" is the *literal* surface definition: **any** `doc:` label exempts a task, even one
40
- * pointing at a since-removed concept (a `doc:`→dead-concept is a third asymmetry, out of this
41
- * command's two-direction scope). Broken doc→doc cross-links are `lore check`'s job (its link/anchor
42
- * pass), not repeated here. An **archived** Backlog task reads identically to a deleted one through the
43
- * JSON adapter — archiving moves it to `backlog/archive/tasks/`, dropping it from BOTH `task list` and
44
- * `task view` (ADR-0002 keeps lore to that JSON-only surface, so the archive directory is deliberately
45
- * never consulted) — so a doc still linking an archived task surfaces here as a dangling link, exactly
46
- * as `lore tasks` drops that same id from its rollup. That is intentional and consistent, not a
47
- * distinction lore could draw. Output follows the uniform CLI modes: the `{schemaVersion, kind:
48
- * "orphans.report", data}` envelope under `--json` — `data` an object `{ orphanTasks?, danglingLinks? }`
49
- * (object-wrapped so the contract can grow additively; the section a flag excludes is **omitted**, not
50
- * emitted empty, so `--docs-only --json` never shows a misleading `orphanTasks: []`) — and otherwise an
51
- * aligned text report.
52
- *
53
- * ## Bounded output (cli-contract §3)
54
- *
55
- * `orphans` is named alongside `query`/`graph`/`context` as a read-heavy command that must cap its
56
- * output rather than dump an unbounded snapshot into a CI log or an agent's context window. Its two
57
- * sections are independent (an unrelated task backlog and an unrelated doc set), so each is capped —
58
- * and reports its own `total`/`shown`/`truncated` — separately against the same `--limit` (default
59
- * {@link DEFAULT_ORPHANS_LIMIT}), mirroring `query`'s single-section shape once per section.
60
- */
61
-
62
- import { join } from "node:path";
63
- import type { BacklogAdapter, BacklogTask } from "../adapters/backlog";
64
- import { loadBundle, toRefList } from "../core/bundle";
65
- import type { Concept } from "../core/concept";
66
- import { loadProfile } from "../core/profile";
67
- import { DOCS_DIR } from "../core/scaffold";
68
- import { ANSI, EXIT_OK, paint, WarningCollector, type Writer } from "../errors";
69
- import {
70
- emit,
71
- maxLen,
72
- type OutputContext,
73
- type Renderable,
74
- renderTaskSummaryRows,
75
- renderTruncationLine,
76
- type TaskSummaryRow,
77
- truncation,
78
- } from "../output";
79
- import { assertFlagAtMostOnce, parseCommandArgs, singleOptionValue, usage } from "./args";
80
- import { dedupeTaskIds, defaultAdapter } from "./link";
81
-
82
- /** Options for {@link runOrphans}; `root`, the streams, and the adapter are injectable for tests. */
83
- export interface OrphansOptions {
84
- /** The repo root the `docs/` bundle and the Backlog adapter resolve against. */
85
- root: string;
86
- /** The resolved output mode/color (from `output.ts`). */
87
- output: OutputContext;
88
- /** The command's normalized flag tokens from Commander. */
89
- args: readonly string[];
90
- /** stdout sink; defaults to `process.stdout`. */
91
- stdout?: Writer;
92
- /** stderr sink for advisory warnings; defaults to `process.stderr`. */
93
- stderr?: Writer;
94
- /** The Backlog adapter; defaults to the real `backlog` binary resolved against `root`. Injected for tests. */
95
- adapter?: BacklogAdapter;
96
- }
97
-
98
- /** The parsed form of `lore orphans`'s arguments — the two mutually-exclusive section filters, plus the cap. */
99
- interface OrphansArgs {
100
- /** `--tasks-only`: report only the orphan-task side (omit `danglingLinks`). */
101
- readonly tasksOnly: boolean;
102
- /** `--docs-only`: report only the dangling-link side (omit `orphanTasks`). */
103
- readonly docsOnly: boolean;
104
- /** `--limit` (at most once); `undefined` falls back to {@link DEFAULT_ORPHANS_LIMIT}. */
105
- readonly limit?: number;
106
- }
107
-
108
- /** One task with no owning doc: its live identity + current Backlog status, from the Backlog snapshot. */
109
- export type OrphanTask = TaskSummaryRow;
110
-
111
- /** One dangling doc→task link: a concept and the `tasks:` id Backlog no longer knows. */
112
- export interface DanglingLink {
113
- /** The owning concept's id (`"stories/bulk-archive-orders"`). */
114
- readonly concept: string;
115
- /** The vanished task id, echoed **verbatim** as the concept's `tasks:` frontmatter wrote it (Backlog has no record to re-case it). */
116
- readonly task: string;
117
- }
118
-
119
- /**
120
- * The `orphans.report` payload. Both keys are optional: a run with no flag carries both, `--tasks-only`
121
- * carries only the orphan-task fields, `--docs-only` only the dangling-link fields. An excluded section
122
- * is entirely absent (not `[]`) so a consumer can tell "not requested" from "requested, found none".
123
- *
124
- * Each section carries its own `total`/`shown`/`truncated` (cli-contract §3) rather than one combined
125
- * triple — `orphanTasks` and `danglingLinks` count unrelated things (a task backlog vs. a doc set), so a
126
- * single combined cap would make one section's truncation state say nothing about the other.
127
- */
128
- export interface OrphansReport {
129
- /** Tasks with no owning doc, sorted by id (case-insensitive), capped to `--limit`. Omitted under `--docs-only`. */
130
- readonly orphanTasks?: readonly OrphanTask[];
131
- /** The full count of orphan tasks before the `--limit` cap. Present iff {@link orphanTasks} is. */
132
- readonly orphanTasksTotal?: number;
133
- /** The number of orphan tasks actually returned (`orphanTasksShown <= orphanTasksTotal`). */
134
- readonly orphanTasksShown?: number;
135
- /** `true` when the `--limit` cap dropped orphan tasks. */
136
- readonly orphanTasksTruncated?: boolean;
137
- /** Docs whose linked task vanished, sorted by (concept, task), capped to `--limit`. Omitted under `--tasks-only`. */
138
- readonly danglingLinks?: readonly DanglingLink[];
139
- /** The full count of dangling links before the `--limit` cap. Present iff {@link danglingLinks} is. */
140
- readonly danglingLinksTotal?: number;
141
- /** The number of dangling links actually returned (`danglingLinksShown <= danglingLinksTotal`). */
142
- readonly danglingLinksShown?: number;
143
- /** `true` when the `--limit` cap dropped dangling links. */
144
- readonly danglingLinksTruncated?: boolean;
145
- }
146
-
147
- /**
148
- * Run `lore orphans`: parse the flags, load the bundle, take the one Backlog snapshot, compute both
149
- * coupling-gap directions by set arithmetic, emit the `orphans.report`, and return `0`. Async because
150
- * it drives the Backlog subprocess. See the module docstring for the failure modes.
151
- */
152
- export async function runOrphans(options: OrphansOptions): Promise<number> {
153
- const parsed = parseOrphansArgs(options.args);
154
- const docsRoot = join(options.root, DOCS_DIR);
155
- const advisories = new WarningCollector();
156
- const profile = loadProfile({ root: options.root });
157
- const graph = loadBundle(docsRoot, { warnings: advisories, profile });
158
- // Flush load advisories (e.g. a file skipped for a malformed header) before any Backlog I/O, so a
159
- // "why isn't this a concept" note survives even if the snapshot read below throws (mirrors `lore tasks`).
160
- advisories.flush({ color: options.output.color, stderr: options.stderr });
161
-
162
- const adapter = options.adapter ?? defaultAdapter(options.root);
163
- // Probe UP FRONT (fail-fast 3/6) before the snapshot, so an incapable binary is reported as such and
164
- // never mistaken for "the project has zero tasks" — the disambiguation the report's meaning relies on.
165
- await adapter.probe();
166
- const snapshot = await adapter.listTasks();
167
-
168
- emit(orphansRenderable(computeOrphans(graph.concepts.values(), snapshot, parsed)), options.output, options.stdout);
169
- return EXIT_OK;
170
- }
171
-
172
- /**
173
- * The pure heart of the command: from the bundle's concepts and one Backlog snapshot, derive both
174
- * coupling-gap directions and apply the section filter. Kept side-effect-free (a plain iterable of
175
- * concepts + the snapshot array in, a report out) so it is exercised directly in tests without a bundle
176
- * or a subprocess.
177
- *
178
- * A single pass over the concepts builds the forward `tasks:` set (for the orphan-task test) and the
179
- * flat list of every `(concept, taskId)` reference (for the dangling test) at once; `orphanTasks` then
180
- * filters the snapshot and `danglingLinks` filters the references, both against case-insensitive id
181
- * sets. Both outputs are sorted for a deterministic, diff-stable report.
182
- */
183
- export function computeOrphans(
184
- concepts: Iterable<Concept>,
185
- snapshot: readonly BacklogTask[],
186
- parsed: OrphansArgs,
187
- ): OrphansReport {
188
- const referenced = new Set<string>(); // lower-cased task ids any concept forward-links
189
- const references: DanglingLink[] = []; // every (concept, taskId) pair, for the dangling test
190
- for (const concept of concepts) {
191
- for (const task of dedupeTaskIds(toRefList(concept.frontmatter.tasks))) {
192
- referenced.add(task.toLowerCase());
193
- references.push({ concept: concept.id, task });
194
- }
195
- }
196
-
197
- const known = new Set(snapshot.map((task) => task.id.toLowerCase())); // lower-cased ids Backlog knows
198
- const byId = new Map(snapshot.map((task) => [task.id.toLowerCase(), task])); // for the parent-chain walk below
199
- const orphanTasks = snapshot
200
- .filter(
201
- (task) =>
202
- !referenced.has(task.id.toLowerCase()) && !hasDocLabel(task) && !hasOwnedAncestor(task, referenced, byId),
203
- )
204
- .map((task): OrphanTask => ({ id: task.id, title: task.title, status: task.status }))
205
- .sort((a, b) => compareLower(a.id, b.id));
206
- const danglingLinks = references
207
- .filter((ref) => !known.has(ref.task.toLowerCase()))
208
- .sort((a, b) => compareLower(a.concept, b.concept) || compareLower(a.task, b.task));
209
-
210
- const limit = parsed.limit ?? DEFAULT_ORPHANS_LIMIT;
211
-
212
- // Compute both directions unconditionally (both are cheap and share the same inputs), then omit the
213
- // section a flag excluded — omission, not an empty array, is how the envelope says "not requested".
214
- // Each section is capped to `limit` independently and carries its own total/shown/truncated (§3):
215
- // the two counts are unrelated, so one combined cap would make one section's truncation state say
216
- // nothing about the other.
217
- const report: {
218
- orphanTasks?: OrphanTask[];
219
- orphanTasksTotal?: number;
220
- orphanTasksShown?: number;
221
- orphanTasksTruncated?: boolean;
222
- danglingLinks?: DanglingLink[];
223
- danglingLinksTotal?: number;
224
- danglingLinksShown?: number;
225
- danglingLinksTruncated?: boolean;
226
- } = {};
227
- if (!parsed.docsOnly) {
228
- const shown = orphanTasks.slice(0, limit);
229
- report.orphanTasks = shown;
230
- report.orphanTasksTotal = orphanTasks.length;
231
- report.orphanTasksShown = shown.length;
232
- report.orphanTasksTruncated = shown.length < orphanTasks.length;
233
- }
234
- if (!parsed.tasksOnly) {
235
- const shown = danglingLinks.slice(0, limit);
236
- report.danglingLinks = shown;
237
- report.danglingLinksTotal = danglingLinks.length;
238
- report.danglingLinksShown = shown.length;
239
- report.danglingLinksTruncated = shown.length < danglingLinks.length;
240
- }
241
- return report;
242
- }
243
-
244
- /** The default `--limit` when none is given, matching `query`'s cap (cli-surface §orphans). */
245
- export const DEFAULT_ORPHANS_LIMIT = 20;
246
-
247
- /** Whether a task claims an owning doc via a `doc:<conceptId>` back-reference label (case-insensitive). */
248
- function hasDocLabel(task: BacklogTask): boolean {
249
- return task.labels.some((label) => label.toLowerCase().startsWith("doc:"));
250
- }
251
-
252
- /**
253
- * LORE-261: exempt a Backlog **subtask** from the orphan report when an ancestor in its
254
- * `parentTaskId` chain is already owned — forward-referenced by some concept's `tasks:` list, or
255
- * itself carrying a `doc:` label. Linking a parent task to a Story does not stamp every subtask
256
- * with its own back-reference (one `backlog task edit` per task — ADR-0009 §2), so without this
257
- * walk a correctly-coupled Story's subtasks would all read as false-positive orphans (the Meridian
258
- * stress test: 8 reported instead of the intended 2).
259
- *
260
- * This is **orphans-side hierarchy awareness**, chosen over a link-side cascade (`lore link` writing
261
- * a `doc:` label onto every subtask): the `--json` adapter already carries `parentTaskId` on every
262
- * task in the SAME `listTasks()` snapshot this command already reads (no extra per-task `view`
263
- * call, preserving the "one snapshot" design above), and a cascade would go stale the instant a new
264
- * subtask is added after the parent was linked — this walk instead recomputes fresh on every run.
265
- *
266
- * Only the task's **ancestors** are walked (never siblings or descendants) — starting at
267
- * `task.parentTaskId`, not `task` itself, since the caller already tested the task's own
268
- * reference/label directly. A parent absent from the current snapshot (deleted, archived, or
269
- * renamed out from under a stale `parentTaskId`) grants no exemption — a subtask under a vanished
270
- * ancestor is reported, matching how `orphans` already treats an archived task as gone everywhere
271
- * else (module docstring, "Scope boundary"). A visited-id set guards a corrupt or cyclic parent
272
- * chain: a cycle yields `false` (not owned, so still reported) rather than looping forever — this
273
- * is the fail-toward-reporting side of AC#3's no-false-negatives requirement.
274
- */
275
- function hasOwnedAncestor(
276
- task: BacklogTask,
277
- referenced: ReadonlySet<string>,
278
- byId: ReadonlyMap<string, BacklogTask>,
279
- ): boolean {
280
- const visited = new Set<string>([task.id.toLowerCase()]);
281
- let parentId = task.parentTaskId;
282
- while (parentId !== null) {
283
- const key = parentId.toLowerCase();
284
- if (visited.has(key)) {
285
- return false; // a cycle/self-reference in the parent chain — fail toward "still reported"
286
- }
287
- visited.add(key);
288
- const parent = byId.get(key);
289
- if (parent === undefined) {
290
- return false; // the ancestor isn't in the current-branch snapshot — no exemption to grant
291
- }
292
- if (referenced.has(key) || hasDocLabel(parent)) {
293
- return true;
294
- }
295
- parentId = parent.parentTaskId;
296
- }
297
- return false;
298
- }
299
-
300
- /** Case-insensitive string order, locale-independent, for the report's deterministic sort. */
301
- function compareLower(a: string, b: string): number {
302
- const x = a.toLowerCase();
303
- const y = b.toLowerCase();
304
- return x < y ? -1 : x > y ? 1 : 0;
305
- }
306
-
307
- // ── Argument parsing ───────────────────────────────────────────────────────────
308
-
309
- /**
310
- * Parse `orphans`'s tokens: the two boolean switches `--tasks-only` / `--docs-only`, the value flag
311
- * `--limit <n>` (also accepting `--limit=<n>`), and nothing else. Commander has already resolved
312
- * lore's global flags, so a `--`-prefixed token here is a command flag; an unrecognized one, a
313
- * positional (orphans takes none), a repeated or value-bearing `--tasks-only`/`--docs-only`, passing
314
- * **both** section filters, or a repeated/value-less/non-integer/too-large/non-positive `--limit` is a
315
- * `usage` error (exit 2). A `--` ends option parsing (after which any token is a stray positional).
316
- */
317
- function parseOrphansArgs(args: readonly string[]): OrphansArgs {
318
- const parsed = parseCommandArgs(args, "orphans");
319
- assertFlagAtMostOnce(parsed, "tasks-only");
320
- assertFlagAtMostOnce(parsed, "docs-only");
321
- if (parsed.positionals.length > 0) {
322
- throw usage(`unexpected argument "${parsed.positionals[0]}"`, "orphans takes no positional arguments");
323
- }
324
- const tasksOnly = parsed.flags.has("tasks-only");
325
- const docsOnly = parsed.flags.has("docs-only");
326
- const rawLimit = singleOptionValue(parsed, "limit");
327
- if (rawLimit === "") {
328
- throw usage("--limit needs a value", "pass a value, e.g. `--limit 20`");
329
- }
330
- const limit = rawLimit === undefined ? undefined : parseCount("--limit", rawLimit);
331
- if (tasksOnly && docsOnly) {
332
- throw usage(
333
- "--tasks-only and --docs-only are mutually exclusive",
334
- "pass at most one of them, or neither for the full report",
335
- );
336
- }
337
- return { tasksOnly, docsOnly, limit };
338
- }
339
-
340
- /**
341
- * Parse `--limit`'s value as a positive integer. Rejects a non-digit run (`Number()` would coerce
342
- * `"1.5"`/`"0x2"`/`" 2 "`/`"1e3"`), `0` (a zero cap returns nothing useful), and a precision-losing
343
- * `> 2^53` run — mirroring `query`'s `--limit` guard so every capped command accepts counts identically.
344
- */
345
- function parseCount(flag: string, value: string): number {
346
- if (!/^\d+$/.test(value)) {
347
- throw usage(`invalid ${flag} "${value}"`, `pass an integer ≥ 1, e.g. \`${flag} 20\``);
348
- }
349
- const count = Number.parseInt(value, 10);
350
- if (!Number.isSafeInteger(count)) {
351
- throw usage(`${flag} "${value}" is too large`, "pass a smaller integer");
352
- }
353
- if (count < 1) {
354
- throw usage(`invalid ${flag} "${value}"`, `pass an integer ≥ 1, e.g. \`${flag} 20\``);
355
- }
356
- return count;
357
- }
358
-
359
- // ── Output ─────────────────────────────────────────────────────────────────────
360
-
361
- /**
362
- * The rendering bundle for `orphans` (output.ts dispatches on the mode). `--json` carries the structured
363
- * {@link OrphansReport}; the pretty/plain text is an aligned report. The two text modes differ only in the
364
- * painted header, so they share one renderer.
365
- */
366
- function orphansRenderable(data: OrphansReport): Renderable<OrphansReport> {
367
- return {
368
- kind: "orphans.report",
369
- data,
370
- pretty: (d, opts) => renderReport(d, opts.color),
371
- plain: (d) => renderReport(d, false),
372
- };
373
- }
374
-
375
- /** The narrow-it hint on each section's §3 truncation line — the one actionable way to see more of it. */
376
- const LIMIT_HINT = "raise --limit to see more";
377
-
378
- /**
379
- * A human/pipe-stable report: a header summarizing the requested sections' **total** counts, then one
380
- * aligned block per non-empty section (` <id> <status> <title>` for orphan tasks; ` <concept> ->
381
- * <task>` for dangling links), each followed by its own §3 truncation footer when `--limit` dropped
382
- * rows. When every requested section is empty, a single all-clear line stands in for the blocks. ANSI
383
- * only on the header, and only when `color`.
384
- */
385
- function renderReport(data: OrphansReport, color: boolean): string {
386
- const { orphanTasks, danglingLinks } = data;
387
- const counts: string[] = [];
388
- if (orphanTasks !== undefined) {
389
- const total = data.orphanTasksTotal as number;
390
- counts.push(`${total} orphan ${total === 1 ? "task" : "tasks"}`);
391
- }
392
- if (danglingLinks !== undefined) {
393
- const total = data.danglingLinksTotal as number;
394
- counts.push(`${total} dangling ${total === 1 ? "link" : "links"}`);
395
- }
396
- const lines = [paint(`orphans: ${counts.join(", ")}`, ANSI.green, color)];
397
-
398
- if (orphanTasks !== undefined && orphanTasks.length > 0) {
399
- lines.push("", "tasks with no owning doc:");
400
- // A per-item loop, not `lines.push(...renderTaskSummaryRows(orphanTasks))` — spreading a large
401
- // array into a function-call argument list has its own engine argument-count ceiling, the same
402
- // class of RangeError the spread-free `maxLen` (below and in output.ts) was written to avoid.
403
- for (const row of renderTaskSummaryRows(orphanTasks)) {
404
- lines.push(row);
405
- }
406
- const footer = renderTruncationLine(
407
- truncation(data.orphanTasksTotal as number, data.orphanTasksShown as number, LIMIT_HINT),
408
- );
409
- if (footer !== "") {
410
- lines.push(footer);
411
- }
412
- }
413
- if (danglingLinks !== undefined && danglingLinks.length > 0) {
414
- const conceptWidth = maxLen(danglingLinks, (link) => link.concept.length);
415
- lines.push("", "docs with a vanished linked task:");
416
- for (const link of danglingLinks) {
417
- lines.push(` ${link.concept.padEnd(conceptWidth)} -> ${link.task}`);
418
- }
419
- const footer = renderTruncationLine(
420
- truncation(data.danglingLinksTotal as number, data.danglingLinksShown as number, LIMIT_HINT),
421
- );
422
- if (footer !== "") {
423
- lines.push(footer);
424
- }
425
- }
426
-
427
- const allClear = allClearLine(orphanTasks, danglingLinks);
428
- if (allClear !== undefined) {
429
- lines.push(allClear);
430
- }
431
- return lines.join("\n");
432
- }
433
-
434
- /**
435
- * The all-clear line when every **requested** section is empty, else `undefined` (a section had entries,
436
- * so its block already stands on its own). Crucially, the phrasing asserts cleanliness ONLY for the
437
- * sections that were actually computed: under `--tasks-only`/`--docs-only` the excluded side (an
438
- * `undefined` argument) was never checked, so it is left out of the sentence rather than falsely
439
- * declared clean. At least one side is always requested (the parser rejects both flags), so the line is
440
- * never empty.
441
- */
442
- function allClearLine(
443
- orphanTasks: readonly OrphanTask[] | undefined,
444
- danglingLinks: readonly DanglingLink[] | undefined,
445
- ): string | undefined {
446
- if ((orphanTasks?.length ?? 0) > 0 || (danglingLinks?.length ?? 0) > 0) {
447
- return undefined;
448
- }
449
- const clauses: string[] = [];
450
- if (orphanTasks !== undefined) {
451
- clauses.push("every task has an owning doc");
452
- }
453
- if (danglingLinks !== undefined) {
454
- clauses.push("every linked task is live");
455
- }
456
- return `(none — ${clauses.join(", ")})`;
457
- }
@@ -1,67 +0,0 @@
1
- /** `lore path`: bounded deterministic paths across exact authored typed edges. */
2
-
3
- import type { BacklogAdapter } from "../adapters/backlog";
4
- import { loadRetrievalGraph, type RetrievalGraphLoader } from "../core/retrieval";
5
- import { findPaths } from "../core/traversal";
6
- import { EXIT_OK, LoreError, WarningCollector, type Writer } from "../errors";
7
- import { emit, type OutputContext } from "../output";
8
- import { parseCommandArgs, usage, workspaceSelection } from "./args";
9
- import {
10
- assertKnownEdgeKinds,
11
- normalizeEndpointId,
12
- parseEndpointKind,
13
- parseTraversalFlags,
14
- pathRenderable,
15
- } from "./traversal";
16
-
17
- export interface PathCommandOptions {
18
- readonly root: string;
19
- readonly output: OutputContext;
20
- readonly args: readonly string[];
21
- readonly stdout?: Writer;
22
- readonly stderr?: Writer;
23
- readonly adapter?: BacklogAdapter;
24
- readonly retrieval?: RetrievalGraphLoader;
25
- }
26
-
27
- export async function runPath(options: PathCommandOptions): Promise<number> {
28
- const parsed = parseCommandArgs(options.args, "path");
29
- const workspace = workspaceSelection(parsed);
30
- if (parsed.positionals.length !== 2) {
31
- throw usage(
32
- "path needs exactly <from> and <to>",
33
- "run `lore path <from> <to> --from-kind <kind> --to-kind <kind> --direction <direction>`",
34
- );
35
- }
36
- const fromKind = parseEndpointKind(parsed, "from-kind");
37
- const toKind = parseEndpointKind(parsed, "to-kind");
38
- const flags = parseTraversalFlags(parsed);
39
- const advisories = new WarningCollector();
40
- const loaded = await (options.retrieval ?? loadRetrievalGraph)({
41
- root: options.root,
42
- warnings: advisories,
43
- adapter: options.adapter,
44
- includeTraversal: true,
45
- ...(workspace !== undefined ? { workspace } : {}),
46
- });
47
- try {
48
- advisories.flush({ color: options.output.color, stderr: options.stderr });
49
- if (loaded.traversal === undefined) throw new LoreError("validation", "traversal snapshot was not loaded");
50
- assertKnownEdgeKinds(loaded.traversal, flags.edgeKinds);
51
- const data = findPaths(loaded.traversal, {
52
- from: {
53
- kind: fromKind,
54
- id: normalizeEndpointId(parsed.positionals[0] as string, fromKind, workspace !== undefined),
55
- },
56
- to: {
57
- kind: toKind,
58
- id: normalizeEndpointId(parsed.positionals[1] as string, toKind, workspace !== undefined),
59
- },
60
- ...flags,
61
- });
62
- emit(pathRenderable(data), options.output, options.stdout);
63
- return EXIT_OK;
64
- } finally {
65
- await loaded.dispose?.();
66
- }
67
- }
@@ -1,68 +0,0 @@
1
- /** `lore provenance`: exact retained-snapshot record evidence. */
2
-
3
- import type { BacklogAdapter } from "../adapters/backlog";
4
- import { findRetainedProvenance, type ProvenanceResult, type RetainedFactKind } from "../core/snapshot";
5
- import { resolveSnapshotScope } from "../core/snapshot-runtime";
6
- import type { SnapshotScopeSelection } from "../core/snapshot-store";
7
- import { loadSnapshot } from "../core/snapshot-store";
8
- import { WarningCollector, type Writer } from "../errors";
9
- import { emit, type OutputContext, type Renderable } from "../output";
10
- import { parseCommandArgs, singleOptionValue, usage, workspaceSelection } from "./args";
11
-
12
- export interface ProvenanceCommandOptions {
13
- readonly root: string;
14
- readonly output: OutputContext;
15
- readonly args: readonly string[];
16
- readonly stdout?: Writer;
17
- readonly stderr?: Writer;
18
- readonly adapter?: BacklogAdapter;
19
- readonly resolveScope?: () => SnapshotScopeSelection | Promise<SnapshotScopeSelection>;
20
- }
21
-
22
- export async function runProvenance(options: ProvenanceCommandOptions): Promise<number> {
23
- const parsed = parseCommandArgs(options.args, "provenance");
24
- const workspace = workspaceSelection(parsed);
25
- if (parsed.positionals.length !== 1)
26
- throw usage("provenance needs exactly one <id>", "run `lore provenance <id> --kind <kind> --snapshot <selector>`");
27
- const kind = parseKind(singleOptionValue(parsed, "kind"));
28
- const selector = singleOptionValue(parsed, "snapshot");
29
- if (selector === undefined || selector.trim() === "")
30
- throw usage("--snapshot needs a value", "pass an exact retained snapshot key or unambiguous commit");
31
- const advisories = new WarningCollector();
32
- const selection = workspace === undefined ? {} : { workspace: workspace.manifestPath };
33
- const scope =
34
- options.resolveScope === undefined
35
- ? await resolveSnapshotScope({ root: options.root, selection, warnings: advisories, adapter: options.adapter })
36
- : await options.resolveScope();
37
- const snapshot = loadSnapshot(options.root, scope, selector.trim());
38
- const data = findRetainedProvenance(snapshot, {
39
- id: parsed.positionals[0] as string,
40
- kind,
41
- repositories: workspace?.memberIds ?? [],
42
- });
43
- advisories.flush({ color: options.output.color, stderr: options.stderr });
44
- emit(provenanceRenderable(data), options.output, options.stdout);
45
- return 0;
46
- }
47
-
48
- function parseKind(value: string | undefined): RetainedFactKind {
49
- if (value !== "concept" && value !== "task" && value !== "edge")
50
- throw usage("--kind must be concept, task, or edge", "pass the retained fact kind explicitly");
51
- return value;
52
- }
53
-
54
- function provenanceRenderable(data: ProvenanceResult): Renderable<ProvenanceResult> {
55
- return { kind: "provenance.result", data, pretty: renderProvenance, plain: renderProvenance };
56
- }
57
-
58
- function renderProvenance(data: ProvenanceResult): string {
59
- const source = data.fact.provenance;
60
- return [
61
- `${data.fact.kind} ${data.fact.id} @ ${data.snapshot.snapshotKey}`,
62
- `repository ${source.memberId ?? source.repositoryScopeKey}`,
63
- `commit ${source.gitCommit ?? "uncommitted"}`,
64
- `export ${source.exportDigest}`,
65
- `record ${source.recordKey}`,
66
- `source ${source.sourcePath ?? "none"}`,
67
- ].join("\n");
68
- }