@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,371 @@
1
+ /**
2
+ * commands/sync.ts — `lore sync [paths…] [--dry-run] [--no-index]` (LORE-26, cli-surface §sync).
3
+ *
4
+ * The **write** counterpart to `lore check`. For every concept linking Backlog tasks via its
5
+ * `tasks:` frontmatter: resolves each linked task's live status (`BacklogAdapter.viewTask`),
6
+ * recomputes the concept's `status` (`core/reconcile.ts`, honoring `[reconcile.overrides]`) and
7
+ * rewrites it when it changed, and regenerates the `<!-- lore:tasks -->` managed region
8
+ * (`core/managed-block.ts`) from the same live data. Then — unless `--no-index` — regenerates every
9
+ * bundle `index.md` (`core/indexes.ts`) and the git-history-derived `log.md` (`core/log.ts`, via the
10
+ * real `git log`-shelling adapter in `adapters/git.ts`). Every write is a byte-diff against the
11
+ * current on-disk content first, so a clean tree is a true no-op (AC#1) — and, unless `--dry-run`,
12
+ * the whole write set is committed **all-or-nothing** via {@link writeManyAtomicOrRollback}
13
+ * (LORE-120): each file is still written atomically ({@link writeFileAtomic}), but if any write in
14
+ * the set throws partway through, every file already written *in that same run* is rolled back to
15
+ * its pre-run bytes (or removed, if it did not exist before) rather than left in a mixed old/new
16
+ * state. Since this is the one command that can write many files in a single invocation, it is also
17
+ * the one that needs this cross-file guarantee, on top of `writeFileAtomic`'s own per-file one.
18
+ *
19
+
20
+ * **A linked task id that no longer resolves aborts the whole run before any write** (`not_found`,
21
+ * exit 3) — every linked task, across every scoped concept, is resolved up front, mirroring
22
+ * `commands/link.ts`'s "validate before write" precedent: a doc's `status` and managed block must
23
+ * never be computed from a partially-resolved task set.
24
+ *
25
+ * **`lore` is the sole committer of `backlog/`** (ADR-0012, design §2.4): after its own `docs/`
26
+ * writes, `sync` calls `state.ts`'s {@link commitBacklogIfDirty} to commit whatever is currently
27
+ * uncommitted under `backlog/`. This is independent of whether `sync` itself changed anything in
28
+ * `docs/`, and (like every write here) is skipped entirely under `--dry-run`. `link`/`unlink`/
29
+ * `rename` already commit their own touched files via `commitBacklogFiles` right after writing them
30
+ * (LORE-49) — nothing is left pending for `sync` on their account. `sync`'s commit step is a
31
+ * catch-all sweep: it picks up whatever is still dirty under `backlog/` from another source (a
32
+ * human's direct `backlog task edit`, or a prior run's commit that failed).
33
+ *
34
+ * A concept with `tasks:` but no managed-block markers is a fail-loud `validation` error
35
+ * (`core/managed-block.ts`'s own contract, ADR-0008) — `sync` never guesses or writes a partial
36
+ * block. A concept with no `tasks:` at all is never touched, and (mirroring `rename.ts`) no
37
+ * {@link BacklogAdapter} is even constructed unless at least one scoped concept links a task.
38
+ *
39
+ * An on-disk `index.md` whose directory no longer holds any concept — directly or via any
40
+ * descendant, e.g. after a manual `rm`/`mv` outside `lore rename` — is an **orphan**
41
+ * (`core/indexes.ts`'s {@link orphanedIndexPaths}, LORE-150): `generateIndexes` never emits an entry
42
+ * for it (only live directories are regenerated), so unlike every other stale file it is never
43
+ * written. It is still surfaced, distinctly from an "updated" file, in `SyncReport.orphanedIndexes`
44
+ * and the rendered report — left untouched on disk (deleting a hand-authored file is not this
45
+ * command's call to make unprompted) but no longer silently unmentioned.
46
+ */
47
+
48
+ import { dirname, join } from "node:path";
49
+ import type { BacklogAdapter } from "../adapters/backlog";
50
+ import { realGitAdapter, resolveHeadSha } from "../adapters/git";
51
+ import { type BundleGraph, loadBundle } from "../core/bundle";
52
+ import { type Concept, idFromPath, parseConcept, serializeConcept } from "../core/concept";
53
+ import { generateIndexes, orphanedIndexPaths } from "../core/indexes";
54
+ import { buildLog, type GitAdapter, generateLog } from "../core/log";
55
+ import { regenerateTaskBlock } from "../core/managed-block";
56
+ import { loadProfile, type Profile } from "../core/profile";
57
+ import { type ReconciledStatus, validateReconcileInputs } from "../core/reconcile";
58
+ import { DOCS_DIR } from "../core/scaffold";
59
+ import { EXIT_OK, LoreError, readFileIfPresent, WarningCollector, type Writer } from "../errors";
60
+ import { emit, type OutputContext, type Renderable } from "../output";
61
+ import {
62
+ type BacklogCommitResult,
63
+ bunGitSpawn,
64
+ commitBacklogIfDirty,
65
+ type GitSpawn,
66
+ renderBacklogCommitLine,
67
+ } from "../state";
68
+ import { parseCommandArgs } from "./args";
69
+ import { readIndexBytes, readSource } from "./discover";
70
+ import { type AtomicRollbackWrite, assertNoSymlinkInAnyPath, ensureDir, writeManyAtomicOrRollback } from "./fswrite";
71
+ import { gatherReconciliation, linkedConcepts, readReconcileConfig } from "./reconcile-shared";
72
+
73
+ /** The reserved log file name, excluded from concept scanning (mirrors `rename.ts`'s index handling). */
74
+ const LOG_FILE = "log.md";
75
+
76
+ /** Options for {@link runSync}; `root`, the streams, and the adapters/seams are injectable for tests. */
77
+ export interface SyncOptions {
78
+ /** The repo root the `docs/` bundle (and `backlog/`) resolve against. */
79
+ root: string;
80
+ /** The resolved output mode/color (from `output.ts`). */
81
+ output: OutputContext;
82
+ /** The command's normalized positional + flag tokens from Commander. */
83
+ args: readonly string[];
84
+ /** stdout sink; defaults to `process.stdout`. */
85
+ stdout?: Writer;
86
+ /** stderr sink for bundle-load advisories; defaults to `process.stderr`. */
87
+ stderr?: Writer;
88
+ /** The Backlog adapter; defaults to the real `backlog` binary on PATH. Only constructed when at least one scoped concept links a task. */
89
+ adapter?: BacklogAdapter;
90
+ /** The git-history adapter (`core/log.ts`) for `log.md`; defaults to the real `git log`-shelling adapter. */
91
+ gitAdapter?: GitAdapter;
92
+ /** Resolves `HEAD` to a sha (or `null` with no commits yet); defaults to the real `git rev-parse HEAD`. */
93
+ resolveHead?: (root: string) => string | null;
94
+ /** The git-write seam (`state.ts`) for committing `backlog/`; defaults to the real `git` binary. */
95
+ gitSpawn?: GitSpawn;
96
+ }
97
+
98
+ /** The parsed form of `lore sync`'s arguments. */
99
+ interface SyncArgs {
100
+ /** Concept ids/paths to scope reconciliation + managed-block regen to; empty means every concept. Index/log regeneration is always whole-bundle. */
101
+ paths: string[];
102
+ /** `--dry-run`: report what would change, write nothing (docs/ or backlog/). */
103
+ dryRun: boolean;
104
+ /** `--no-index`: skip both index.md and log.md regeneration. */
105
+ noIndex: boolean;
106
+ }
107
+
108
+ /** One written (or, under `--dry-run`, would-be-written) file, for the report. */
109
+ interface ChangedFile {
110
+ /** Repo-relative POSIX path of the file. */
111
+ readonly path: string;
112
+ }
113
+
114
+ /** The `sync.result` payload. */
115
+ export interface SyncReport {
116
+ /** Every `docs/` file that changed (or would change), ascending. */
117
+ readonly files: readonly ChangedFile[];
118
+ /** How many files changed (== `files.length`). */
119
+ readonly filesChanged: number;
120
+ /** The `backlog/` commit outcome — always `{committed: false, files: []}` under `--dry-run`. */
121
+ readonly backlogCommit: BacklogCommitResult;
122
+ /** Whether this was a `--dry-run` (nothing was written). */
123
+ readonly dryRun: boolean;
124
+ /**
125
+ * Repo-relative paths of on-disk `index.md` files whose directory no longer holds any concept,
126
+ * directly or via any descendant (LORE-150) — e.g. after a manual `rm`/`mv` outside `lore rename`.
127
+ * Reported distinctly from `files` (not counted in `filesChanged`): the file is left untouched on
128
+ * disk, since deleting a hand-authored file is not this command's call to make unprompted, but it
129
+ * is no longer silently unmentioned either. Always `[]` when `--no-index` skipped index regen.
130
+ * Ascending.
131
+ */
132
+ readonly orphanedIndexes: readonly string[];
133
+ }
134
+
135
+ /**
136
+ * Run `lore sync`: reconcile every scoped concept's `status` and managed task block from live
137
+ * Backlog data, regenerate `index.md`/`log.md` (unless `--no-index`), write every changed file
138
+ * (unless `--dry-run`), commit any dirty `backlog/` changes, emit the `sync.result`, and return the
139
+ * exit code.
140
+ *
141
+ * @returns `0` on success (a fully clean tree is still `0` — idempotent). Throws (never returns) a
142
+ * `not_found` {@link LoreError} (exit `3`) when a linked task id no longer exists, or `validation`/
143
+ * `drift` (exit `6`) when reconciliation, the managed block, or the `backlog/` commit fails.
144
+ */
145
+ export async function runSync(options: SyncOptions): Promise<number> {
146
+ const parsed = parseSyncArgs(options.args);
147
+ const docsRoot = join(options.root, DOCS_DIR);
148
+ const advisories = new WarningCollector();
149
+ // Loaded unconditionally, before the bundle: loadBundle validates every concept's frontmatter
150
+ // against this profile (LORE-84), and it runs regardless of reconciliation eligibility, so the
151
+ // profile can no longer be deferred to the eligibility-gated block below.
152
+ const profile = loadProfile({ root: options.root });
153
+ const graph = loadBundle(docsRoot, { warnings: advisories, profile });
154
+ advisories.flush({ color: options.output.color, stderr: options.stderr });
155
+
156
+ const scoped = scopeConcepts(graph, parsed.paths);
157
+ // This command's precedence for when MULTIPLE local config sources are simultaneously broken:
158
+ // a malformed .lore/profile.toml now surfaces FIRST, unconditionally — profile loads above,
159
+ // before loadBundle, so its own parse failure throws before docsRoot is even walked (LORE-84
160
+ // superseded the pre-LORE-27 "profile loads only when reconciliation is eligible" precedence:
161
+ // loadBundle needs the same profile for every sync run, eligible or not). backlog/config.yml/
162
+ // .lore/config.toml SYNTAX errors (readReconcileConfig, a plain read — no semantic check yet)
163
+ // surface next, then validateReconcileInputs's SEMANTIC checks (a duplicate flow entry, an
164
+ // invalid override target) — both still conditioned on eligibility (mirrors gatherReconciliation's
165
+ // own check) so a bundle with nothing to reconcile never pays for either. The resolved,
166
+ // already-validated config is then passed straight into gatherReconciliation so it is never
167
+ // read/validated a second time.
168
+ const eligible = linkedConcepts(scoped).length > 0;
169
+ const config = eligible ? readReconcileConfig(options.root) : undefined;
170
+ if (config !== undefined) {
171
+ validateReconcileInputs(config.flow, config.overrides);
172
+ }
173
+ const targets = await gatherReconciliation(options.root, scoped, options.adapter, config);
174
+
175
+ // bundle-relative path -> { before: pre-run bytes (undefined if the file didn't exist), after: new
176
+ // bytes }. `before` is captured here, at diff time, alongside `after` — never re-derived later —
177
+ // so a mid-run rollback (LORE-120) always restores exactly what was actually on disk before this
178
+ // run touched it, not a re-read that could itself race against a concurrent edit.
179
+ const writes = new Map<string, { before: string | undefined; after: string }>();
180
+ for (const { concept, newStatus, rows } of targets) {
181
+ const docPath = `${DOCS_DIR}/${concept.path}`;
182
+ const original = readSource(join(docsRoot, concept.path), docPath);
183
+ // Derived from the FRESHLY re-read `original` bytes, not the stale in-memory `concept` object
184
+ // captured (in `targets`, via `gatherReconciliation`) before the async Backlog round-trip: a
185
+ // concurrent on-disk edit landing on this doc during that round-trip must survive a
186
+ // status-changing sync write, not be silently discarded in favor of a pre-round-trip snapshot
187
+ // (LORE-119).
188
+ const base =
189
+ newStatus !== null && newStatus !== concept.frontmatter.status
190
+ ? withUpdatedStatus(concept.path, original, newStatus, profile)
191
+ : original;
192
+
193
+ const final = regenerateTaskBlock(base, rows, { docPath });
194
+ if (final !== original) {
195
+ writes.set(concept.path, { before: original, after: final });
196
+ }
197
+ }
198
+
199
+ const orphanedIndexes = parsed.noIndex ? [] : regenerateIndexAndLog(options, docsRoot, graph, writes);
200
+
201
+ if (!parsed.dryRun) {
202
+ // Swept as a whole BEFORE any write starts (LORE-93 AC#5): ensureDir's own per-call guard
203
+ // below is reactive — in this loop, it would only refuse once it REACHES a bad target, by
204
+ // which point earlier targets in the same `writes` map may already be on disk. A single
205
+ // preflight over every planned path makes the write either fully proceed or refuse before
206
+ // touching anything.
207
+ assertNoSymlinkInAnyPath(
208
+ options.root,
209
+ [...writes.keys()].map((path) => `${DOCS_DIR}/${path}`),
210
+ );
211
+ // All-or-nothing across the whole set (LORE-120): every parent directory is created up front
212
+ // (mkdir -p is itself idempotent — nothing to roll back there), then the actual byte writes go
213
+ // through writeManyAtomicOrRollback, which undoes every write already applied in this same run
214
+ // if a later one throws, rather than leaving an arbitrary prefix of `writes` committed and the
215
+ // rest not.
216
+ const rollbackWrites: AtomicRollbackWrite[] = [];
217
+ for (const [path, { before, after }] of writes) {
218
+ ensureDir(options.root, dirname(`${DOCS_DIR}/${path}`));
219
+ rollbackWrites.push({ abs: join(docsRoot, path), relPath: `${DOCS_DIR}/${path}`, before, after });
220
+ }
221
+ writeManyAtomicOrRollback(rollbackWrites);
222
+ }
223
+
224
+ let backlogCommit: BacklogCommitResult = { committed: false, files: [] };
225
+ if (!parsed.dryRun) {
226
+ const gitSpawn = options.gitSpawn ?? bunGitSpawn(options.root);
227
+ backlogCommit = await commitBacklogIfDirty(gitSpawn);
228
+ }
229
+
230
+ const files = [...writes.keys()].sort().map((path) => ({ path: `${DOCS_DIR}/${path}` }));
231
+ const report: SyncReport = {
232
+ files,
233
+ filesChanged: files.length,
234
+ backlogCommit,
235
+ dryRun: parsed.dryRun,
236
+ orphanedIndexes: orphanedIndexes.map((path) => `${DOCS_DIR}/${path}`),
237
+ };
238
+ emit(reportRenderable(report), options.output, options.stdout);
239
+ return EXIT_OK;
240
+ }
241
+
242
+ /**
243
+ * Re-parse `raw` — the freshly re-read on-disk bytes for the concept at `path` — and re-serialize it
244
+ * with `status` applied to its frontmatter. Every other frontmatter key and the body come straight
245
+ * from `raw` itself, never from an earlier in-memory snapshot, so a concurrent on-disk edit made to
246
+ * the doc between the initial bundle load and this status-changing write survives it (LORE-119). Only
247
+ * `status` is overwritten — sync's `newStatus` is always the authoritative, live-reconciled value, so
248
+ * a concurrent edit to `status` itself (were one to land) is still resolved to what Backlog reports,
249
+ * exactly as an uncontended sync would resolve it.
250
+ */
251
+ function withUpdatedStatus(path: string, raw: string, status: ReconciledStatus, profile: Profile): string {
252
+ const fresh = parseConcept(path, raw, { profile });
253
+ return serializeConcept({ ...fresh, frontmatter: { ...fresh.frontmatter, status } }, { profile });
254
+ }
255
+
256
+ // ── Index + log regeneration ────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Regenerate every `index.md` and `log.md`, adding an entry to `writes` for each one whose bytes
260
+ * actually changed, and return the bundle-relative paths of any **orphaned** on-disk `index.md`
261
+ * files — one whose directory no longer holds a concept, directly or via any descendant (LORE-150) —
262
+ * so the caller can report them distinctly instead of `generateIndexes` silently never mentioning
263
+ * them (it only ever emits entries for live directories; a stale disk entry outside that set is
264
+ * simply absent from its returned map, indistinguishable from "unchanged" without this comparison).
265
+ * Always whole-bundle, regardless of `[paths…]` scoping — both index regeneration and orphan
266
+ * detection are inherently global (a hub lists its whole directory; the log is derived from all of
267
+ * git history).
268
+ */
269
+ function regenerateIndexAndLog(
270
+ options: SyncOptions,
271
+ docsRoot: string,
272
+ graph: BundleGraph,
273
+ writes: Map<string, { before: string | undefined; after: string }>,
274
+ ): readonly string[] {
275
+ const diskIndexBytes = readIndexBytes(docsRoot);
276
+ const regeneratedIndexes = generateIndexes(graph, { existing: diskIndexBytes });
277
+ for (const [path, bytes] of regeneratedIndexes) {
278
+ const before = diskIndexBytes.get(path);
279
+ if (bytes !== before) {
280
+ writes.set(path, { before, after: bytes });
281
+ }
282
+ }
283
+ const orphaned = orphanedIndexPaths(graph, diskIndexBytes);
284
+
285
+ const resolveHead = options.resolveHead ?? resolveHeadSha;
286
+ const headSha = resolveHead(options.root);
287
+ const logBytes =
288
+ headSha === null
289
+ ? generateLog([], { root: DOCS_DIR })
290
+ : buildLog(options.gitAdapter ?? realGitAdapter(options.root), { to: headSha }, { root: DOCS_DIR });
291
+ const existingLog = readFileIfPresent(join(docsRoot, LOG_FILE), `${DOCS_DIR}/${LOG_FILE}`);
292
+ if (logBytes !== existingLog) {
293
+ writes.set(LOG_FILE, { before: existingLog, after: logBytes });
294
+ }
295
+ return orphaned;
296
+ }
297
+
298
+ // ── Scoping ────────────────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Filter `graph`'s concepts to those under one of `paths` (each resolved to a concept id via
302
+ * {@link idFromPath}, matched as an exact id or a directory prefix); an empty `paths` scopes to
303
+ * every concept. Index/log regeneration is never scoped this way (see {@link regenerateIndexAndLog}).
304
+ */
305
+ function scopeConcepts(graph: BundleGraph, paths: readonly string[]): Concept[] {
306
+ const all = [...graph.concepts.values()];
307
+ if (paths.length === 0) {
308
+ return all;
309
+ }
310
+ // Strip a trailing slash before deriving the id: idFromPath() preserves it verbatim, and a
311
+ // trailing "/" (natural from shell tab-completion of a directory) would otherwise never match
312
+ // any concept id (`c.id === "stories/foo/"` and `c.id.startsWith("stories/foo//")` are both
313
+ // always false), silently scoping to nothing rather than the whole "stories/foo" directory.
314
+ const prefixes = paths.map((p) => idFromPath(p.replace(/\/+$/, "")));
315
+ for (const prefix of prefixes) {
316
+ if (!all.some((c) => matchesScope(c.id, prefix))) {
317
+ // A path/id that matches nothing (a typo, a trailing slash that still resolves to no
318
+ // directory, a concept that was never linked) is a fail-loud usage error, not a silent
319
+ // empty scope — matching link/unlink/rename's `conceptNotInBundle` precedent: a `lore sync`
320
+ // that quietly reconciled zero concepts and reported "0 files changed" would read as "already
321
+ // in sync" when it never looked at the intended target at all.
322
+ throw new LoreError(
323
+ "not_found",
324
+ `no concept found at or under "${prefix}"`,
325
+ "check the id/path and try again — run `lore query` or `lore graph` to see known concept ids",
326
+ { path: prefix },
327
+ );
328
+ }
329
+ }
330
+ return all.filter((c) => prefixes.some((prefix) => matchesScope(c.id, prefix)));
331
+ }
332
+
333
+ /** Whether `id` is exactly `prefix` or lives under it as a directory prefix. */
334
+ function matchesScope(id: string, prefix: string): boolean {
335
+ return id === prefix || id.startsWith(`${prefix}/`);
336
+ }
337
+
338
+ // ── Argument parsing ───────────────────────────────────────────────────────────
339
+
340
+ /** Parse `sync`'s tokens into `[paths…]`, `--dry-run`, and `--no-index` via the shared parser. */
341
+ function parseSyncArgs(args: readonly string[]): SyncArgs {
342
+ const { positionals, flags } = parseCommandArgs(args, "sync");
343
+ return { paths: positionals, dryRun: flags.has("dry-run"), noIndex: flags.has("no-index") };
344
+ }
345
+
346
+ // ── Output ─────────────────────────────────────────────────────────────────────
347
+
348
+ /** The per-result-type rendering bundle for `sync` (output.ts dispatches on the mode). */
349
+ function reportRenderable(data: SyncReport): Renderable<SyncReport> {
350
+ return { kind: "sync.result", data, pretty: render, plain: render };
351
+ }
352
+
353
+ /**
354
+ * One line per changed file, one line per orphaned index (distinct from "updated": the file is
355
+ * reported but not written, LORE-150), the backlog-commit outcome (if any), then a summary line.
356
+ * (No color: no severities.)
357
+ */
358
+ function render(data: SyncReport): string {
359
+ const verb = data.dryRun ? "would update" : "updated";
360
+ const lines = data.files.map((f) => `${verb} ${f.path}`);
361
+ for (const path of data.orphanedIndexes) {
362
+ lines.push(`orphaned index ${path} (no concepts remain under this directory; left untouched)`);
363
+ }
364
+ const commitLine = renderBacklogCommitLine(data.backlogCommit);
365
+ if (commitLine !== undefined) {
366
+ lines.push(commitLine);
367
+ }
368
+ const noun = data.filesChanged === 1 ? "file" : "files";
369
+ lines.push(`${data.filesChanged} ${noun} changed${data.dryRun ? " (dry-run)" : ""}`);
370
+ return lines.join("\n");
371
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * commands/tasks.ts — `lore tasks <id> [--status <S>]` (cli-surface §tasks).
3
+ *
4
+ * The thin, read-only view of a concept's **live** Backlog task rollup: it loads the
5
+ * `docs/` bundle, resolves the target concept by id, reads that concept's `tasks:`
6
+ * frontmatter list, and fetches each linked task's CURRENT state from the Backlog
7
+ * JSON adapter — the same live data `lore sync` materializes into a concept's managed
8
+ * `<!-- lore:tasks -->` block, but rendered directly here and written nowhere.
9
+ *
10
+ * Output follows the uniform CLI modes: the `{schemaVersion, kind: "tasks.rollup",
11
+ * data}` envelope under `--json` — `data` an object `{ concept, status?, tasks }` whose
12
+ * `tasks` are `{id, title, status}` rows in the concept's own `tasks:` order (wrapped in
13
+ * an object, like every sibling list command, so the contract can grow additively) —
14
+ * and otherwise an aligned text table. `--status <S>` filters the rows to one Backlog
15
+ * status (case-insensitive match on the configured label) and is echoed back on `status`.
16
+ *
17
+ * Failure modes, in the order they can occur:
18
+ * - a bad flag / missing-or-duplicate `<id>` is a `usage` error (exit 2);
19
+ * - an `<id>` absent from the bundle is a `not_found` error (exit 3);
20
+ * - Backlog being unavailable is fail-fast: the adapter's capability probe runs UP FRONT,
21
+ * so a missing binary (exit 3) or a stock, non-`--json` binary (exit 6) is reported
22
+ * before any per-task read. That ordering is what lets a `viewTask` null AFTER a passing
23
+ * probe mean, unambiguously, "this linked id is a dangling reference" — dropped from the
24
+ * rollup with a stderr advisory, exit 0 — rather than "Backlog is broken". `orphans`
25
+ * (LORE-32) is the dedicated dangling-link report; `tasks` only notes them so its rollup
26
+ * shows just the live tasks. A per-task read that *fails* (Backlog drift) still propagates;
27
+ * a per-task read that *succeeds* but answers with a DIFFERENT task's id than requested is
28
+ * also a hard `not_found` error (exit 3), never silently attributed to the requested row —
29
+ * via the same shared guard `commands/link.ts`'s `verifiedViewTask` exports (LORE-183; this
30
+ * module no longer hand-maintains its own copy, LORE-125) — the SAME guard `reconcile-shared.ts`'s
31
+ * `resolveTaskDetails` (originally LORE-122) now also delegates to (LORE-183), so the
32
+ * comparison/`LoreError` lives in exactly one place across `link.ts`/`tasks.ts`/`reconcile-shared.ts`.
33
+ */
34
+
35
+ import { join } from "node:path";
36
+ import type { BacklogAdapter } from "../adapters/backlog";
37
+ import { conceptNotInBundle, loadBundle, toRefList } from "../core/bundle";
38
+ import { idFromPath } from "../core/concept";
39
+ import { loadProfile } from "../core/profile";
40
+ import { DOCS_DIR } from "../core/scaffold";
41
+ import { ANSI, EXIT_OK, paint, WarningCollector, type Writer } from "../errors";
42
+ import { emit, type OutputContext, type Renderable, renderTaskSummaryRows, type TaskSummaryRow } from "../output";
43
+ import { parseCommandArgs, singleOptionValue, usage } from "./args";
44
+ import { mapWithConcurrency, TASK_DETAILS_CONCURRENCY } from "./concurrency";
45
+ import { dedupeTaskIds, defaultAdapter, verifiedViewTask } from "./link";
46
+
47
+ /** Options for {@link runTasks}; `root`, the streams, and the adapter are injectable for tests. */
48
+ export interface TasksOptions {
49
+ /** The repo root the `docs/` bundle and the Backlog adapter resolve 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
+ /** The Backlog adapter; defaults to the real `backlog` binary resolved against `root`. Injected for tests. */
60
+ adapter?: BacklogAdapter;
61
+ }
62
+
63
+ /** The parsed form of `lore tasks`'s arguments. */
64
+ interface TasksArgs {
65
+ /** The target concept id (positional, already {@link idFromPath}-normalized). */
66
+ id: string;
67
+ /** The `--status` filter, when given (case-insensitively matched against each task's live status). */
68
+ status?: string;
69
+ }
70
+
71
+ /** One row of the rollup: a linked task's live identity + current Backlog status, in `tasks:` order. */
72
+ export type TaskRollupRow = TaskSummaryRow;
73
+
74
+ /** The `tasks.rollup` payload: the concept, the applied `--status` filter (when any), and the live rows. */
75
+ export interface TaskRollup {
76
+ /** The resolved concept id whose `tasks:` were rolled up. */
77
+ readonly concept: string;
78
+ /** The applied `--status` filter, canonicalized to the matched rows' Backlog casing (raw input when nothing matched); omitted when no filter was given. */
79
+ readonly status?: string;
80
+ /** The linked tasks' live data, in the concept's own `tasks:` order (dangling ids dropped). */
81
+ readonly tasks: readonly TaskRollupRow[];
82
+ }
83
+
84
+ /**
85
+ * Run `lore tasks`: parse the arguments, load the bundle, resolve the concept, fetch its
86
+ * linked tasks' live status from Backlog, emit the `tasks.rollup`, and return `0`. Async
87
+ * because it drives the Backlog subprocess. See the module docstring for the failure modes.
88
+ */
89
+ export async function runTasks(options: TasksOptions): Promise<number> {
90
+ const parsed = parseTasksArgs(options.args);
91
+ const docsRoot = join(options.root, DOCS_DIR);
92
+ const advisories = new WarningCollector();
93
+ const profile = loadProfile({ root: options.root });
94
+ const graph = loadBundle(docsRoot, { warnings: advisories, profile });
95
+ // Flush load warnings before the not_found throw below, so an advisory explaining *why* a
96
+ // file is not a concept survives on exactly the path that most needs it (mirrors `lore context`).
97
+ advisories.flush({ color: options.output.color, stderr: options.stderr });
98
+
99
+ const concept = graph.concepts.get(parsed.id);
100
+ if (concept === undefined) {
101
+ throw conceptNotInBundle(parsed.id);
102
+ }
103
+
104
+ const linked = dedupeTaskIds(toRefList(concept.frontmatter.tasks));
105
+ const tasks = await resolveRollup(linked, parsed.status, options);
106
+ const status = echoedStatus(parsed.status, tasks);
107
+ const data: TaskRollup = status === undefined ? { concept: parsed.id, tasks } : { concept: parsed.id, status, tasks };
108
+
109
+ emit(tasksRenderable(data), options.output, options.stdout);
110
+ return EXIT_OK;
111
+ }
112
+
113
+ /**
114
+ * The `--status` filter to echo on the envelope. `--status` matches case-insensitively, so echoing the
115
+ * user's raw input verbatim could disagree in case with every row it selected (`--status done` →
116
+ * `status: "done"` beside rows whose status is `"Done"`). Canonicalize it to the matched rows' actual
117
+ * Backlog casing so `data.status` always agrees with the tasks it produced; fall back to the raw filter
118
+ * only when nothing matched (no row to canonicalize against). `undefined` when no filter was given.
119
+ */
120
+ function echoedStatus(filter: string | undefined, tasks: readonly TaskRollupRow[]): string | undefined {
121
+ if (filter === undefined) {
122
+ return undefined;
123
+ }
124
+ return tasks[0]?.status ?? filter;
125
+ }
126
+
127
+ /**
128
+ * Resolve every linked task id to its live {@link TaskRollupRow}, filtered to `status` when given.
129
+ * Returns `[]` without touching Backlog when the concept links nothing (mirrors
130
+ * `gatherReconciliation` constructing no adapter for an uncoupled concept).
131
+ *
132
+ * The Backlog capability probe runs UP FRONT so a missing/incapable binary fails fast (exit 3/6)
133
+ * before any per-task read — the disambiguation the module docstring relies on. Reads then run
134
+ * concurrently, bounded to {@link TASK_DETAILS_CONCURRENCY} in flight at once via the shared
135
+ * {@link mapWithConcurrency} worker-pool (mirroring `resolveTaskDetails`'s own LORE-111 fix, so a
136
+ * concept whose `tasks:` list links many ids never fans out one Backlog CLI subprocess per id fully
137
+ * concurrently). Each read's outcome is captured into a settled-result array, in `tasks:` order, so a
138
+ * second failing read never escapes as an unhandled rejection: the FIRST read that *fails* — in
139
+ * `tasks:` order — is rethrown as a hard error (Backlog drift), exactly as `lore check`/`sync` treat
140
+ * it, while a clean `null` — a task Backlog does not know — is soft: dropped from the rollup with a
141
+ * stderr advisory (`orphans` owns the dangling-link report).
142
+ *
143
+ * Each read goes through `commands/link.ts`'s exported {@link verifiedViewTask} (LORE-183), not a
144
+ * raw `adapter.viewTask` call: it checks the returned detail's own `id` against the id actually
145
+ * requested at that position (case-insensitively) and throws before this function ever sees a
146
+ * mismatched detail — `viewTask` is keyed by id, but nothing enforces that the detail it returns
147
+ * actually carries that id back, and trusting it blindly would let a mismatch silently attribute
148
+ * another task's title/status to this row. That thrown `LoreError` surfaces here as an ordinary
149
+ * rejected settle, handled identically to any other failed read (a hard `not_found` failure,
150
+ * exactly as before LORE-125 folded this module's own copy of the check into the shared helper).
151
+ * The `--status` filter is applied last, to the live rows only.
152
+ */
153
+ async function resolveRollup(
154
+ linked: readonly string[],
155
+ status: string | undefined,
156
+ options: TasksOptions,
157
+ ): Promise<TaskRollupRow[]> {
158
+ if (linked.length === 0) {
159
+ return [];
160
+ }
161
+ const adapter = options.adapter ?? defaultAdapter(options.root);
162
+ await adapter.probe();
163
+
164
+ const settled: PromiseSettledResult<Awaited<ReturnType<typeof verifiedViewTask>>>[] = new Array(linked.length);
165
+ await mapWithConcurrency(
166
+ linked.map((id, index) => ({ id, index })),
167
+ TASK_DETAILS_CONCURRENCY,
168
+ async ({ id, index }) => {
169
+ try {
170
+ const value = await verifiedViewTask(adapter, id);
171
+ settled[index] = { status: "fulfilled", value };
172
+ } catch (reason) {
173
+ settled[index] = { status: "rejected", reason };
174
+ }
175
+ },
176
+ );
177
+ const dangling: string[] = [];
178
+ const rows: TaskRollupRow[] = [];
179
+ for (let i = 0; i < linked.length; i++) {
180
+ const result = settled[i] as PromiseSettledResult<Awaited<ReturnType<typeof verifiedViewTask>>>;
181
+ if (result.status === "rejected") {
182
+ // A read that FAILED — Backlog drift, or `verifiedViewTask`'s own id-mismatch guard refusing
183
+ // an adapter detail that doesn't belong to the requested task — is hard-failed on the first,
184
+ // in tasks: order, before any partial rollup or advisory is emitted.
185
+ throw result.reason;
186
+ }
187
+ if (result.value === null) {
188
+ dangling.push(linked[i] as string);
189
+ continue;
190
+ }
191
+ rows.push({ id: result.value.id, title: result.value.title, status: result.value.status });
192
+ }
193
+ if (dangling.length > 0) {
194
+ warnDangling(dangling, options);
195
+ }
196
+
197
+ return status === undefined ? rows : rows.filter((row) => row.status.toLowerCase() === status.toLowerCase());
198
+ }
199
+
200
+ /**
201
+ * Advise (on stderr) that one or more `tasks:` ids no longer resolve to a live Backlog task and were
202
+ * dropped from the rollup — a coupling gap worth surfacing without failing the read (`lore orphans`
203
+ * is the CI-grade report). Routed through the shared {@link WarningCollector} so it renders with the
204
+ * exact `warning:`-prefix format and coloring as this command's load advisories and every other lore
205
+ * warning, rather than a hand-painted line.
206
+ */
207
+ function warnDangling(ids: readonly string[], options: TasksOptions): void {
208
+ const noun = ids.length === 1 ? "task" : "tasks";
209
+ const advisories = new WarningCollector();
210
+ advisories.add(`${ids.length} linked ${noun} not in Backlog, dropped from the rollup: ${ids.join(", ")}`);
211
+ advisories.flush({ color: options.output.color, stderr: options.stderr });
212
+ }
213
+
214
+ // ── Argument parsing ───────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Parse `tasks`'s tokens into the required `<id>` positional and the value flag `--status <S>` (also
218
+ * accepting the `--status=value` form). Commander has already resolved Lore's global flags, so a
219
+ * `--`-prefixed token here is a command flag: an unrecognized one, a repeated/value-less `--status`, a
220
+ * missing `<id>`, or a second positional is a `usage` error (exit 2). A `--` ends option parsing. The
221
+ * `<id>` is {@link idFromPath}-normalized so path/`.md`/`./` forms resolve (mirrors `lore context`).
222
+ */
223
+ function parseTasksArgs(args: readonly string[]): TasksArgs {
224
+ const parsed = parseCommandArgs(args, "tasks");
225
+ const positionals = parsed.positionals;
226
+ const status = singleOptionValue(parsed, "status");
227
+ if (status === "") {
228
+ throw usage("--status needs a value", 'pass a value, e.g. `--status "In Progress"`');
229
+ }
230
+ if (positionals.length === 0) {
231
+ throw usage(
232
+ "`lore tasks` needs a concept id",
233
+ "give the concept whose task rollup to show, e.g. `lore tasks stories/x`",
234
+ );
235
+ }
236
+ if (positionals.length > 1) {
237
+ throw usage(`unexpected argument "${positionals[1]}"`, "run `lore tasks <id> [--status <S>]`");
238
+ }
239
+ return { id: idFromPath(positionals[0] as string), status };
240
+ }
241
+
242
+ // ── Output ─────────────────────────────────────────────────────────────────────
243
+
244
+ /**
245
+ * The rendering bundle for `tasks` (output.ts dispatches on the mode). `--json` carries the structured
246
+ * {@link TaskRollup}; the pretty/plain text is an aligned table. The two text modes differ only in the
247
+ * painted header, so they share one renderer.
248
+ */
249
+ function tasksRenderable(data: TaskRollup): Renderable<TaskRollup> {
250
+ return {
251
+ kind: "tasks.rollup",
252
+ data,
253
+ pretty: (d, opts) => renderTable(d, opts.color),
254
+ plain: (d) => renderTable(d, false),
255
+ };
256
+ }
257
+
258
+ /**
259
+ * A human/pipe-stable rollup: a header naming the concept, its live task count, and the `--status`
260
+ * filter (when any), then one ` <id> <status> <title>` row per live task (columns padded to align).
261
+ * An empty rollup renders a single explanatory line. ANSI only on the header, and only when `color`.
262
+ */
263
+ function renderTable(data: TaskRollup, color: boolean): string {
264
+ const filter = data.status !== undefined ? `, status "${data.status}"` : "";
265
+ const noun = data.tasks.length === 1 ? "task" : "tasks";
266
+ const header = paint(`tasks: ${data.concept} — ${data.tasks.length} ${noun}${filter}`, ANSI.green, color);
267
+ if (data.tasks.length === 0) {
268
+ return `${header}\n${data.status !== undefined ? "(no linked tasks match that status)" : "(no linked tasks)"}`;
269
+ }
270
+ return [header, ...renderTaskSummaryRows(data.tasks)].join("\n");
271
+ }