@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,1084 +0,0 @@
1
- /**
2
- * adapters/backlog.ts — the **only** place a `backlog` subprocess is spawned (design spec §2.3, §8).
3
- *
4
- * This is the Backlog subprocess seam: the second of lore's three injectable determinism seams
5
- * (the clock and the git history seam are the others; lore-design §8). Everything that shells out to
6
- * the `backlog` binary flows through the {@link BacklogSpawn} interface here, so unit and golden tests
7
- * inject a fake that returns fixed output instead of driving a real subprocess.
8
- *
9
- * **Scope.** This file ships the capability probe (LORE-4) and the full typed read/write adapter
10
- * (`task list`/`view`/`search` parsing, status mapping, the `doc:<id>` back-reference — LORE-21).
11
- * Both target the same contract: upstream's (MrLesk/Backlog.md) independently-shipped `--json`
12
- * implementation (PR #790, BACK-545), adopted in place of upstreaming the jeremy-newhouse/Backlog.md
13
- * fork this file originally shipped against (LORE-5). The migration ran in two steps — LORE-53
14
- * migrated the probe alone (`probeBacklog`), LORE-54 migrated the rest (`EnvelopeSchema`,
15
- * `parseEnvelope`, and the typed read functions) — so the probe and the full adapter were briefly on
16
- * two different contracts; both now target upstream's real, per-command envelope shape: numeric
17
- * `schemaVersion: 1`, hyphenated `kind` (`"task-list"` / `"task-view"` / `"search"`), and a
18
- * per-command payload key (`tasks` / `task` / `results`), not a shared `data` key.
19
- *
20
- * Normative contract: docs/reference/backlog-cli-contract.md §5 (capability probe) and
21
- * docs/reference/backlog-json-schema.md (the schema of record, §1–§7 now describing upstream's shape
22
- * directly).
23
- *
24
- * This file also reads the project's ordered status flow directly from `backlog/config.yml`'s own
25
- * `statuses:` key (LORE-26) — plain repo-committed YAML, not a `--json` envelope, so it is a direct
26
- * file read rather than a spawn; see {@link readStatusFlow} at the bottom of this file.
27
- */
28
-
29
- import { join } from "node:path";
30
- import * as yaml from "js-yaml";
31
- import { z } from "zod";
32
- import { deriveMessage, errnoCode, LoreError, readFileIfPresent, stderrHint } from "../errors";
33
-
34
- /**
35
- * The **binary version floor** the probe requires (`backlog --version`, contract §5 step 3). A sanity
36
- * floor only — it does not by itself distinguish a `--json`-capable binary from plain stock, since a
37
- * pre-`--json` stock release can still report a version at or above this floor. The `--json` envelope
38
- * parse (step 3 below) is the real discriminator — a binary without `--json` support rejects the option
39
- * and exits non-zero.
40
- *
41
- * `1.49.0` is upstream's first **tagged release** whose history contains commit 22a091b (PR #790 /
42
- * BACK-545, stable `--json` output), published 2026-08-02. Before this floor moved here from the
43
- * `1.47.1` fork floor, `lore` had no tagged release to depend on and consumed a locally-built binary
44
- * pinned at that commit (LCLI-253; docs/runbooks/backlog-json-patch.md §8.1) — that interim build is
45
- * retired now that a real release exists.
46
- */
47
- export const MIN_BACKLOG_VERSION = "1.49.0";
48
-
49
- /**
50
- * The `schemaVersion` every `--json` envelope carries — upstream's real envelope, a **number**
51
- * (`1`), shared by the capability probe and the full read adapter now that both target the same
52
- * contract (LORE-53 migrated the probe alone first; LORE-54 migrated the rest and retired the
53
- * probe-only `PROBE_SCHEMA_VERSION` split).
54
- */
55
- export const EXPECTED_SCHEMA_VERSION = 1;
56
-
57
- /** The `kind` a `backlog task list --json` envelope carries. Shared by the probe and `listTasks`. */
58
- const TASK_LIST_KIND = "task-list";
59
- /** The `kind` a `backlog task view <id> --json` (or bare `task <id> --json`) envelope carries. */
60
- const TASK_VIEW_KIND = "task-view";
61
- /** The `kind` a `backlog search --json` envelope carries. */
62
- const SEARCH_KIND = "search";
63
-
64
- /** The default binary name resolved from PATH. */
65
- const BACKLOG_BINARY = "backlog";
66
-
67
- /**
68
- * The environment variable an operator overrides {@link DEFAULT_BACKLOG_TIMEOUT_MS} with (LORE-217
69
- * AC #2). Exported so a test can target the exact name {@link bunBacklogSpawn} reads rather than
70
- * duplicating the string literal.
71
- */
72
- export const BACKLOG_TIMEOUT_ENV_VAR = "LORE_BACKLOG_TIMEOUT_MS";
73
-
74
- /**
75
- * The default wall-clock bound, in milliseconds, on one real `backlog` subprocess invocation
76
- * (LORE-217 AC #2) — generous enough that a legitimately slow `backlog task list --json` on a large
77
- * project never trips it in normal use, while still recovering a wedged/non-terminating process
78
- * well within a human agent's patience rather than hanging lore forever.
79
- */
80
- export const DEFAULT_BACKLOG_TIMEOUT_MS = 30_000;
81
-
82
- /**
83
- * Resolve the timeout bound {@link bunBacklogSpawn} enforces, re-read from
84
- * {@link BACKLOG_TIMEOUT_ENV_VAR} on every call (not cached at import time) so an operator — or a
85
- * test — can change it between invocations. Unset, blank, non-numeric, or non-positive falls back to
86
- * {@link DEFAULT_BACKLOG_TIMEOUT_MS} rather than silently disabling the guard.
87
- */
88
- function resolveBacklogTimeoutMs(): number {
89
- const raw = process.env[BACKLOG_TIMEOUT_ENV_VAR];
90
- if (raw === undefined || raw.trim() === "") {
91
- return DEFAULT_BACKLOG_TIMEOUT_MS;
92
- }
93
- const parsed = Number(raw);
94
- return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_BACKLOG_TIMEOUT_MS;
95
- }
96
-
97
- /**
98
- * Build the fail-loud "the real `backlog` subprocess did not exit in time" error (`validation`, exit
99
- * 6) — LORE-217 AC #1. Mirrors the wording style of this file's other environment-diagnosis errors
100
- * ({@link notJsonCapable}, `configError`): a single-line message plus an actionable hint naming the
101
- * override knob.
102
- */
103
- function timeoutError(binary: string, args: readonly string[], timeoutMs: number): LoreError {
104
- return new LoreError(
105
- "validation",
106
- `\`${binary} ${args.join(" ")}\` did not exit within ${timeoutMs}ms and was killed`,
107
- `the \`backlog\` process appears wedged; if this is a legitimately slow invocation, raise ${BACKLOG_TIMEOUT_ENV_VAR} (currently ${timeoutMs}ms) — otherwise investigate why \`${binary}\` hung`,
108
- { binary, args: [...args], timeoutMs },
109
- );
110
- }
111
-
112
- /**
113
- * The result of one `backlog` invocation as the {@link BacklogSpawn} seam surfaces it. The minimal,
114
- * deterministic projection the probe needs: an exit code plus captured streams. A fake returns a fixed
115
- * one; the real {@link bunBacklogSpawn} builds it from `Bun.spawn`.
116
- */
117
- export interface SpawnResult {
118
- /** The process exit code (`0` on success). */
119
- readonly exitCode: number;
120
- /** Everything the process wrote to stdout (the JSON envelope, or the bare `--version` line). */
121
- readonly stdout: string;
122
- /** Everything the process wrote to stderr (human diagnostics; never parsed as data). */
123
- readonly stderr: string;
124
- }
125
-
126
- /**
127
- * The injectable Backlog subprocess seam (design spec §8). Callers hand the probe a `BacklogSpawn`
128
- * rather than a hardcoded `Bun.spawn`, so tests inject a fake returning canned {@link SpawnResult}s
129
- * (or throwing an `ENOENT`-coded error to simulate a missing binary). `args` are the arguments after
130
- * the binary name — e.g. `["--version"]` or `["task", "list", "--json"]`; the binary itself is bound
131
- * inside the implementation. The returned promise **rejects** only when the process could not be
132
- * spawned at all (e.g. `ENOENT`) — a process that ran and failed resolves with a non-zero `exitCode`.
133
- * The one further rejection case is specific to the real {@link bunBacklogSpawn} implementation, not
134
- * this contract in general: a subprocess that runs but does not exit within the operator-overridable
135
- * wall-clock bound (LORE-217) is killed and rejects with a typed {@link LoreError} instead of hanging
136
- * — fakes are free to ignore this case entirely, since they never spawn a real process.
137
- */
138
- export type BacklogSpawn = (args: readonly string[]) => Promise<SpawnResult>;
139
-
140
- /**
141
- * Wrap a single `spawn(args)` invocation so a **spawn-level** rejection — the process could not be
142
- * started at all, as opposed to a process that ran and exited non-zero — always surfaces as a typed
143
- * {@link LoreError} instead of an untyped throw (LORE-222). Every call site in this file that invokes
144
- * `spawn` directly — the probe's `--version` and dry `task list --json` steps, the `read` helper, and
145
- * `viewTask`/`createTask`/`editTask` — routes through this one wrapper, so a `backlog` binary that
146
- * disappears mid-run, or a resource-limit/permission spawn failure, maps the same way no matter which
147
- * command triggered it, rather than only the probe's very first spawn being covered.
148
- *
149
- * - An `ENOENT`-coded rejection (the binary is absent from PATH) is `not_found` (exit 3) with the
150
- * install hint — the same mapping the probe's `--version` step already applied.
151
- * - An `EACCES`/`EPERM`-coded rejection (present but not executable) is `denied` (exit 4), mirroring
152
- * {@link readFileIfPresent}'s filesystem-errno policy.
153
- * - Any other errno-coded rejection (`EMFILE`, `EAGAIN`, …) is `validation` (exit 6), naming the code.
154
- * - A rejection carrying no errno `code` at all is not a recognized spawn failure and propagates
155
- * unchanged — including the typed {@link LoreError} {@link bunBacklogSpawn}'s own wall-clock timeout
156
- * already constructs (LORE-217), which carries no `.code` and must not be re-wrapped.
157
- *
158
- * Every mapped message embeds {@link deriveMessage}'s rendering of the original `cause`, so the
159
- * underlying OS diagnostic is never lost — only reclassified from an untyped throw to a typed one.
160
- */
161
- async function spawnOrThrow(spawn: BacklogSpawn, args: readonly string[]): Promise<SpawnResult> {
162
- try {
163
- return await spawn(args);
164
- } catch (cause) {
165
- const code = errnoCode(cause);
166
- if (code === "ENOENT") {
167
- throw new LoreError("not_found", "`backlog` was not found on PATH.", RUNBOOK_HINT, { binary: BACKLOG_BINARY });
168
- }
169
- if (code === "EACCES" || code === "EPERM") {
170
- throw new LoreError(
171
- "denied",
172
- `spawning \`backlog ${args.join(" ")}\` failed: ${deriveMessage(cause)}`,
173
- "check execute permissions on the `backlog` binary",
174
- { binary: BACKLOG_BINARY, args: [...args], code },
175
- );
176
- }
177
- if (code !== undefined) {
178
- throw new LoreError(
179
- "validation",
180
- `spawning \`backlog ${args.join(" ")}\` failed (${code}): ${deriveMessage(cause)}`,
181
- "check that `backlog` can be spawned in this environment (process/file-descriptor limits, resource availability)",
182
- { binary: BACKLOG_BINARY, args: [...args], code },
183
- );
184
- }
185
- throw cause;
186
- }
187
- }
188
-
189
- /** What the probe learned about the `backlog` binary once it passes — cached by the caller (§5). */
190
- export interface BacklogCapability {
191
- /** The `major.minor.patch` the binary reported (extra pre-release/build metadata dropped). */
192
- readonly version: string;
193
- /** The `schemaVersion` its `--json` envelope carried (always {@link EXPECTED_SCHEMA_VERSION} today). */
194
- readonly schemaVersion: number;
195
- }
196
-
197
- /** A parsed semantic version — just the numeric release triple the floor comparison needs. */
198
- interface Semver {
199
- readonly major: number;
200
- readonly minor: number;
201
- readonly patch: number;
202
- /** The `"major.minor.patch"` string, echoed into {@link BacklogCapability.version}. */
203
- readonly raw: string;
204
- }
205
-
206
- /** The one hint pointing an operator at how to obtain a `--json`-capable Backlog.md. */
207
- const RUNBOOK_HINT = `lore needs a --json-capable Backlog.md. Install backlog.md>=${MIN_BACKLOG_VERSION} (npm install -g backlog.md, or your package manager's equivalent) and put its \`backlog\` binary on PATH; see docs/runbooks/backlog-json-patch.md.`;
208
-
209
- /**
210
- * Parse the leading `major.minor.patch` from `backlog --version` output. Backlog prints a **bare**
211
- * semver plus a trailing newline (`"1.47.1\n"`) — no `v` prefix, no program name — so we anchor at the
212
- * start of the trimmed string and ignore any pre-release/build suffix. Returns `null` when the output
213
- * is not a recognizable semver (an empty string, a name-prefixed line, garbage), which the probe treats
214
- * as a fail-loud condition rather than guessing.
215
- */
216
- function parseSemver(output: string): Semver | null {
217
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(output.trim());
218
- if (!match) {
219
- return null;
220
- }
221
- const [, major, minor, patch] = match;
222
- return { major: Number(major), minor: Number(minor), patch: Number(patch), raw: `${major}.${minor}.${patch}` };
223
- }
224
-
225
- /** Order two {@link Semver}s by release triple: negative if `a < b`, positive if `a > b`, else `0`. */
226
- function compareSemver(a: Semver, b: Semver): number {
227
- return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
228
- }
229
-
230
- /**
231
- * Raise the fail-loud "needs a `--json`-capable Backlog.md" error (contract §5): the binary is present
232
- * but does not emit the envelope lore requires (stock rejects `--json`, or the output is unparseable /
233
- * the wrong shape / an unrecognized `schemaVersion`). Maps to exit `6` (`validation`) so the caller can
234
- * refuse the coupling commands while still allowing pure-OKF commands.
235
- */
236
- function notJsonCapable(reason: string, input?: Record<string, unknown>): never {
237
- throw new LoreError("validation", `The \`backlog\` binary is not --json-capable: ${reason}`, RUNBOOK_HINT, input);
238
- }
239
-
240
- /**
241
- * The capability probe (contract §5), run once at startup and cached by the caller in `.lore/cache/`.
242
- * Fail-loud: it either returns the {@link BacklogCapability} of a `--json`-capable binary or throws a
243
- * typed {@link LoreError} — it never best-effort parses or silently degrades (there is deliberately no
244
- * `--plain` text fallback; ADR-0002).
245
- *
246
- * Steps, in order:
247
- * 1. `backlog --version` — a missing binary (`ENOENT`) is `not_found` (exit 3) with an install hint;
248
- * a non-zero exit or non-semver output is fail-loud.
249
- * 2. Compare the reported version against {@link MIN_BACKLOG_VERSION}; below the floor is fail-loud.
250
- * 3. `backlog task list --json` — a non-zero exit (a binary without `--json` rejects the unknown
251
- * option), unparseable stdout, the wrong `kind`, a non-array `tasks`, or an unrecognized
252
- * `schemaVersion` are all fail-loud "not --json-capable" (exit 6). This step, not the version, is
253
- * what proves `--json` support.
254
- *
255
- * The `spawn` seam is injected so tests exercise every branch without a real subprocess.
256
- */
257
- export async function probeBacklog(spawn: BacklogSpawn): Promise<BacklogCapability> {
258
- // Step 1 — version. A spawn rejection with an ENOENT code means the binary is absent from PATH; that
259
- // is `not_found` (exit 3) with an install hint, distinct from a present-but-incapable binary (exit 6).
260
- // Routed through spawnOrThrow (LORE-222) so a non-ENOENT-coded rejection is a typed LoreError too,
261
- // not an untyped throw.
262
- const versionResult = await spawnOrThrow(spawn, ["--version"]);
263
- if (versionResult.exitCode !== 0) {
264
- notJsonCapable("`backlog --version` exited non-zero", { exitCode: versionResult.exitCode });
265
- }
266
- const version = parseSemver(versionResult.stdout);
267
- if (!version) {
268
- notJsonCapable("`backlog --version` did not print a bare semver");
269
- }
270
-
271
- // Step 2 — version floor. Below the tested floor is fail-loud; note a pre-`--json` stock release can
272
- // still pass this check, so passing here does NOT yet prove --json — step 3 does.
273
- const floor = parseSemver(MIN_BACKLOG_VERSION);
274
- if (floor && compareSemver(version, floor) < 0) {
275
- notJsonCapable(`version ${version.raw} is below the ${MIN_BACKLOG_VERSION} floor`, {
276
- version: version.raw,
277
- floor: MIN_BACKLOG_VERSION,
278
- });
279
- }
280
-
281
- // Step 3 — the dry `task list --json` probe. THIS is the real discriminator: a binary without --json
282
- // support has no such option, so Commander exits non-zero here. A --json-capable binary emits one
283
- // parseable envelope in upstream's shape (backlog-json-schema.md §8): {schemaVersion: 1, kind:
284
- // "task-list", tasks: [...]}.
285
- const listResult = await spawnOrThrow(spawn, ["task", "list", "--json"]);
286
- if (listResult.exitCode !== 0) {
287
- notJsonCapable("`task list --json` exited non-zero (binary does not support --json)", {
288
- exitCode: listResult.exitCode,
289
- });
290
- }
291
-
292
- let envelope: unknown;
293
- try {
294
- envelope = JSON.parse(listResult.stdout);
295
- } catch {
296
- notJsonCapable("`task list --json` did not print parseable JSON");
297
- }
298
- if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) {
299
- notJsonCapable("`task list --json` did not print a JSON envelope object");
300
- }
301
- const { schemaVersion, kind, tasks } = envelope as { schemaVersion?: unknown; kind?: unknown; tasks?: unknown };
302
- if (kind !== TASK_LIST_KIND) {
303
- notJsonCapable(`envelope kind was ${JSON.stringify(kind)}, expected ${JSON.stringify(TASK_LIST_KIND)}`);
304
- }
305
- if (!Array.isArray(tasks)) {
306
- notJsonCapable("envelope `tasks` was not an array");
307
- }
308
- // An unrecognized schemaVersion is a contract drift lore must not mis-read (§5): fail loud rather than
309
- // parse a shape it does not understand.
310
- if (schemaVersion !== EXPECTED_SCHEMA_VERSION) {
311
- notJsonCapable(
312
- `unrecognized schemaVersion ${JSON.stringify(schemaVersion)} (this lore understands ${JSON.stringify(EXPECTED_SCHEMA_VERSION)})`,
313
- { schemaVersion },
314
- );
315
- }
316
-
317
- return { version: version.raw, schemaVersion: EXPECTED_SCHEMA_VERSION };
318
- }
319
-
320
- /**
321
- * The **real** {@link BacklogSpawn}: shells out to the `backlog` binary via `Bun.spawn`, capturing both
322
- * streams and the exit code. Impure command-layer wiring (like the real clock and the real `git`
323
- * adapter), supplied where coupling commands are built; the probe and the rest of core never construct
324
- * it. A binary missing from PATH surfaces as a rejected promise carrying an `ENOENT` code, which
325
- * {@link probeBacklog} maps to `not_found`.
326
- *
327
- * `binary` defaults to `"backlog"` (resolved from PATH); it is a parameter so a test or a pinned
328
- * install can point at an explicit path. `cwd` defaults to the current process's working directory
329
- * (`Bun.spawn`'s own default); a caller working against a non-default `root` must pass it explicitly,
330
- * or the subprocess resolves Backlog's project files against the wrong directory.
331
- *
332
- * **Wall-clock bound (LORE-217).** Every lore coupling command (link/unlink/rename/sync/reconcile)
333
- * ultimately flows through this seam, so a `backlog` invocation that wedges — never printing on
334
- * either stream and never exiting — would otherwise leave lore blocked forever with no diagnostic.
335
- * A timer armed for {@link resolveBacklogTimeoutMs}'s bound kills the process (`SIGKILL`, so a
336
- * process ignoring `SIGTERM` still terminates) and the call rejects with a typed
337
- * {@link LoreError} ({@link timeoutError}) instead of awaiting `proc.exited` indefinitely. The
338
- * buffered-stream `Promise.all` is otherwise unchanged — a normal exit within the bound clears the
339
- * timer and resolves exactly as before.
340
- */
341
- export function bunBacklogSpawn(binary: string = BACKLOG_BINARY, cwd?: string): BacklogSpawn {
342
- return async (args: readonly string[]): Promise<SpawnResult> => {
343
- const timeoutMs = resolveBacklogTimeoutMs();
344
- const proc = Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "pipe", cwd });
345
- let timedOut = false;
346
- const timer = setTimeout(() => {
347
- timedOut = true;
348
- // SIGKILL, not the default SIGTERM: a wedged process may be ignoring or unable to act on
349
- // SIGTERM (the very reason it never exited on its own), so only an unmaskable signal
350
- // guarantees `proc.exited` below actually resolves instead of also hanging past the bound.
351
- proc.kill("SIGKILL");
352
- }, timeoutMs);
353
- try {
354
- const [stdout, stderr, exitCode] = await Promise.all([
355
- new Response(proc.stdout).text(),
356
- new Response(proc.stderr).text(),
357
- proc.exited,
358
- ]);
359
- if (timedOut) {
360
- throw timeoutError(binary, args, timeoutMs);
361
- }
362
- return { exitCode, stdout, stderr };
363
- } finally {
364
- // Always disarm: on the normal-exit path this prevents a stray kill() firing after the
365
- // process (and possibly its pid, reused by the OS) has already exited.
366
- clearTimeout(timer);
367
- }
368
- };
369
- }
370
-
371
- // ── The `--json` contract mirror (schema of record) ────────────────────────────────
372
- //
373
- // A Zod encoding of docs/reference/backlog-json-schema.md §1–§5 — the schema of record for what
374
- // upstream's `--json` emits (CLI-INSTRUCTIONS.md "Stable JSON output", src/formatters/json-output.ts).
375
- // Every read validates its envelope's payload against the matching shape here: unknown *extra* keys
376
- // are tolerated (`z.looseObject`, the additive-only contract — "Version 1 may gain backward-compatible
377
- // fields"), missing required keys are rejected. This is the runtime authority the doc describes (§7
378
- // step 4); the golden test (`test/backlog-json-golden.test.ts`) re-imports these same schemas via
379
- // `test/support/backlog-golden.ts` so the committed fixtures and the adapter can never validate against
380
- // two different contracts.
381
-
382
- /**
383
- * `priority` and `type` are both open, config-driven labels upstream (CLI-INSTRUCTIONS.md: priority
384
- * comes from `backlog/config.yml`'s `priorities:`; `type` from its `types:`) — not the fork's closed
385
- * `"high"|"medium"|"low"` enum. A free string or null.
386
- */
387
- const OpenLabel = z.string().nullable();
388
-
389
- /** An acceptance-criterion / definition-of-done item; `index` is positional and NON-durable (§6). */
390
- const Criterion = z.looseObject({
391
- index: z.number(),
392
- text: z.string(),
393
- checked: z.boolean(),
394
- });
395
-
396
- /** A task comment; `author` may be null. Field names match upstream's wire shape (`body`, `createdAt`). */
397
- const Comment = z.looseObject({
398
- index: z.number(),
399
- body: z.string(),
400
- createdAt: z.string().nullable(),
401
- author: z.string().nullable(),
402
- });
403
-
404
- /**
405
- * `kind: "task-list"` entry / a `search` task hit's payload — the compact summary fields
406
- * (CLI-INSTRUCTIONS.md: "Task list and task search results use these compact fields"). Upstream's
407
- * summary carries **no path** — only `task view` does (§4; contract §1.2) — so `file` on lore's mapped
408
- * {@link BacklogTask} comes exclusively from {@link TaskSchema} via {@link mapTask}.
409
- */
410
- export const TaskSummarySchema = z.looseObject({
411
- id: z.string(),
412
- title: z.string(),
413
- status: z.string(),
414
- type: OpenLabel,
415
- priority: OpenLabel,
416
- assignees: z.array(z.string()),
417
- reporter: z.string().nullable(),
418
- labels: z.array(z.string()),
419
- milestone: z.string().nullable(),
420
- parentTaskId: z.string().nullable(),
421
- ordinal: z.number().nullable(),
422
- createdAt: z.string().nullable(),
423
- updatedAt: z.string().nullable(),
424
- });
425
-
426
- /**
427
- * `kind: "task-view"` — the full task object (§3), the richest shape. `looseObject` tolerates unknown
428
- * additive keys. Excludes internal/git fields the fork used to carry (`source`, `branch`,
429
- * `onStatusChange`) — upstream deliberately does not expose them (CLI-INSTRUCTIONS.md: "branch
430
- * metadata... are not exposed").
431
- */
432
- export const TaskSchema = z.looseObject({
433
- id: z.string(),
434
- title: z.string(),
435
- status: z.string(),
436
- type: OpenLabel,
437
- priority: OpenLabel,
438
- assignees: z.array(z.string()),
439
- reporter: z.string().nullable(),
440
- labels: z.array(z.string()),
441
- milestone: z.string().nullable(),
442
- parentTaskId: z.string().nullable(),
443
- ordinal: z.number().nullable(),
444
- createdAt: z.string().nullable(),
445
- updatedAt: z.string().nullable(),
446
- path: z.string().nullable(),
447
- description: z.string().nullable(),
448
- dependencies: z.array(z.string()),
449
- references: z.array(z.string()),
450
- documentation: z.array(z.string()),
451
- modifiedFiles: z.array(z.string()),
452
- subtasks: z.array(z.looseObject({ id: z.string(), title: z.string() })),
453
- acceptanceCriteria: z.array(Criterion),
454
- definitionOfDone: z.array(Criterion),
455
- implementationPlan: z.string().nullable(),
456
- implementationNotes: z.string().nullable(),
457
- comments: z.array(Comment),
458
- finalSummary: z.string().nullable(),
459
- });
460
-
461
- /**
462
- * One search hit (§5): a `type`-tagged wrapper around the matched `data`. Upstream drops `score`
463
- * entirely ("Search scores are not part of the version 1 public contract") — no fork-shaped `item`/
464
- * `score` keys survive. `data`'s shape depends on `type`; `lore` only consumes task hits, which match
465
- * {@link TaskSummarySchema}; document/decision hits are Backlog-owned and only shape-checked loosely.
466
- */
467
- export const SearchHitSchema = z.looseObject({
468
- type: z.enum(["task", "document", "decision"]),
469
- data: z.looseObject({}),
470
- });
471
-
472
- /** The per-`kind` envelope (§1): one object, numeric `schemaVersion`, hyphenated `kind`, a per-command payload key. */
473
- export const EnvelopeSchema = z.discriminatedUnion("kind", [
474
- z.looseObject({
475
- schemaVersion: z.literal(EXPECTED_SCHEMA_VERSION),
476
- kind: z.literal(TASK_VIEW_KIND),
477
- task: TaskSchema,
478
- }),
479
- z.looseObject({
480
- schemaVersion: z.literal(EXPECTED_SCHEMA_VERSION),
481
- kind: z.literal(TASK_LIST_KIND),
482
- tasks: z.array(TaskSummarySchema),
483
- }),
484
- z.looseObject({
485
- schemaVersion: z.literal(EXPECTED_SCHEMA_VERSION),
486
- kind: z.literal(SEARCH_KIND),
487
- results: z.array(SearchHitSchema),
488
- }),
489
- ]);
490
-
491
- /** The three envelope kinds a `--json` command can carry, in upstream's hyphenated spelling. */
492
- export type EnvelopeKind = typeof TASK_LIST_KIND | typeof TASK_VIEW_KIND | typeof SEARCH_KIND;
493
-
494
- // ── lore's internal task model (the mapped read surface) ────────────────────────────
495
- //
496
- // The adapter maps the validated `--json` payload into these types before any coupling command sees
497
- // it. The mapping bakes in the load-bearing caveats from backlog-json-schema.md §6 so a caller cannot
498
- // get them wrong: `file` is the project-relative `path` upstream's `task view` carries — `task list`/
499
- // `search` summaries carry no path at all (§4; contract §1.2), so {@link BacklogTask} has no `file`
500
- // field and only {@link BacklogTaskDetail} (the `task view` shape) does — and AC/DoD items drop their
501
- // NON-durable positional `index` so callers must match on `text`. `id` is kept verbatim as identity
502
- // (display-cased); a filename is never derived from it.
503
-
504
- /** Task priority (or task `type`), mirroring the JSON's open, config-driven label — a free string or null. */
505
- export type BacklogPriority = string | null;
506
-
507
- /**
508
- * An acceptance-criterion / definition-of-done line, with the JSON `index` **deliberately dropped**
509
- * (§6: it is positional and renumbers on edit). Callers key on {@link text}, never a position.
510
- */
511
- export interface BacklogCriterion {
512
- readonly text: string;
513
- readonly checked: boolean;
514
- }
515
-
516
- /** A task comment, with the positional `index` dropped for the same reason as {@link BacklogCriterion}. */
517
- export interface BacklogComment {
518
- readonly author: string | null;
519
- readonly createdAt: string | null;
520
- readonly body: string;
521
- }
522
-
523
- /**
524
- * The **summary** of a task — the stable subset `task list` and `search` surface. Enough to render a
525
- * listing and reconcile status without a per-task `view`. Carries no file path (§4; contract §1.2) —
526
- * only {@link BacklogTaskDetail} (`task view`) does. `labels` includes any `doc:<conceptId>`
527
- * back-reference.
528
- */
529
- export interface BacklogTask {
530
- /** Display-cased identity (`"LORE-21"`). Identity only — never derive a filename from it (§6). */
531
- readonly id: string;
532
- readonly title: string;
533
- /** The raw configured status string, no presentation icon (§2). */
534
- readonly status: string;
535
- readonly priority: BacklogPriority;
536
- /** Sort ordinal within status, or `null`. */
537
- readonly ordinal: number | null;
538
- readonly assignees: readonly string[];
539
- /** Includes the `doc:<conceptId>` back-reference label lore reads for coupling. */
540
- readonly labels: readonly string[];
541
- readonly milestone: string | null;
542
- readonly parentTaskId: string | null;
543
- }
544
-
545
- /**
546
- * The **full** task (output of `backlog task view <id> --json`, `kind: "task-view"`), extending
547
- * {@link BacklogTask} with the fields only the per-id view carries: the project-relative {@link file}
548
- * path, dependencies, the doc/ref arrays, the structured body sections, and comments. Upstream does not
549
- * expose `source`/`branch`/`onStatusChange` (internal/git fields the fork used to carry) or a
550
- * `parentTaskTitle`.
551
- */
552
- export interface BacklogTaskDetail extends BacklogTask {
553
- /** Project-relative (`backlog/tasks/…`) or `null` on a not-yet-written task; upstream carries no absolute path. */
554
- readonly file: string | null;
555
- readonly reporter: string | null;
556
- readonly createdAt: string | null;
557
- readonly updatedAt: string | null;
558
- readonly dependencies: readonly string[];
559
- readonly references: readonly string[];
560
- readonly documentation: readonly string[];
561
- readonly modifiedFiles: readonly string[];
562
- readonly subtasks: readonly { readonly id: string; readonly title: string }[];
563
- readonly acceptanceCriteria: readonly BacklogCriterion[];
564
- readonly definitionOfDone: readonly BacklogCriterion[];
565
- readonly description: string | null;
566
- readonly implementationPlan: string | null;
567
- readonly implementationNotes: string | null;
568
- readonly finalSummary: string | null;
569
- readonly comments: readonly BacklogComment[];
570
- }
571
-
572
- /** Map a validated `task-list`/`search` summary item into lore's {@link BacklogTask}. */
573
- function mapSummary(item: z.infer<typeof TaskSummarySchema>): BacklogTask {
574
- return {
575
- id: item.id,
576
- title: item.title,
577
- status: item.status,
578
- priority: item.priority,
579
- ordinal: item.ordinal,
580
- assignees: item.assignees,
581
- labels: item.labels,
582
- milestone: item.milestone,
583
- parentTaskId: item.parentTaskId,
584
- };
585
- }
586
-
587
- /** Strip the non-durable `index` from AC/DoD items (§6): callers match on text, never position. */
588
- function mapCriteria(items: readonly z.infer<typeof Criterion>[]): BacklogCriterion[] {
589
- return items.map((c) => ({ text: c.text, checked: c.checked }));
590
- }
591
-
592
- /** Map a validated `task-view` payload into lore's {@link BacklogTaskDetail} (full per-id view). */
593
- function mapTask(data: z.infer<typeof TaskSchema>): BacklogTaskDetail {
594
- return {
595
- id: data.id,
596
- title: data.title,
597
- status: data.status,
598
- priority: data.priority,
599
- ordinal: data.ordinal,
600
- assignees: data.assignees,
601
- labels: data.labels,
602
- milestone: data.milestone,
603
- parentTaskId: data.parentTaskId,
604
- file: data.path,
605
- reporter: data.reporter,
606
- createdAt: data.createdAt,
607
- updatedAt: data.updatedAt,
608
- dependencies: data.dependencies,
609
- references: data.references,
610
- documentation: data.documentation,
611
- modifiedFiles: data.modifiedFiles,
612
- subtasks: data.subtasks,
613
- acceptanceCriteria: mapCriteria(data.acceptanceCriteria),
614
- definitionOfDone: mapCriteria(data.definitionOfDone),
615
- description: data.description,
616
- implementationPlan: data.implementationPlan,
617
- implementationNotes: data.implementationNotes,
618
- finalSummary: data.finalSummary,
619
- comments: data.comments.map((c) => ({ author: c.author, createdAt: c.createdAt, body: c.body })),
620
- };
621
- }
622
-
623
- // ── Read/write fail-loud helpers ────────────────────────────────────────────────────
624
-
625
- /** Flatten Zod issues to a single-line `field: reason; field: reason` string (mirrors core/schema.ts). */
626
- function describeZodIssues(error: z.ZodError): string {
627
- return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
628
- }
629
-
630
- /** Project Zod issues onto a plain, JSON-safe array for a {@link LoreError}'s `input.issues`. */
631
- function zodIssueList(error: z.ZodError): Array<{ path: string; message: string }> {
632
- return error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message }));
633
- }
634
-
635
- /**
636
- * A `--json` read did not return the envelope lore expects (unparseable stdout, a `schemaVersion`/`kind`
637
- * mismatch, or a `data` shape that fails the contract mirror). This is **fail-loud drift** (exit 6): the
638
- * adapter never best-effort parses and there is deliberately no `--plain` text fallback (ADR-0002).
639
- */
640
- function readDrift(reason: string, input?: Record<string, unknown>): never {
641
- throw new LoreError("drift", `\`backlog\` --json read drift: ${reason}`, RUNBOOK_HINT, input);
642
- }
643
-
644
- /**
645
- * Parse and validate one `--json` envelope's stdout for `command`, asserting it carries `expectedKind`,
646
- * and return the validated payload found at `payloadKey` — upstream's envelope has **no shared `data`
647
- * key**; each command names its own (`tasks` / `task` / `results`). Staged like the probe so each
648
- * failure names its cause: parseable JSON → an envelope object → the pinned `schemaVersion` → the
649
- * expected `kind` → the per-kind payload shape (Zod). Every step failing is {@link readDrift} (exit 6)
650
- * — never a silent degrade.
651
- */
652
- function parseEnvelope<S extends z.ZodType>(
653
- stdout: string,
654
- expectedKind: EnvelopeKind,
655
- payloadKey: "tasks" | "task" | "results",
656
- dataSchema: S,
657
- command: string,
658
- ): z.infer<S> {
659
- let parsed: unknown;
660
- try {
661
- parsed = JSON.parse(stdout);
662
- } catch {
663
- readDrift(`\`${command}\` did not print parseable JSON`);
664
- }
665
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
666
- readDrift(`\`${command}\` did not print a JSON envelope object`);
667
- }
668
- const envelope = parsed as Record<string, unknown>;
669
- const { schemaVersion, kind } = envelope as { schemaVersion?: unknown; kind?: unknown };
670
- if (schemaVersion !== EXPECTED_SCHEMA_VERSION) {
671
- readDrift(
672
- `\`${command}\` envelope schemaVersion was ${JSON.stringify(schemaVersion)} (this lore understands ${JSON.stringify(EXPECTED_SCHEMA_VERSION)})`,
673
- { schemaVersion },
674
- );
675
- }
676
- if (kind !== expectedKind) {
677
- readDrift(`\`${command}\` envelope kind was ${JSON.stringify(kind)}, expected ${JSON.stringify(expectedKind)}`);
678
- }
679
- const result = dataSchema.safeParse(envelope[payloadKey]);
680
- if (!result.success) {
681
- throw new LoreError(
682
- "validation",
683
- `\`backlog ${command}\` --json payload failed contract validation: ${describeZodIssues(result.error)}`,
684
- RUNBOOK_HINT,
685
- { command, issues: zodIssueList(result.error) },
686
- );
687
- }
688
- return result.data;
689
- }
690
-
691
- /** The `tasks` schema for a `task-list` envelope: an array of summaries. */
692
- const TaskListData = z.array(TaskSummarySchema);
693
- /** The `results` schema for a `search` envelope: an array of scored hits. */
694
- const SearchResultData = z.array(SearchHitSchema);
695
-
696
- // ── The typed adapter (JSON-only reads, CLI writes) ─────────────────────────────────
697
-
698
- /** Filters for {@link BacklogAdapter.listTasks}, passed through to `task list --json`. */
699
- export interface ListTasksOptions {
700
- /** Filter to one configured status (`--status`, case-insensitive). */
701
- readonly status?: string;
702
- /** Require every listed label (`--labels`, AND-match); e.g. `["doc:stories/x"]`. */
703
- readonly labels?: readonly string[];
704
- }
705
-
706
- /** Input for {@link BacklogAdapter.createTask}. Only the fields lore's coupling commands set. */
707
- export interface CreateTaskInput {
708
- readonly title: string;
709
- /** Labels to set on the new task (comma-joined into one `--labels`, per contract §2.4). */
710
- readonly labels?: readonly string[];
711
- readonly description?: string;
712
- /** Documentation refs (`--doc`, accumulator) — the display cross-reference. */
713
- readonly doc?: readonly string[];
714
- readonly milestone?: string;
715
- }
716
-
717
- /** A patch for {@link BacklogAdapter.editTask}. Label ops are **incremental** (add/remove), never SET. */
718
- export interface EditTaskPatch {
719
- /** Labels to add (`--add-label`, case-insensitive de-dup) — e.g. a `doc:<conceptId>` back-ref. */
720
- readonly addLabels?: readonly string[];
721
- /** Labels to remove (`--remove-label`). */
722
- readonly removeLabels?: readonly string[];
723
- /** New status (`--status`). */
724
- readonly status?: string;
725
- /** Documentation refs to set (`--doc`) — SET/REPLACE the whole array (contract §2.4). */
726
- readonly doc?: readonly string[];
727
- }
728
-
729
- /**
730
- * The typed Backlog.md read/write surface — the **only** consumer of the {@link BacklogSpawn} seam
731
- * beyond the probe, and the sole place the `--json` schema is parsed (design §2.3, schema doc §7).
732
- *
733
- * Reads are JSON-only: each shells the matching `--json` command, `JSON.parse`s the single envelope,
734
- * asserts `schemaVersion`/`kind`, validates `data` against the contract mirror, and maps to lore's
735
- * internal types — never touching `--plain` (ADR-0002). Writes go through `task create`/`task edit`;
736
- * a create's new id is captured from the `Created task <ID>` stdout line, not from JSON (contract §2.1).
737
- * Every method first runs the capability {@link probeBacklog} (memoized once per adapter), so a binary
738
- * that is not `--json`-capable is refused before any command's output is trusted.
739
- */
740
- export interface BacklogAdapter {
741
- /** The cached capability probe verdict; runs `probeBacklog` at most once, fail-loud on an incapable binary. */
742
- probe(): Promise<BacklogCapability>;
743
- /** `task list --json` → the summaries on the current branch, optionally filtered by status/labels. */
744
- listTasks(opts?: ListTasksOptions): Promise<BacklogTask[]>;
745
- /** `task view <id> --json` → the full task, or `null` when the id has no task (exit code 1). */
746
- viewTask(id: string): Promise<BacklogTaskDetail | null>;
747
- /** `task list --json --labels <label>` → tasks carrying an exact label (e.g. a `doc:<conceptId>` back-ref). */
748
- searchByLabel(label: string): Promise<BacklogTask[]>;
749
- /** `search <query> --json` → the **task** hits only (document/decision hits are dropped, §5). */
750
- searchTasks(query: string): Promise<BacklogTask[]>;
751
- /** `task create` (no `--plain`/`--json`) → the new display-cased id, captured from `Created task <ID>`. */
752
- createTask(input: CreateTaskInput): Promise<string>;
753
- /** `task edit <id>` (no `--json` — unsupported, LORE-57) with an incremental patch; fail-loud on a missing task or a non-zero exit. */
754
- editTask(id: string, patch: EditTaskPatch): Promise<void>;
755
- }
756
-
757
- /** Captures the display-cased id from a create's first stdout line (`Created task LORE-1` / `Created draft …`). */
758
- const CREATED_ID = /^Created (?:task|draft) (\S+)$/m;
759
-
760
- /**
761
- * Reject a caller-controlled value that begins with `-` before it reaches a `spawn` argv position.
762
- * Backlog's own CLI parses argv positionally: an id, title, label, status, milestone, description, doc
763
- * ref, or search query beginning with `-` is read by Backlog's flag parser as an option rather than the
764
- * literal data lore intends — with no `--` option-terminator inserted at each call site (several push a
765
- * real flag like `--json` immediately after the data position, which a terminator would itself swallow),
766
- * a dash-prefixed value could alter or hijack the invoked Backlog command. There is no per-value escape
767
- * for this (Backlog looks only at the leading character), so — matching {@link commaJoin}'s existing
768
- * policy for an unescapable embedded comma — a dash-prefixed value is rejected outright rather than risking
769
- * misinterpretation.
770
- */
771
- function rejectFlagLike(value: string): string {
772
- if (value.startsWith("-")) {
773
- throw new LoreError(
774
- "validation",
775
- `cannot send "${value}" to Backlog: a value beginning with "-" would be parsed as a flag, not literal data`,
776
- "rename the value so it does not begin with '-'",
777
- { value },
778
- );
779
- }
780
- return value;
781
- }
782
-
783
- /**
784
- * Comma-join multiple values into a single occurrence of one flag, so lore never has to repeat a
785
- * flag to send more than one value (§2.4). This is safe for the label flags this helper actually
786
- * serves — `task list`'s `--labels` filter, `task edit`'s `--add-label`/`--remove-label`
787
- * (accumulators), and `task create`'s `--labels` (single-value/last-wins): lore always sends exactly
788
- * one occurrence, never a repeat, and each of these four flags reads one comma-joined occurrence as
789
- * the same intended list either way. This is a per-flag property, not a rule of "multiplicity
790
- * family" in general, so do not assume it for a flag not listed above without checking Backlog's
791
- * source first — e.g. `--assignee` is single-value/last-wins on both `create` and `edit` (§2.4), yet
792
- * only `edit` comma-splits it (`create` takes the option's raw string as one literal value with no
793
- * split at all). Dedup is also per-consumer, not per-family: `task list`/`task edit`'s label flags
794
- * all dedup repeated values (via upstream's `parseDelimitedStringList`), but `task create --labels`
795
- * does not (an inline split with no `Set`) — harmless here because every `commaJoin` caller already
796
- * passes a purpose-built, non-repeating value list.
797
- * Backlog's CLI has no escape for an embedded comma — the comma **is** the delimiter — so a value
798
- * containing one cannot be sent safely: it would silently split into two (or more) unrelated
799
- * Backlog-side values instead of the one lore intends. Reject it instead. Each value is also run
800
- * through {@link rejectFlagLike} — the comma-join happens after the flag itself (e.g. `--labels`), but a
801
- * leading `-` on any individual label is still ambiguous flag-like data, so it is rejected the same way.
802
- */
803
- function commaJoin(values: readonly string[]): string {
804
- for (const value of values) {
805
- rejectFlagLike(value);
806
- }
807
- const offender = values.find((v) => v.includes(","));
808
- if (offender !== undefined) {
809
- throw new LoreError(
810
- "validation",
811
- `cannot send "${offender}" to Backlog: a comma-separated flag has no escape for an embedded comma`,
812
- "rename the concept/label/value so it contains no comma",
813
- { value: offender },
814
- );
815
- }
816
- return values.join(",");
817
- }
818
-
819
- /**
820
- * Build the typed {@link BacklogAdapter} over an injected {@link BacklogSpawn} (real via
821
- * {@link bunBacklogSpawn}, a fake in tests). The capability probe is memoized on first use — its promise
822
- * is cached so a passing verdict runs the underlying `--version` + dry `task list --json` exactly once
823
- * per adapter, and a failing verdict rejects every method the same way. (The cross-process cache in
824
- * `.lore/cache/` described in the schema doc §7 is a command-layer concern, layered on top of this.)
825
- */
826
- export function createBacklogAdapter(spawn: BacklogSpawn): BacklogAdapter {
827
- let capability: Promise<BacklogCapability> | undefined;
828
- const ensureProbed = (): Promise<BacklogCapability> => {
829
- if (capability === undefined) {
830
- capability = probeBacklog(spawn);
831
- }
832
- return capability;
833
- };
834
-
835
- /** Run a read command through the probe gate and return its captured {@link SpawnResult}. */
836
- async function read(args: readonly string[], command: string): Promise<SpawnResult> {
837
- await ensureProbed();
838
- const result = await spawnOrThrow(spawn, args);
839
- if (result.exitCode !== 0) {
840
- readDrift(`\`${command}\` exited ${result.exitCode}`, { exitCode: result.exitCode });
841
- }
842
- return result;
843
- }
844
-
845
- /**
846
- * `task list --json` → the summaries on the current branch, optionally filtered by status/labels.
847
- * Closed-over (like {@link read}/{@link ensureProbed}) so {@link BacklogAdapter.searchByLabel} can
848
- * invoke it directly instead of going through `this` on the returned object.
849
- */
850
- async function listTasks(opts?: ListTasksOptions): Promise<BacklogTask[]> {
851
- const args = ["task", "list", "--json"];
852
- if (opts?.status !== undefined) {
853
- args.push("--status", rejectFlagLike(opts.status));
854
- }
855
- if (opts?.labels !== undefined && opts.labels.length > 0) {
856
- args.push("--labels", commaJoin(opts.labels));
857
- }
858
- const result = await read(args, "task list --json");
859
- return parseEnvelope(result.stdout, TASK_LIST_KIND, "tasks", TaskListData, "task list --json").map(mapSummary);
860
- }
861
-
862
- return {
863
- probe: ensureProbed,
864
-
865
- listTasks,
866
-
867
- async viewTask(id: string): Promise<BacklogTaskDetail | null> {
868
- await ensureProbed();
869
- const result = await spawnOrThrow(spawn, ["task", "view", rejectFlagLike(id), "--json"]);
870
- // A missing task exits 1 unconditionally, in every output mode (upstream, PR #790; contract
871
- // §2.2's migration note) — the fork's old "exit 0, empty stdout" signal no longer applies. Any
872
- // other nonzero exit, or a 1 that unexpectedly printed something, is a fail-loud drift. This is
873
- // why `viewTask` cannot share the `read` helper's exit-code guard (it treats every nonzero as
874
- // fatal).
875
- if (result.exitCode === 1) {
876
- if (result.stdout.trim() !== "") {
877
- readDrift("`task view --json` exited 1 (the missing-task signal) but printed something to stdout", {
878
- id,
879
- });
880
- }
881
- return null;
882
- }
883
- if (result.exitCode !== 0) {
884
- readDrift(`\`task view --json\` exited ${result.exitCode}`, { exitCode: result.exitCode, id });
885
- }
886
- return mapTask(parseEnvelope(result.stdout, TASK_VIEW_KIND, "task", TaskSchema, "task view --json"));
887
- },
888
-
889
- async searchByLabel(label: string): Promise<BacklogTask[]> {
890
- return listTasks({ labels: [label] });
891
- },
892
-
893
- async searchTasks(query: string): Promise<BacklogTask[]> {
894
- const result = await read(["search", rejectFlagLike(query), "--json"], "search --json");
895
- const hits = parseEnvelope(result.stdout, SEARCH_KIND, "results", SearchResultData, "search --json");
896
- // lore consumes only task hits (§5); document/decision hits are Backlog-owned. Re-validate each
897
- // task hit's loosely-typed `data` against the summary contract before mapping.
898
- const tasks: BacklogTask[] = [];
899
- for (const hit of hits) {
900
- if (hit.type !== "task") {
901
- continue;
902
- }
903
- const item = TaskSummarySchema.safeParse(hit.data);
904
- if (!item.success) {
905
- throw new LoreError(
906
- "validation",
907
- `\`backlog search --json\` task hit failed contract validation: ${describeZodIssues(item.error)}`,
908
- RUNBOOK_HINT,
909
- { issues: zodIssueList(item.error) },
910
- );
911
- }
912
- tasks.push(mapSummary(item.data));
913
- }
914
- return tasks;
915
- },
916
-
917
- async createTask(input: CreateTaskInput): Promise<string> {
918
- await ensureProbed();
919
- // Create runs WITHOUT --plain and WITHOUT --json (contract §2.1): --plain suppresses the
920
- // `Created task <ID>` line lore captures, and create emits no JSON envelope.
921
- const args = ["task", "create", rejectFlagLike(input.title)];
922
- if (input.description !== undefined) {
923
- args.push("--description", rejectFlagLike(input.description));
924
- }
925
- if (input.labels !== undefined && input.labels.length > 0) {
926
- args.push("--labels", commaJoin(input.labels));
927
- }
928
- if (input.milestone !== undefined) {
929
- args.push("--milestone", rejectFlagLike(input.milestone));
930
- }
931
- for (const doc of input.doc ?? []) {
932
- args.push("--doc", rejectFlagLike(doc)); // --doc is an accumulator (§2.4): repeat, don't comma-join.
933
- }
934
- const result = await spawnOrThrow(spawn, args);
935
- if (result.exitCode !== 0) {
936
- throw new LoreError(
937
- "validation",
938
- `\`backlog task create\` exited ${result.exitCode}`,
939
- stderrHint(result.stderr),
940
- {
941
- exitCode: result.exitCode,
942
- },
943
- );
944
- }
945
- const newId = CREATED_ID.exec(result.stdout)?.[1];
946
- if (newId === undefined) {
947
- // `task create` already exited 0 — Backlog genuinely created the task — so failing loud with
948
- // no context would leave it orphaned and unreferenceable: the caller has no id to look it up
949
- // by. Echo the raw stdout (and the title Backlog was given) in the error's `input` so a caller
950
- // can still recover the new task, e.g. via `backlog task list --search "<title>"`.
951
- readDrift("`task create` exited 0 but did not print a `Created task <ID>` line to capture the new id", {
952
- title: input.title,
953
- stdout: result.stdout,
954
- });
955
- }
956
- return newId;
957
- },
958
-
959
- async editTask(id: string, patch: EditTaskPatch): Promise<void> {
960
- await ensureProbed();
961
- const args = ["task", "edit", rejectFlagLike(id)];
962
- if (patch.addLabels !== undefined && patch.addLabels.length > 0) {
963
- // accumulator flag (§2.4): comma-join into one occurrence.
964
- args.push("--add-label", commaJoin(patch.addLabels));
965
- }
966
- if (patch.removeLabels !== undefined && patch.removeLabels.length > 0) {
967
- // accumulator flag (§2.4): comma-join into one occurrence.
968
- args.push("--remove-label", commaJoin(patch.removeLabels));
969
- }
970
- if (patch.status !== undefined) {
971
- args.push("--status", rejectFlagLike(patch.status));
972
- }
973
- for (const doc of patch.doc ?? []) {
974
- args.push("--doc", rejectFlagLike(doc)); // accumulator, SET/REPLACE the whole array (§2.4).
975
- }
976
- const result = await spawnOrThrow(spawn, args);
977
- // `task edit <missing>` exits 1 (contract §2.2) — the one write whose exit code IS meaningful.
978
- if (result.exitCode !== 0) {
979
- const missing = /not found/i.test(result.stderr);
980
- throw new LoreError(
981
- missing ? "not_found" : "validation",
982
- missing
983
- ? `\`backlog task edit\` could not find task ${JSON.stringify(id)}`
984
- : `\`backlog task edit\` exited ${result.exitCode}`,
985
- stderrHint(result.stderr),
986
- { id, exitCode: result.exitCode },
987
- );
988
- }
989
- },
990
- };
991
- }
992
-
993
- // ── Status flow from `backlog/config.yml` (LORE-26, backlog-cli-contract.md §3.1) ──────
994
- //
995
- // `reconcile.ts`'s `reconcileStatus` needs the project's ordered status set — never the hardcoded
996
- // three defaults. This is read directly from `backlog/config.yml`'s own `statuses:` key (plain
997
- // repo-committed YAML Backlog.md itself owns and writes), not shelled through a `backlog` subprocess:
998
- // ADR-0012's future config-drift assertion (`lore check`, LORE-27) already establishes the precedent
999
- // of reading this same file directly, and it needs no `--json` envelope treatment (it is not a Task).
1000
-
1001
- /** Where Backlog.md keeps its own project config, relative to the repo root. */
1002
- export const BACKLOG_CONFIG_REL_PATH = "backlog/config.yml";
1003
-
1004
- /** The status flow backlog-cli-contract.md §3.1 documents as the default, used when `backlog/config.yml` is absent or carries no `statuses:` key. */
1005
- export const DEFAULT_STATUS_FLOW: readonly string[] = ["To Do", "In Progress", "Done"];
1006
-
1007
- /** The frozen js-yaml load config (matches concept.ts's ADR-0011 §2 choice): `JSON_SCHEMA` avoids implicit type coercion on plain scalar status names. */
1008
- const CONFIG_YAML_LOAD_OPTIONS = Object.freeze({ schema: yaml.JSON_SCHEMA });
1009
-
1010
- /**
1011
- * Parse the ordered `statuses:` list out of `backlog/config.yml`'s raw YAML text. Pure — no
1012
- * filesystem, so tests exercise it directly rather than through a real file. An absent `statuses:`
1013
- * key (or an empty/`null` document, e.g. a freshly-`backlog init`ed project that has not yet touched
1014
- * this key) yields {@link DEFAULT_STATUS_FLOW}, matching contract §3.1's documented default; a
1015
- * `statuses:` key present but not a list of strings is a fail-loud `validation` error rather than a
1016
- * silent guess.
1017
- *
1018
- * @throws LoreError `validation` when the YAML does not parse, is not a mapping, or `statuses:` is
1019
- * present but not a list of strings.
1020
- */
1021
- export function parseStatusFlow(yamlText: string): string[] {
1022
- // js-yaml 5 makes `load("")` a syntax error rather than returning undefined.
1023
- // Preserve lore's documented fresh-config behavior independently of that
1024
- // parser-version detail.
1025
- if (yamlText.trim() === "") {
1026
- return [...DEFAULT_STATUS_FLOW];
1027
- }
1028
- let parsed: unknown;
1029
- try {
1030
- parsed = yaml.load(yamlText, CONFIG_YAML_LOAD_OPTIONS);
1031
- } catch (cause) {
1032
- throw configError(`is not valid YAML${reasonSuffix(cause)}`);
1033
- }
1034
- if (parsed === "" || parsed === null || parsed === undefined) {
1035
- return [...DEFAULT_STATUS_FLOW]; // an empty document — Backlog's config carries no keys yet
1036
- }
1037
- if (typeof parsed !== "object" || Array.isArray(parsed)) {
1038
- throw configError("must be a YAML mapping");
1039
- }
1040
- const statuses = (parsed as Record<string, unknown>).statuses;
1041
- if (statuses === undefined) {
1042
- return [...DEFAULT_STATUS_FLOW];
1043
- }
1044
- if (!Array.isArray(statuses) || statuses.some((s) => typeof s !== "string")) {
1045
- throw configError("`statuses:` must be a list of strings");
1046
- }
1047
- return statuses as string[];
1048
- }
1049
-
1050
- /**
1051
- * Read and parse the project's status flow from `backlog/config.yml` under `root` — the
1052
- * command-layer I/O half of {@link parseStatusFlow}, via `errors.ts`'s shared
1053
- * {@link readFileIfPresent} (adapters cannot import `commands/discover.ts`, which owns the
1054
- * analogous `readSource`). A missing file yields {@link DEFAULT_STATUS_FLOW} (mirrors `config.ts`'s
1055
- * own missing-file-is-zero-config policy); a permission failure is `denied` (exit 4); any other read
1056
- * failure propagates unclassified (there is no sensible fallback for, say, a directory sitting at
1057
- * the path).
1058
- */
1059
- export function readStatusFlow(root: string): string[] {
1060
- const relPath = BACKLOG_CONFIG_REL_PATH;
1061
- const text = readFileIfPresent(join(root, relPath), relPath);
1062
- return text === undefined ? [...DEFAULT_STATUS_FLOW] : parseStatusFlow(text);
1063
- }
1064
-
1065
- /** Build the fail-loud "malformed backlog/config.yml" error (`validation`, exit 6). */
1066
- function configError(reason: string): LoreError {
1067
- return new LoreError(
1068
- "validation",
1069
- `cannot read the project's status flow: ${BACKLOG_CONFIG_REL_PATH} ${reason}`,
1070
- `fix ${BACKLOG_CONFIG_REL_PATH}'s \`statuses:\` key, or remove it to use the default flow`,
1071
- { path: BACKLOG_CONFIG_REL_PATH },
1072
- );
1073
- }
1074
-
1075
- /**
1076
- * Append `: <reason>` when a non-empty message can be derived from a thrown cause, via the shared,
1077
- * guarded {@link deriveMessage} — not a hand-rolled `instanceof Error` check, which would lose the
1078
- * reason (or itself throw) for a non-`Error` cause carrying its own `.message`, or one with a
1079
- * hostile `toString`/`Symbol.toPrimitive`.
1080
- */
1081
- function reasonSuffix(cause: unknown): string {
1082
- const message = deriveMessage(cause).trim();
1083
- return message === "" ? "" : `: ${message}`;
1084
- }