@opum-ai/lore 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.
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,312 +0,0 @@
1
- /**
2
- * commands/query.ts — `lore query ["<text>"] [--type <T>] [--tag <t>]… [--status <S>] [--field k=v]… [--limit <n>]`.
3
- *
4
- * The thin, read-only layer behind the bundle's full-text search (cli-surface
5
- * §query; LORE-33). Commander supplies a verified indexed {@link BundleGraph}
6
- * with automatic reference fallback; direct core callers retain the reference
7
- * loader. The optional search text and frontmatter filters then go to the pure
8
- * {@link query} engine, which keeps concepts matching every filter, ranks them
9
- * by BM25 relevance, and returns the top `--limit` hits with a bounded-output
10
- * signal. **No vectors, RAG, or chunking** (ADR-0018): the lexical index remains
11
- * deterministic and in memory.
12
- *
13
- * Output follows the uniform CLI modes: the `{schemaVersion, kind: "query.results",
14
- * data}` envelope under the global `--json`, otherwise a ranked listing — one
15
- * `<id> [<type>] (<score>) — <snippet>` line per hit followed by the §3 truncation
16
- * footer when the `--limit` cap dropped matches. There is no command-specific JSON
17
- * flag; machine consumers use the same `--json` they use everywhere. The pretty and
18
- * plain renderers are identical (the listing is structural — no severities to color),
19
- * so they share one renderer.
20
- *
21
- * Validation lives here (the ranking/filtering stays pure in `core/query.ts`): an
22
- * unknown flag, a repeated `--type`/`--status`/`--limit`, a value-less value flag, a
23
- * non-integer/too-large/non-positive `--limit`, a malformed `--field` (no `=` or an
24
- * empty key), or a second positional is a `usage` error (exit 2). `query` never looks
25
- * an id up, so there is no `not_found` path — zero hits is a normal exit 0.
26
- */
27
-
28
- import { join } from "node:path";
29
- import type { BacklogAdapter } from "../adapters/backlog";
30
- import { loadBundle } from "../core/bundle";
31
- import { loadProfile } from "../core/profile";
32
- import { type FieldFilter, type QueryResult, query } from "../core/query";
33
- import { loadRetrievalGraph, type RetrievalGraphLoader } from "../core/retrieval";
34
- import { DOCS_DIR } from "../core/scaffold";
35
- import type { WorkspaceRetrievalContext, WorkspaceRetrievalSelection } from "../core/workspace-retrieval";
36
- import { EXIT_OK, LoreError, singleLine, stripAnsiAndControls, WarningCollector, type Writer } from "../errors";
37
- import { emit, type OutputContext, type Renderable, renderTruncationLine, truncation } from "../output";
38
- import { optionValues, parseCommandArgs, singleOptionValue, workspaceSelection } from "./args";
39
-
40
- /** Options for {@link runQuery}; `root` and the streams are injectable for tests. */
41
- export interface QueryCommandOptions {
42
- /** The repo root the `docs/` bundle resolves against. */
43
- root: string;
44
- /** The resolved output mode/color (from `output.ts`). */
45
- output: OutputContext;
46
- /** The command's normalized positional + flag tokens from Commander. */
47
- args: readonly string[];
48
- /** stdout sink; defaults to `process.stdout`. */
49
- stdout?: Writer;
50
- /** stderr sink for advisory warnings; defaults to `process.stderr`. */
51
- stderr?: Writer;
52
- /** Backlog snapshot seam used only by indexed projection freshness/builds. */
53
- adapter?: BacklogAdapter;
54
- /** Indexed/reference selector injected by the Commander handler or conformance tests. */
55
- retrieval?: RetrievalGraphLoader;
56
- }
57
-
58
- /** The parsed form of `lore query`'s arguments. */
59
- interface QueryArgs {
60
- /** The free-text search (the optional positional); `undefined` means filters-only. */
61
- text?: string;
62
- /** `--type` (at most once). */
63
- type?: string;
64
- /** `--status` (at most once). */
65
- status?: string;
66
- /** `--limit` (at most once); `undefined` falls back to the engine default. */
67
- limit?: number;
68
- /** `--tag` values, in order (repeatable). */
69
- tags: string[];
70
- /** `--field key=value` filters, in order (repeatable). */
71
- fields: FieldFilter[];
72
- readonly workspace?: WorkspaceRetrievalSelection;
73
- }
74
-
75
- /** The narrow-it hint on the §3 truncation line (AC#2) — the actionable ways to bound a broad result. */
76
- const NARROW_HINT = "narrow with --type/--tag/--status/--field, or raise --limit";
77
-
78
- /**
79
- * Run `lore query`: parse the arguments, load the bundle, search it, emit the
80
- * `query.results`, and return `0`. A bad flag/positional throws a `usage`
81
- * {@link LoreError} (exit `2`); there is no not-found path (zero hits is a normal `0`).
82
- */
83
- export function runQuery(options: QueryCommandOptions): number | Promise<number> {
84
- const parsed = parseQueryArgs(options.args);
85
- const advisories = new WarningCollector();
86
- const retrieval = options.retrieval ?? (parsed.workspace !== undefined ? loadRetrievalGraph : undefined);
87
- if (retrieval !== undefined) {
88
- return retrieval({
89
- root: options.root,
90
- warnings: advisories,
91
- adapter: options.adapter,
92
- ...(parsed.workspace !== undefined ? { workspace: parsed.workspace } : {}),
93
- }).then(async (loaded) => {
94
- try {
95
- const indexedResult = await loaded.indexed?.query({
96
- text: parsed.text,
97
- type: parsed.type,
98
- tags: parsed.tags,
99
- status: parsed.status,
100
- fields: parsed.fields,
101
- limit: parsed.limit,
102
- });
103
- return finishQuery(options, parsed, loaded.graph, advisories, indexedResult, loaded.workspace);
104
- } finally {
105
- await loaded.dispose?.();
106
- }
107
- });
108
- }
109
- const profile = loadProfile({ root: options.root });
110
- const graph = loadBundle(join(options.root, DOCS_DIR), { warnings: advisories, profile });
111
- return finishQuery(options, parsed, graph, advisories);
112
- }
113
-
114
- function finishQuery(
115
- options: QueryCommandOptions,
116
- parsed: QueryArgs,
117
- graph: ReturnType<typeof loadBundle>,
118
- advisories: WarningCollector,
119
- indexedResult?: QueryResult,
120
- workspace?: WorkspaceRetrievalContext,
121
- ): number {
122
- advisories.flush({ color: options.output.color, stderr: options.stderr });
123
-
124
- const data =
125
- indexedResult ??
126
- query(graph, {
127
- text: parsed.text,
128
- type: parsed.type,
129
- tags: parsed.tags,
130
- status: parsed.status,
131
- fields: parsed.fields,
132
- limit: parsed.limit,
133
- });
134
- const scoped: QueryResult =
135
- workspace === undefined
136
- ? data
137
- : {
138
- ...data,
139
- hits: data.hits.map((hit) => ({ ...hit, provenance: workspace.provenanceById.get(hit.id) })),
140
- workspace: workspace.scope,
141
- };
142
- emit(queryRenderable(scoped), options.output, options.stdout);
143
- return EXIT_OK;
144
- }
145
-
146
- // ── Argument parsing ───────────────────────────────────────────────────────────
147
-
148
- /**
149
- * Parse `query`'s tokens into the optional text positional and the value flags
150
- * (`--type`/`--status`/`--limit` at most once each; `--tag`/`--field` repeatable),
151
- * also accepting the `--flag=value` form. Commander has already resolved Lore's
152
- * global flags, so a `--`-prefixed token here is a command flag: an unrecognized one
153
- * is a `usage` error, as is a repeated single-value flag, a value-less value flag, a
154
- * non-integer/out-of-range `--limit`, a malformed `--field`, or a second positional.
155
- * A `--` ends option parsing.
156
- */
157
- function parseQueryArgs(args: readonly string[]): QueryArgs {
158
- const parsed = parseCommandArgs(args, "query");
159
- const workspace = workspaceSelection(parsed);
160
- const positionals = parsed.positionals;
161
- const trimmed = (name: string): string | undefined => {
162
- const value = singleOptionValue(parsed, name);
163
- if (value === undefined) return undefined;
164
- const result = value.trim();
165
- if (result === "") throw usage(`--${name} needs a value`, `pass a value, e.g. \`--${name} orders\``);
166
- return result;
167
- };
168
- const type = trimmed("type");
169
- const status = trimmed("status");
170
- const rawLimit = singleOptionValue(parsed, "limit");
171
- if (rawLimit === "") throw usage("--limit needs a value", "pass a value, e.g. `--limit orders`");
172
- const limit = rawLimit === undefined ? undefined : parseCount("--limit", rawLimit);
173
- const tags = optionValues(parsed, "tag").map((value) => {
174
- const tag = value.trim();
175
- if (tag === "") throw usage("--tag needs a value", "pass a value, e.g. `--tag orders`");
176
- return tag;
177
- });
178
- const fields = optionValues(parsed, "field").map(parseFieldFilter);
179
- if (positionals.length > 1) {
180
- throw usage(
181
- `unexpected argument "${positionals[1]}"`,
182
- 'pass one quoted search string, e.g. `lore query "soft delete retention"`',
183
- );
184
- }
185
- return { text: positionals[0], type, status, limit, tags, fields, workspace };
186
- }
187
-
188
- /**
189
- * Parse a `--field` argument into a `key=value` {@link FieldFilter}. Splits on the
190
- * **first** `=` (so a value may itself contain `=`); both sides are **trimmed** (the
191
- * comparison folds case and ignores surrounding space, so a padded `status= Done` must
192
- * not silently miss). A missing `=`, an empty key, or an **empty value** is a `usage`
193
- * error — the latter matching the empty-value guard {@link readValue} enforces for every
194
- * other flag, so `--field status=` is rejected rather than matching only a literally
195
- * empty field.
196
- */
197
- function parseFieldFilter(raw: string): FieldFilter {
198
- const eq = raw.indexOf("=");
199
- if (eq === -1) {
200
- throw usage(`invalid --field "${raw}"`, "use key=value, e.g. `--field status=in-progress`");
201
- }
202
- const key = raw.slice(0, eq).trim();
203
- if (key === "") {
204
- throw usage(`invalid --field "${raw}"`, "the field name before = must not be empty");
205
- }
206
- const value = raw.slice(eq + 1).trim();
207
- if (value === "") {
208
- throw usage(`invalid --field "${raw}"`, "the value after = must not be empty");
209
- }
210
- return { key, value };
211
- }
212
-
213
- /**
214
- * Parse `--limit`'s value as a positive integer. Rejects a non-digit run (`Number()`
215
- * would coerce `"1.5"`/`"0x2"`/`" 2 "`/`"1e3"`), `0` (a zero cap returns nothing
216
- * useful), and a precision-losing `> 2^53` run — mirroring the count guard `lore
217
- * graph`/`context` use so the commands accept counts identically.
218
- */
219
- function parseCount(flag: string, value: string): number {
220
- if (!/^\d+$/.test(value)) {
221
- throw usage(`invalid ${flag} "${value}"`, `pass an integer ≥ 1, e.g. \`${flag} 20\``);
222
- }
223
- const count = Number.parseInt(value, 10);
224
- if (!Number.isSafeInteger(count)) {
225
- throw usage(`${flag} "${value}" is too large`, "pass a smaller integer");
226
- }
227
- if (count < 1) {
228
- throw usage(`invalid ${flag} "${value}"`, `pass an integer ≥ 1, e.g. \`${flag} 20\``);
229
- }
230
- return count;
231
- }
232
-
233
- /**
234
- * Read a value flag's argument: its inline `--flag=value` form when present, else the
235
- * **next** token. A missing/empty value — or a next token that is itself an option
236
- * (`--type --tag`) — is a `usage` error rather than a silently swallowed flag
237
- * (mirroring `lore graph`/`context`'s value-flag guard).
238
- */
239
- // ── Output ─────────────────────────────────────────────────────────────────────
240
-
241
- /**
242
- * The per-result rendering bundle for `query` (output.ts dispatches on the mode).
243
- * `--json` always carries the structured {@link QueryResult}; the pretty/plain text is
244
- * the ranked listing. The two text modes render identically (the data is structural —
245
- * no severities to color), so pretty and plain share one renderer.
246
- */
247
- function queryRenderable(data: QueryResult): Renderable<QueryResult> {
248
- return { kind: "query.results", data, pretty: renderText, plain: renderText };
249
- }
250
-
251
- /**
252
- * A human/pipe-stable ranked listing: a `query "<text>": <n> matches` header (or
253
- * `query (filters): …` when filters drove the result), one `<id> [<type>]
254
- * (<score>) — <snippet>` line per hit (the `(<score>)` shown only under a text query;
255
- * the `— <snippet>` dropped when the concept has neither summary nor title), and the
256
- * trailing §3 truncation footer when the `--limit` cap dropped matches. ANSI-free and
257
- * deterministic: the query text and every hit's `id`/`type`/`snippet` are sanitized
258
- * ({@link sanitizeField}) before interpolation, so a crafted concept file (an id, a
259
- * `type`/`summary` frontmatter scalar) or the raw `--query` argument cannot smuggle an
260
- * ANSI escape sequence or other control byte into the rendered line (LORE-118).
261
- */
262
- function renderText(data: QueryResult): string {
263
- const queryText = data.query !== undefined ? sanitizeField(data.query) : undefined;
264
- const head = queryText !== undefined ? `query "${queryText}"` : "query (filters)";
265
- const lines = [
266
- ...(data.workspace !== undefined
267
- ? [`workspace: ${sanitizeField(data.workspace.workspaceId)} (${data.workspace.repositories.length} repositories)`]
268
- : []),
269
- `${head}: ${data.total} ${data.total === 1 ? "match" : "matches"}`,
270
- ];
271
- for (const hit of data.hits) {
272
- const score = queryText !== undefined ? ` (${formatScore(hit.score)})` : "";
273
- const id = sanitizeField(hit.id);
274
- const type = sanitizeField(hit.type);
275
- const snippet = hit.snippet !== undefined ? ` — ${sanitizeField(hit.snippet)}` : "";
276
- lines.push(` ${id} [${type}]${score}${snippet}`);
277
- }
278
- const footer = renderTruncationLine(truncation(data.total, data.shown, NARROW_HINT));
279
- if (footer !== "") {
280
- lines.push(footer);
281
- }
282
- return lines.join("\n");
283
- }
284
-
285
- /**
286
- * Sanitize a field before it is interpolated into the plain/pretty listing: collapse
287
- * it to one line ({@link singleLine}) and strip ANSI escape sequences plus residual C0/C1
288
- * control bytes ({@link stripAnsiAndControls}) — every source here (a concept `id`, its
289
- * `type`/`summary` frontmatter, or the raw `--query` text) can carry attacker-influenced
290
- * bytes (a crafted bundle file, or the CLI argument itself), and without this a CSI
291
- * sequence could rewrite terminal state or forge output (LORE-118).
292
- */
293
- function sanitizeField(text: string): string {
294
- return stripAnsiAndControls(singleLine(text));
295
- }
296
-
297
- /**
298
- * Format a BM25 score for the text listing: two decimals normally, but a **positive**
299
- * score that would round to `0.00` (a real hit whose relevance is tiny — e.g. a
300
- * near-ubiquitous term in a large bundle) falls back to two significant figures, so a
301
- * returned hit is never displayed as the `0.00` that reads like "should have been
302
- * dropped". Exported for direct unit coverage of both branches.
303
- */
304
- export function formatScore(score: number): string {
305
- const fixed = score.toFixed(2);
306
- return score > 0 && Number(fixed) === 0 ? score.toPrecision(2) : fixed;
307
- }
308
-
309
- /** A `usage` {@link LoreError} (exit `2`) with an actionable hint. */
310
- function usage(message: string, hint: string): LoreError {
311
- return new LoreError("usage", message, hint);
312
- }
@@ -1,280 +0,0 @@
1
- /**
2
- * reconcile-shared.ts — resolve every `tasks:`-linked concept's live Backlog data and compute its
3
- * reconciled status + managed-block rows, shared by `lore sync` (LORE-26, writes the result) and
4
- * `lore check` (LORE-27, diffs it against disk and never writes — ADR-0007).
5
- *
6
- * Both commands need the *identical* gather: skip reserved-stem concepts (`index`/`log`, regenerated
7
- * wholesale elsewhere), read and validate the project's status flow/overrides **before** spending any
8
- * Backlog subprocess round-trip (so a broken `.lore/config.toml` is reported immediately rather than
9
- * masked behind N task resolutions — sync's own fail-fast property, LORE-26 round 4), resolve every
10
- * distinct linked task id once, and run `core/reconcile.ts`'s `reconcileStatus` per concept. Only what
11
- * happens with the result differs, so that part stays with each command.
12
- */
13
-
14
- import { posix } from "node:path";
15
- import { type BacklogAdapter, type BacklogTaskDetail, readStatusFlow } from "../adapters/backlog";
16
- import { loadConfig } from "../config";
17
- import { toRefList } from "../core/bundle";
18
- import type { Concept } from "../core/concept";
19
- import type { ManagedTaskRow } from "../core/managed-block";
20
- import { type ReconciledStatus, reconcileStatus, validateReconcileInputs } from "../core/reconcile";
21
- import { RESERVED_STEMS } from "../core/scaffold";
22
- import { LoreError } from "../errors";
23
- // `mapWithConcurrency`/`TASK_DETAILS_CONCURRENCY` moved to the neutral `./concurrency` module
24
- // (LORE-233) so `link.ts` can share them too without a `link -> reconcile-shared -> link` import
25
- // cycle (this file already imports `dedupeTaskIds`/`defaultAdapter`/`verifiedViewTask` FROM
26
- // `link.ts`). Re-exported here so `check.ts` and `reconcile-shared.test.ts`, which import both
27
- // from this module, keep working unchanged.
28
- import { mapWithConcurrency, TASK_DETAILS_CONCURRENCY } from "./concurrency";
29
- import { dedupeTaskIds, defaultAdapter, verifiedViewTask } from "./link";
30
-
31
- export { mapWithConcurrency, TASK_DETAILS_CONCURRENCY };
32
-
33
- /** The outcome of resolving one task id: its live detail, or the error resolving it hit. */
34
- export type TaskResolution =
35
- | { readonly ok: true; readonly detail: BacklogTaskDetail }
36
- | { readonly ok: false; readonly error: unknown };
37
-
38
- /** One concept's resolved reconciliation: its recomputed status and live managed-block rows. */
39
- export interface ReconcileTarget {
40
- /** The concept as loaded (unmodified) — its `frontmatter.status` is the pre-reconciliation value. */
41
- readonly concept: Concept;
42
- /** The rolled-up status (`core/reconcile.ts`), or `null` when the concept links no tasks (never true here). */
43
- readonly newStatus: ReconciledStatus | null;
44
- /** The linked tasks' live data, in the concept's own `tasks:` order, ready for `regenerateTaskBlock`. */
45
- readonly rows: ManagedTaskRow[];
46
- }
47
-
48
- /** One concept eligible for reconciliation: not a reserved stem, and linking at least one task. */
49
- export interface EligibleConcept {
50
- readonly concept: Concept;
51
- /** Its `tasks:` frontmatter, deduplicated (case-insensitively) but otherwise in authored order. */
52
- readonly linked: string[];
53
- }
54
-
55
- /**
56
- * Filter `concepts` to those eligible for reconciliation: not a reserved stem (`index`/`log` —
57
- * regenerated wholesale elsewhere, so a reconciled write here would only be silently discarded,
58
- * mirroring `link`/`rename`/`supersede`'s shared `assertNotReservedStem` policy) and linking at
59
- * least one task. Pure and synchronous — callers use it to decide *whether* reconciliation is
60
- * needed at all (e.g. `lore check` deciding sync-vs-async) without paying for any Backlog IO.
61
- *
62
- * Deliberately type-agnostic: a `tasks:` list is reconciled on ANY concept type, not only
63
- * `Story`/`Spec` — `lore link` (LORE-24) never restricts which type it targets, and this predates
64
- * LORE-27 entirely (`tasks:` on a `Reference`/`ADR`/etc. is an OKF §9-tolerated unknown-key warning,
65
- * never an error). Narrowing eligibility to specific types here would make `check` (which shares
66
- * this exact function) more restrictive than `sync` — the disagreement-with-`sync` failure mode
67
- * this module's callers otherwise take pains to avoid — not fix a LORE-27-introduced gap.
68
- */
69
- export function linkedConcepts(concepts: Iterable<Concept>): EligibleConcept[] {
70
- const eligible: EligibleConcept[] = [];
71
- for (const concept of concepts) {
72
- if (RESERVED_STEMS.has(posix.basename(concept.id))) {
73
- continue;
74
- }
75
- const linked = dedupeTaskIds(toRefList(concept.frontmatter.tasks));
76
- if (linked.length > 0) {
77
- eligible.push({ concept, linked });
78
- }
79
- }
80
- return eligible;
81
- }
82
-
83
- /** The project's reconciliation config: its ordered status flow and `[reconcile.overrides]`. */
84
- export interface ReconcileConfig {
85
- readonly flow: readonly string[];
86
- readonly overrides: Readonly<Record<string, string>>;
87
- }
88
-
89
- /**
90
- * Read (but do not validate) `backlog/config.yml`'s status flow and `.lore/config.toml`'s
91
- * `[reconcile.overrides]` — pure IO, no semantic check, no Backlog subprocess round-trip. Split out
92
- * from {@link resolveReconcileConfig} so a caller with its OWN precedence to preserve (`lore sync`,
93
- * against `.lore/profile.toml`) can interleave the syntactic reads, the semantic validation, and
94
- * whatever else it needs in the exact order its own contract requires, rather than this module
95
- * silently deciding that order.
96
- */
97
- export function readReconcileConfig(root: string): ReconcileConfig {
98
- const flow = readStatusFlow(root);
99
- const config = loadConfig({ root });
100
- return { flow, overrides: config.reconcile.overrides };
101
- }
102
-
103
- /**
104
- * {@link readReconcileConfig} plus its semantic validation — the fast, local, IO-only half of
105
- * reconciliation, with no Backlog subprocess round-trip. This is what {@link gatherReconciliation}
106
- * uses by default; a caller that already resolved (and validated) its own config up front (`lore
107
- * sync`, see {@link readReconcileConfig}'s doc comment) passes it straight to `gatherReconciliation`
108
- * instead, so the files are never read/validated twice in the same command run.
109
- *
110
- * @throws LoreError `validation` if the status flow has fewer than two entries, a duplicate entry,
111
- * or an override's target is not a valid rollup status.
112
- */
113
- export function resolveReconcileConfig(root: string): ReconcileConfig {
114
- const resolved = readReconcileConfig(root);
115
- validateReconcileInputs(resolved.flow, resolved.overrides);
116
- return resolved;
117
- }
118
-
119
- /**
120
- * Resolve every `tasks:`-linked, non-reserved-stem concept in `concepts` to a {@link ReconcileTarget}.
121
- * Returns `[]` (constructing no adapter at all, mirroring `rename.ts`'s precedent) when nothing in
122
- * `concepts` links a task — so a bundle with no Story/Spec coupling never shells out to Backlog.
123
- *
124
- * @param root the repo root `backlog/config.yml` / `.lore/config.toml` / the Backlog adapter resolve
125
- * against — independent of whichever docs bundle root `concepts` was loaded from.
126
- * @param concepts the concepts to consider (already loaded/scoped by the caller).
127
- * @param adapterOverride test seam; defaults to {@link defaultAdapter}.
128
- * @param configOverride an already-resolved-and-validated {@link ReconcileConfig} (`lore sync` passes
129
- * its own, having already called {@link resolveReconcileConfig} itself for ordering reasons); when
130
- * omitted, resolved (and validated) here.
131
- * @param detailsOverride an already-resolved {@link TaskResolution} map, keyed by lowercase task id,
132
- * covering (at least) every id `concepts` links — `lore check`'s multi-root drift pass (LORE-50)
133
- * resolves the union of every bundle root's linked ids exactly once and passes the shared result
134
- * to each root's own `gatherReconciliation` call, rather than each root re-fetching the same id.
135
- * When omitted, every id is resolved fresh via `adapterOverride`/`defaultAdapter`, as before.
136
- * @param configErrorOverride a config-validation failure another caller already hit resolving the
137
- * SAME config this call would otherwise read fresh (LORE-50: `lore check`'s multi-root pass
138
- * resolves the config once, up front, for every bundle root that has any eligible concept). When
139
- * set (and `configOverride` is not), thrown immediately in place of re-reading/re-validating —
140
- * preserving the exact fail-fast point a fresh {@link resolveReconcileConfig} call would have hit
141
- * for THIS call's own eligible concepts, without a second disk read. Ignored when `eligible` is
142
- * empty (mirrors `resolveReconcileConfig` never running for a concept list with nothing to
143
- * reconcile) or when `configOverride` is given (a caller with a genuinely resolved config, like
144
- * `lore sync`, never also carries a cached failure).
145
- * @throws LoreError `validation` if the status flow/overrides are malformed (before any task
146
- * resolution); `not_found` (exit 3) naming the first linked task id that no longer exists, OR
147
- * whose resolved detail's `id` does not case-insensitively match the requested id.
148
- */
149
- export async function gatherReconciliation(
150
- root: string,
151
- concepts: Iterable<Concept>,
152
- adapterOverride?: BacklogAdapter,
153
- configOverride?: ReconcileConfig,
154
- detailsOverride?: ReadonlyMap<string, TaskResolution>,
155
- configErrorOverride?: unknown,
156
- ): Promise<ReconcileTarget[]> {
157
- const eligible = linkedConcepts(concepts);
158
- if (eligible.length === 0) {
159
- return [];
160
- }
161
-
162
- if (configOverride === undefined && configErrorOverride !== undefined) {
163
- throw configErrorOverride;
164
- }
165
- const { flow, overrides } = configOverride ?? resolveReconcileConfig(root);
166
-
167
- const allTaskIds = dedupeTaskIds(eligible.flatMap((e) => e.linked));
168
- const details = detailsOverride
169
- ? pickResolved(detailsOverride, allTaskIds)
170
- : await resolveAllTasks(adapterOverride ?? defaultAdapter(root), allTaskIds);
171
-
172
- return eligible.map(({ concept, linked }) => {
173
- const detailList = linked.map((id) => details.get(id.toLowerCase()) as BacklogTaskDetail);
174
- const newStatus = reconcileStatus(
175
- detailList.map((d) => d.status),
176
- flow,
177
- overrides,
178
- );
179
- const rows: ManagedTaskRow[] = detailList.map((d) => ({
180
- id: d.id,
181
- title: d.title,
182
- status: d.status,
183
- file: d.file,
184
- }));
185
- return { concept, newStatus, rows };
186
- });
187
- }
188
-
189
- /**
190
- * Resolve every task id to a {@link TaskResolution}, keyed by lowercase id — a not-found, an
191
- * identity-mismatched, or a rejected `viewTask` all become an `ok: false` entry rather than a
192
- * thrown error, so a caller resolving ids shared across several independent groups (bundle roots,
193
- * in `lore check`'s multi-root drift pass) can fetch each distinct id exactly once and let EACH
194
- * group decide for itself whether ids IT needs failed, instead of one failing id aborting every
195
- * group's resolution. Reads run concurrently, bounded to {@link TASK_DETAILS_CONCURRENCY} in
196
- * flight at once (via {@link mapWithConcurrency}); this never throws.
197
- *
198
- * Every resolved detail's own `id` is checked (case-insensitively) against the requested `taskId`
199
- * via `link.ts`'s shared {@link verifiedViewTask} — a mismatch is treated exactly like a not-found
200
- * rather than stored as `ok: true` (it throws a `LoreError`, caught below and stored verbatim, same
201
- * as any other `viewTask` rejection). Nothing downstream (`gatherReconciliation`'s
202
- * `details.get(id.toLowerCase())` lookup keys purely on the REQUESTED id) would otherwise notice an
203
- * adapter handing back the wrong task's detail, and a mismatch here would silently attribute
204
- * another task's title/status to this concept's managed `tasks:` block. This is the ONE place the
205
- * check lives — `verifiedViewTask` — shared with `link.ts`'s own `viewTask` consumers and
206
- * `tasks.ts`'s `resolveRollup` (LORE-125), so the comparison/`LoreError` never drifts between sites.
207
- */
208
- export async function resolveTaskDetails(
209
- adapter: BacklogAdapter,
210
- taskIds: readonly string[],
211
- ): Promise<Map<string, TaskResolution>> {
212
- const resolved = new Map<string, TaskResolution>();
213
- await mapWithConcurrency(taskIds, TASK_DETAILS_CONCURRENCY, async (taskId) => {
214
- try {
215
- const detail = await verifiedViewTask(adapter, taskId);
216
- if (detail === null) {
217
- resolved.set(taskId.toLowerCase(), {
218
- ok: false,
219
- error: new LoreError(
220
- "not_found",
221
- `task "${taskId}" does not exist`,
222
- "a linked concept's tasks: list must reference only live Backlog tasks — check the id, or unlink it",
223
- { taskId },
224
- ),
225
- });
226
- } else {
227
- resolved.set(taskId.toLowerCase(), { ok: true, detail });
228
- }
229
- } catch (cause) {
230
- resolved.set(taskId.toLowerCase(), {
231
- ok: false,
232
- error: cause instanceof Error ? cause : new Error(String(cause)),
233
- });
234
- }
235
- });
236
- return resolved;
237
- }
238
-
239
- /**
240
- * Resolve every task id to its live {@link BacklogTaskDetail}, keyed by lowercase id. Every id is
241
- * validated to exist BEFORE any concept's status/managed-block is computed — mirrors
242
- * `commands/link.ts`'s up-front validation exactly, including running the reads concurrently
243
- * (`allSettled`) but reporting the first not-found/failure in argument order.
244
- *
245
- * @throws LoreError `not_found` (exit 3) naming the first missing task id, in `taskIds` order.
246
- */
247
- async function resolveAllTasks(
248
- adapter: BacklogAdapter,
249
- taskIds: readonly string[],
250
- ): Promise<Map<string, BacklogTaskDetail>> {
251
- const resolved = await resolveTaskDetails(adapter, taskIds);
252
- return pickResolved(resolved, taskIds);
253
- }
254
-
255
- /**
256
- * Extract `taskIds` from an already-resolved {@link TaskResolution} map, throwing the FIRST (in
257
- * `taskIds` order) unresolved id's own error — the same contract {@link resolveAllTasks} always
258
- * had, now shared by a caller (`lore check`, LORE-50) that resolved the map once, up front, for a
259
- * UNION of ids spanning more than this one `taskIds` list. An id genuinely absent from `resolved`
260
- * (the caller's union omitted one `gatherReconciliation` call actually needs — never expected in
261
- * practice, since every caller derives its union from the same eligible concepts) fails loud rather
262
- * than silently treating it as resolved.
263
- */
264
- function pickResolved(
265
- resolved: ReadonlyMap<string, TaskResolution>,
266
- taskIds: readonly string[],
267
- ): Map<string, BacklogTaskDetail> {
268
- const details = new Map<string, BacklogTaskDetail>();
269
- for (const taskId of taskIds) {
270
- const r = resolved.get(taskId.toLowerCase());
271
- if (r === undefined) {
272
- throw new Error(`internal: task "${taskId}" was never resolved`);
273
- }
274
- if (!r.ok) {
275
- throw r.error;
276
- }
277
- details.set(taskId.toLowerCase(), r.detail);
278
- }
279
- return details;
280
- }