@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.
- package/README.md +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/adapters/backlog.ts
DELETED
|
@@ -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
|
-
}
|