@opum-ai/lore 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,583 +0,0 @@
1
- /**
2
- * commands/init.ts — `lore init`: scaffold an empty, conformant OKF bundle, and fold the rest of
3
- * onboarding into the SAME one command (LORE-260): the Claude Code agent bridge, downstream
4
- * doc-site scaffolds (mkdocs/docusaurus/obsidian), and a backlog-coupling capability check —
5
- * replacing the old `init` → `agents` → external `lore-setup.sh` → manual Obsidian sequence.
6
- *
7
- * ## The locked design decision (2026-07-24)
8
- *
9
- * A **bare** `lore init` on an interactive terminal runs a guided wizard that asks about each
10
- * configurable consumer; it is **TTY-gated** — when stdin OR stderr is not a TTY (CI, pipes, a
11
- * test, or a caller that redirects only one stream), `--json` was requested, or ANY of this
12
- * command's own flags is passed, it runs fully **non-interactively** with defaults and no prompt
13
- * can ever block it (the npm-init pattern: interactive on a bare TTY invocation, `-y`/non-TTY skips
14
- * prompts). **Both stdin and stderr must be real terminals** — every wizard question is written to
15
- * stderr (cli-contract §4: stdout stays exclusively `init`'s own envelope), so gating on stdin alone
16
- * would leave the wizard blocked-but-invisible behind a redirected stderr (review round 2,
17
- * BLOCKING-1 — confirmed live: `lore init >/dev/null 2>&1` under a pty hung forever with zero
18
- * output). `--json` is a third, independent veto: a machine-readable run must never prompt even at a
19
- * genuinely interactive terminal. Every wizard question maps 1:1 to a flag (`--agents`,
20
- * `--scaffold <target>`, `--obsidian`, `--no-backlog`/`--check-backlog`), so a script gets the exact
21
- * same outcome as answering the wizard, with zero prompts. This is documented in
22
- * [ADR-0017](../../docs/adr/0017-interactive-init-wizard-tty-gated.md) (an amendment to ADR-0004/
23
- * ADR-0005's non-interactive CLI contract).
24
- *
25
- * **EOF (Ctrl-D) mid-wizard is a `usage` error, not a silent exit 0** (review round 2, BLOCKING-2):
26
- * `readline/promises`' `rl.question()` never settles on stdin EOF, so a naive implementation left the
27
- * wizard's promise abandoned forever — the process would exit 0 with `process.exitCode` never set,
28
- * zero stdout bytes even under `--json` (a parse error for a `| jq` consumer expecting either a valid
29
- * envelope or a classified failure), and a half-applied run (the base scaffold already written,
30
- * nothing else). {@link createRealPrompter} now races every question against the readline
31
- * interface's own `close` event and throws a `usage` {@link LoreError} on an early close, so the run
32
- * exits non-zero with a rendered diagnostic instead — chosen over silently falling back to each
33
- * question's default because BLOCKING-1's lesson applies here too: never silently do something the
34
- * user couldn't see coming.
35
- *
36
- * **The non-interactive default is UNCHANGED from before this task**: with no flags and a non-TTY
37
- * stdin (the automatic case for every existing caller — CI, `lore-setup.sh`, this file's own
38
- * pre-LORE-260 tests), `lore init` does exactly what it always did — scaffold `docs/`/`.lore/` and
39
- * nothing else. The agent bridge, scaffolds, and the backlog check are strictly opt-in via flags (or
40
- * the wizard); this is what keeps the docker e2e harness's existing bare `lore init` calls, and
41
- * every pre-existing unit test, byte-for-byte compatible with the prior behavior.
42
- *
43
- * The base bundle scaffold (this file's original, sole responsibility) is the thin command layer
44
- * over the pure {@link buildScaffold} (lore-design §2.2, §3.1): it resolves the repo root and a
45
- * clock, asks core for the intended bytes, and applies them to the filesystem **idempotently**.
46
- * The load-bearing behavior is idempotency (AC#2/AC#3): every file is created only when **absent**
47
- * (an atomic `wx` write, so there is no time-of-check/time-of-use race and no clobber of a user's
48
- * edits), and directories are `mkdir -p` (already-exists is not an error). The agent bridge and
49
- * scaffold steps reuse the SAME idempotent primitives `lore agents`/`lore scaffold` ship
50
- * ({@link applyAgentsBridge}/{@link applyScaffold}) rather than duplicating their logic, so a second
51
- * run of any combination of flags is a no-op wherever the first run already finished.
52
- *
53
- * The interactive wizard's TTY gate and its I/O are both **injectable** ({@link InitOptions.stdinIsTTY}
54
- * / {@link InitOptions.prompter}), never read from `process.stdin` at this call site — a test drives
55
- * the wizard path by passing `stdinIsTTY: true` plus a scripted {@link InitPrompter}, never a real
56
- * terminal. `runInit` itself stays a plain (non-`async`) function returning `number | Promise<number>`
57
- * (mirroring `commands/check.ts`'s own `runCheck`): the common, fully-synchronous path (no flags, no
58
- * backlog check) returns a plain number exactly as before LORE-260, and only the wizard or an
59
- * actually-requested backlog check return a `Promise`.
60
- */
61
-
62
- import { join } from "node:path";
63
- import * as readline from "node:readline/promises";
64
- import type { BacklogAdapter } from "../adapters/backlog";
65
- import { loadProfile } from "../core/profile";
66
- import { buildScaffold } from "../core/scaffold";
67
- import { ANSI, EXIT_OK, LoreError, paint, WarningCollector, type Writer } from "../errors";
68
- import { emit, type OutputContext, type Renderable } from "../output";
69
- import { type AgentsResult, applyAgentsBridge, bridgeActionColor, renderTrailer } from "./agents";
70
- import { optionValues, parseCommandArgs, usage } from "./args";
71
- import { applyCodexBridge, type CodexBridgeResult } from "./codex-bridge";
72
- import { assertNoSymlinkInPath, createIfAbsent, ensureDir } from "./fswrite";
73
- import { defaultAdapter } from "./link";
74
- import { applyScaffold, TARGETS as SCAFFOLD_TARGETS, type ScaffoldResult } from "./scaffold";
75
-
76
- /** The backlog-coupling capability check's outcome, folded into {@link InitResult} when it ran. */
77
- export interface InitBacklogCheck {
78
- /** Always `true` when this field is present at all — kept explicit (vs. field presence alone) so a `--json` consumer can branch without an `in` check. */
79
- readonly checked: true;
80
- /** Whether a `--json`-capable `backlog` was found on PATH. */
81
- readonly capable: boolean;
82
- /** The reported `backlog --version`, when capable. */
83
- readonly version?: string;
84
- /** The advisory message (also written to stderr) when NOT capable. */
85
- readonly warning?: string;
86
- }
87
-
88
- /** The result of a `lore init` run: the base scaffold, plus whichever optional consumers ran. */
89
- export interface InitResult {
90
- /** The repo root the bundle was initialized in. */
91
- root: string;
92
- /** Repo-relative POSIX paths created this run, in scaffold order (base OKF bundle only). */
93
- created: string[];
94
- /** Repo-relative POSIX paths that already existed and were left untouched (base OKF bundle only). */
95
- skipped: string[];
96
- /** Whether the interactive wizard ran this invocation. */
97
- interactive: boolean;
98
- /** The agent bridge's result, present iff this run set it up (wizard "yes", or `--agents`). */
99
- agents?: AgentsResult;
100
- /** Codex bridge result, present iff Codex setup was selected or explicitly requested. */
101
- codex?: CodexBridgeResult;
102
- /** One entry per downstream doc-site/vault actually scaffolded this run (wizard picks, `--scaffold`, `--obsidian`); empty when none were requested. */
103
- scaffolds: ScaffoldResult[];
104
- /** The backlog-coupling capability check's outcome, present iff it ran this invocation. */
105
- backlog?: InitBacklogCheck;
106
- }
107
-
108
- /** The interactive wizard's minimal prompt vocabulary — confirm (yes/no) and choose (one of a fixed list). Injected so the wizard is unit-testable without a real terminal. */
109
- export interface InitPrompter {
110
- /** Ask a yes/no question; an empty answer (bare Enter) resolves to `defaultValue`. */
111
- confirm(question: string, defaultValue: boolean): Promise<boolean>;
112
- /** Ask the user to pick one of `choices`; an empty or unrecognized answer resolves to `defaultValue`. */
113
- choose(question: string, choices: readonly string[], defaultValue: string): Promise<string>;
114
- /** Release the prompter's I/O resources (the real implementation's `readline` interface). */
115
- close(): void;
116
- }
117
-
118
- /** Options for {@link runInit}; `root`, `clock`, the streams, the TTY gate, the prompter, and the backlog adapter are all injectable for tests. */
119
- export interface InitOptions {
120
- /** The repo root to initialize. */
121
- root: string;
122
- /** The resolved output mode/color (from `output.ts`). */
123
- output: OutputContext;
124
- /** The command's normalized tokens from Commander. */
125
- args?: readonly string[];
126
- /** Clock seam for the root index timestamp (and a fresh scaffold's timestamp); defaults to the real wall clock. */
127
- clock?: () => Date;
128
- /** stdout sink; defaults to `process.stdout`. */
129
- stdout?: Writer;
130
- /** stderr sink for the backlog-check advisory; defaults to `process.stderr`. */
131
- stderr?: Writer;
132
- /**
133
- * Whether STDIN is an interactive terminal — one half of the wizard's TTY gate (AC#2). Resolved
134
- * once at the CLI boundary (`cli.ts`'s `run`, mirroring its own `isTTY`/`stderrIsTTY` handling) and
135
- * handed in here as a plain boolean; this module never reads `process.stdin.isTTY` itself. Defaults
136
- * to `false` (non-interactive) so an omitted value can never accidentally enable a blocking prompt —
137
- * the safe default for every existing caller (tests, `lore-setup.sh`, CI) that predates this flag.
138
- */
139
- stdinIsTTY?: boolean;
140
- /**
141
- * Whether STDERR is an interactive terminal — the wizard's OTHER required condition (review
142
- * round 2, BLOCKING-1). Every wizard question is written to stderr (cli-contract §4: stdout stays
143
- * exclusively `init`'s own envelope), so `stdinIsTTY` alone is not sufficient — a caller that
144
- * redirects only stderr (`lore init >out 2>/dev/null`, or the universal shell idiom
145
- * `cmd >/dev/null 2>&1` that `lore-setup.sh` itself uses) still has a readable stdin, and would
146
- * otherwise block forever on a prompt nobody can see. Resolved once at the CLI boundary exactly
147
- * like {@link stdinIsTTY} (`cli.ts` already computes this for the error-color gate; LORE-260 round
148
- * 2 threads the SAME resolved value here instead of leaving it unset). Defaults to `false` for the
149
- * same "never accidentally enable a blocking prompt" reason.
150
- */
151
- stderrIsTTY?: boolean;
152
- /**
153
- * Whether `--json` was requested for this run. A machine-readable invocation must never prompt —
154
- * even sitting at a real, fully-interactive terminal (review round 2, BLOCKING-1's sibling
155
- * finding) — since a script piping `--json` output can never answer a wizard question. Kept as its
156
- * own explicit boolean rather than derived from {@link InitOptions.output}'s `mode` (which governs
157
- * rendering only, per `output.ts`'s documented single-responsibility split): `output.mode` is
158
- * always `"json"` in exactly this case for a real `cli.ts` invocation (`resolveMode` maps `--json`
159
- * to `mode: "json"` unconditionally), but a unit test may also hand-build a `mode: "json"`
160
- * `OutputContext` purely so it can `JSON.parse` the result for assertions while still wanting the
161
- * wizard to run (see `test/init.test.ts`'s wizard-path tests) — a separate flag lets that stay
162
- * possible without conflating "how do we render" with "was `--json` actually on the command line".
163
- * Defaults to `false` (not requested) when omitted.
164
- */
165
- jsonRequested?: boolean;
166
- /** The interactive wizard's I/O seam; defaults to a real `node:readline/promises` session over stdin/stderr. Injected in tests so the wizard never touches a real terminal. */
167
- prompter?: InitPrompter;
168
- /** The Backlog adapter for the coupling capability check; defaults to the real `backlog` binary on PATH. */
169
- adapter?: BacklogAdapter;
170
- /** Injectable executable discovery for the interactive agent choices. */
171
- agentAvailability?: () => AgentAvailability;
172
- }
173
-
174
- export interface AgentAvailability {
175
- readonly claude: boolean;
176
- readonly codex: boolean;
177
- }
178
-
179
- /** The parsed, validated `lore init` arguments. */
180
- interface InitArgs {
181
- /** `--yes` (or its `--non-interactive` alias, NIT-2): force the non-interactive path (with defaults) even on a TTY — the npm-init `-y` equivalent. */
182
- yes: boolean;
183
- /** `--agents`: also set up the Claude Code agent bridge. */
184
- agents: boolean;
185
- /** `--codex`: set up the Codex bridge. */
186
- codex: boolean;
187
- /** `--scaffold <target>` (repeatable) and/or `--obsidian`, deduped; targets from {@link SCAFFOLD_TARGETS}. */
188
- scaffolds: string[];
189
- /** `--no-backlog`: skip the backlog-coupling capability check entirely. */
190
- noBacklog: boolean;
191
- /** `--check-backlog`: run the backlog-coupling capability check even with no other flag requesting it. */
192
- checkBacklog: boolean;
193
- }
194
-
195
- /**
196
- * Run `lore init` against `options.root`: scaffold the base bundle idempotently (unchanged from
197
- * before LORE-260), then resolve the optional consumers — via the interactive wizard on a bare TTY
198
- * invocation, or from flags otherwise — apply whichever were chosen (also idempotently), render the
199
- * result, and return the exit code. A filesystem permission failure throws a `denied` {@link
200
- * LoreError}; a scaffold collision throws `conflict`; a bad flag throws `usage`; any other
201
- * unexpected IO error propagates to the CLI's top-level handler.
202
- *
203
- * Stays a plain (non-`async`) function, like `commands/check.ts`'s `runCheck`: the common path (no
204
- * flags, no implied backlog check) returns a plain `number`, so every pre-LORE-260 synchronous
205
- * caller — this file's own original tests, and the router's `--bogus`/positional usage-error paths —
206
- * is untouched. Only the wizard, or a backlog check actually requested/implied, return a `Promise`.
207
- */
208
- export function runInit(options: InitOptions): number | Promise<number> {
209
- const parsed = parseInitArgs(options.args ?? []);
210
- const stdinIsTTY = options.stdinIsTTY ?? false;
211
- // BLOCKING-1 (review round 2): BOTH streams must be a real terminal — every wizard question is
212
- // written to stderr, so a redirected stderr with a still-TTY stdin must never engage the wizard
213
- // (the reader can't see the prompt to answer it). `--json` is an independent third veto: a
214
- // machine-readable run must never prompt even at a genuinely interactive terminal.
215
- const stderrIsTTY = options.stderrIsTTY ?? false;
216
- const jsonRequested = options.jsonRequested ?? false;
217
- const interactive = stdinIsTTY && stderrIsTTY && !jsonRequested && !anyFlagGiven(parsed);
218
-
219
- const clock = options.clock ?? (() => new Date());
220
- // Honor a pre-existing `.lore/profile.toml` so `init` scaffolds schemas for a project's custom
221
- // types; with none present this is the built-in story-convention profile (zero-config).
222
- const profile = loadProfile({ root: options.root });
223
- const plan = buildScaffold({ timestamp: clock().toISOString(), profile });
224
-
225
- for (const dir of plan.dirs) {
226
- // LORE-77/LORE-93: ensureDir itself refuses a pre-existing symlink at (or above) this
227
- // directory before its mkdirSync gets a chance to transparently walk through it.
228
- ensureDir(options.root, dir);
229
- }
230
-
231
- const created: string[] = [];
232
- const skipped: string[] = [];
233
- for (const file of plan.files) {
234
- assertNoSymlinkInPath(options.root, file.path);
235
- if (createIfAbsent(join(options.root, file.path), file.contents, file.path)) {
236
- created.push(file.path);
237
- } else {
238
- skipped.push(file.path);
239
- }
240
- }
241
- const base = { root: options.root, created, skipped };
242
-
243
- if (interactive) {
244
- return runInteractiveWizard(options, base);
245
- }
246
-
247
- const scaffoldTargets = [...new Set(parsed.scaffolds)];
248
- const agents = parsed.agents ? applyAgentsBridge({ root: options.root, force: false, check: false }) : undefined;
249
- const codex = parsed.codex ? applyCodexBridge({ root: options.root, force: false, check: false }) : undefined;
250
- const scaffolds = scaffoldTargets.map((target) => applyScaffold({ root: options.root, target, force: false, clock }));
251
-
252
- // The backlog check is advisory-only (never fails the run) and, off-TTY/via-flags, runs only when
253
- // it's actually relevant: explicitly requested (`--check-backlog`), or implied by onboarding a
254
- // consumer that depends on the coupling (`--agents`/`--scaffold`/`--obsidian`) — unless the user
255
- // opted all the way out with `--no-backlog`. A completely bare `lore init` therefore never spawns
256
- // a `backlog` subprocess, exactly as before this task.
257
- const shouldCheckBacklog =
258
- parsed.checkBacklog || (!parsed.noBacklog && (parsed.agents || parsed.codex || scaffoldTargets.length > 0));
259
- if (!shouldCheckBacklog) {
260
- const result: InitResult = { ...base, interactive: false, agents, codex, scaffolds, backlog: undefined };
261
- emit(initRenderable(result), options.output, options.stdout);
262
- return EXIT_OK;
263
- }
264
-
265
- const warnings = new WarningCollector();
266
- return probeBacklogCapability(options, warnings).then((backlog) => {
267
- warnings.flush({ color: options.output.color, stderr: options.stderr ?? process.stderr });
268
- const result: InitResult = { ...base, interactive: false, agents, codex, scaffolds, backlog };
269
- emit(initRenderable(result), options.output, options.stdout);
270
- return EXIT_OK;
271
- });
272
- }
273
-
274
- /**
275
- * The interactive wizard (AC#1): ask the three fold-in questions over the injected {@link
276
- * InitPrompter} (or a real `readline` session over stdin/stderr — prompts and the whole UI live on
277
- * **stderr**, per cli-contract §4: stdout must stay exclusively `init`'s own envelope), apply
278
- * whichever were chosen (idempotently, via the exact same core primitives the flag path uses), then
279
- * ALWAYS run the backlog-coupling detection (a fact-check, not a choice — mirrors AC#1's "detection"
280
- * wording, not a fourth question) before rendering the combined result.
281
- */
282
- async function runInteractiveWizard(
283
- options: InitOptions,
284
- base: { root: string; created: string[]; skipped: string[] },
285
- ): Promise<number> {
286
- const prompter = options.prompter ?? createRealPrompter();
287
- const scaffoldTargets: string[] = [];
288
- let wantAgents = false;
289
- let wantCodex = false;
290
- try {
291
- const available = detectAgentAvailability(options);
292
- if (available.claude) {
293
- wantAgents = await prompter.confirm("Set up the Claude Code agent bridge (SKILL.md + CLAUDE.md nudge)?", true);
294
- }
295
- if (available.codex) {
296
- wantCodex = await prompter.confirm("Set up the Codex agent bridge (SKILL.md + AGENTS.md nudge)?", true);
297
- }
298
- const site = await prompter.choose("Scaffold a downstream docs site?", ["none", "mkdocs", "docusaurus"], "none");
299
- if (site !== "none") {
300
- scaffoldTargets.push(site);
301
- }
302
- const wantObsidian = await prompter.confirm("Also scaffold an Obsidian vault config (docs/.obsidian)?", false);
303
- if (wantObsidian) {
304
- scaffoldTargets.push("obsidian");
305
- }
306
- } finally {
307
- prompter.close();
308
- }
309
-
310
- const clock = options.clock ?? (() => new Date());
311
- const agents = wantAgents ? applyAgentsBridge({ root: options.root, force: false, check: false }) : undefined;
312
- const codex = wantCodex ? applyCodexBridge({ root: options.root, force: false, check: false }) : undefined;
313
- const scaffolds = scaffoldTargets.map((target) => applyScaffold({ root: options.root, target, force: false, clock }));
314
-
315
- const warnings = new WarningCollector();
316
- const backlog = await probeBacklogCapability(options, warnings);
317
- warnings.flush({ color: options.output.color, stderr: options.stderr ?? process.stderr });
318
-
319
- const result: InitResult = { ...base, interactive: true, agents, codex, scaffolds, backlog };
320
- emit(initRenderable(result), options.output, options.stdout);
321
- return EXIT_OK;
322
- }
323
-
324
- /**
325
- * A real, interactive {@link InitPrompter} over the given streams (defaulting to
326
- * `process.stdin`/`process.stderr`) — constructed only when the wizard actually runs and no
327
- * test-injected prompter was given. `streams` is a parameter (not hard-coded) purely so a unit test
328
- * can exercise this function's own EOF handling over a fake stream pair, never a real terminal.
329
- *
330
- * **BLOCKING-2 (review round 2):** `readline/promises`' `rl.question()` never settles when its input
331
- * stream hits EOF (Ctrl-D, or stdin simply closing) — confirmed live: a pending `question()` call
332
- * neither resolves nor rejects, so a naive implementation left the wizard's promise abandoned
333
- * forever, `finally { prompter.close() }` never ran, and the process fell through to exit `0` with
334
- * `process.exitCode` unset and zero stdout bytes (a broken `--json | jq` contract on top of a
335
- * half-applied run). Every `question()` call is now raced against the readline interface's own
336
- * `close` event via {@link ask}: the interface closes on EOF regardless of whether `question()`
337
- * itself ever settles, so the race always resolves. **Disposition: error out, not silently default**
338
- * (documented in ADR-0017 and this task's Implementation Notes) — an early close throws a `usage`
339
- * {@link LoreError} rather than silently resolving to each question's default value, for the same
340
- * reason as BLOCKING-1: a user who hits Ctrl-D gets no visual confirmation of what happened, so
341
- * guessing an answer on their behalf and proceeding is exactly the kind of invisible side effect
342
- * BLOCKING-1 already ruled out. The rejection propagates out of `confirm`/`choose`, unwinds
343
- * `runInteractiveWizard`'s `try`/`finally` (which still calls `prompter.close()` — safe here since
344
- * `rl.close()` is idempotent and the interface is already closing), and reaches `cli.ts`'s async
345
- * error path, which renders the diagnostic and maps `usage` to exit `2`.
346
- *
347
- * The `closedEarly` promise is given a standalone `.catch(() => {})` in addition to being raced,
348
- * because the *normal* (non-EOF) completion path also ends in `prompter.close()` — every question
349
- * answered, `runInteractiveWizard`'s `finally` calls `close()` intentionally, which emits `close` for
350
- * the FIRST time in that path and would otherwise reject an unobserved promise (an unhandled
351
- * rejection) after every race has already settled successfully.
352
- */
353
- export function createRealPrompter(
354
- streams: { input: NodeJS.ReadableStream; output: NodeJS.WritableStream } = {
355
- input: process.stdin,
356
- output: process.stderr,
357
- },
358
- ): InitPrompter {
359
- const rl = readline.createInterface({ input: streams.input, output: streams.output });
360
- const closedEarly = new Promise<never>((_resolve, reject) => {
361
- rl.once("close", () => {
362
- reject(
363
- new LoreError(
364
- "usage",
365
- "stdin closed or the wizard was interrupted before it finished (EOF/Ctrl-D or Ctrl-C)",
366
- "answer every prompt, or run prompt-free with `lore init --yes` (or --claude/--codex/--scaffold <target>/--obsidian/--no-backlog/--check-backlog)",
367
- ),
368
- );
369
- });
370
- });
371
- // Prevents an "unhandled promise rejection" once the wizard finishes normally and its own
372
- // `prompter.close()` call fires `close` for the first time, after every `ask()` race is already
373
- // settled and nothing is awaiting `closedEarly` anymore — see the doc comment above.
374
- closedEarly.catch(() => {});
375
-
376
- /** Race one `rl.question()` call against the interface's own `close` event (see the doc above). */
377
- function ask(promptText: string): Promise<string> {
378
- return Promise.race([rl.question(promptText), closedEarly]);
379
- }
380
-
381
- return {
382
- async confirm(question, defaultValue) {
383
- const suffix = defaultValue ? "Y/n" : "y/N";
384
- const raw = (await ask(`${question} [${suffix}] `)).trim().toLowerCase();
385
- if (raw === "") {
386
- return defaultValue;
387
- }
388
- return raw === "y" || raw === "yes";
389
- },
390
- async choose(question, choices, defaultValue) {
391
- const raw = (await ask(`${question} (${choices.join("/")}) [${defaultValue}] `)).trim().toLowerCase();
392
- return choices.includes(raw) ? raw : defaultValue;
393
- },
394
- close() {
395
- rl.close();
396
- },
397
- };
398
- }
399
-
400
- /**
401
- * Run the backlog-coupling capability check (AC#1/AC#4): probe the injected {@link
402
- * InitOptions.adapter} (defaulting to the real `backlog` binary on PATH). Never throws — a
403
- * missing/incapable binary is recorded as `warnings` advisory (stderr) plus the returned {@link
404
- * InitBacklogCheck}, never a failed `lore init` run, since the base scaffold (and any agent
405
- * bridge/doc-site scaffold already applied) succeeded regardless of whether Backlog.md coupling is
406
- * available yet.
407
- */
408
- async function probeBacklogCapability(options: InitOptions, warnings: WarningCollector): Promise<InitBacklogCheck> {
409
- const adapter = options.adapter ?? defaultAdapter(options.root);
410
- try {
411
- const capability = await adapter.probe();
412
- return { checked: true, capable: true, version: capability.version };
413
- } catch (err) {
414
- const message = err instanceof Error ? err.message : String(err);
415
- const hint = err instanceof LoreError ? err.hint : undefined;
416
- warnings.add(`backlog coupling unavailable: ${message}${hint ? ` — ${hint}` : ""}`);
417
- return { checked: true, capable: false, warning: message };
418
- }
419
- }
420
-
421
- /** Whether any of `lore init`'s own flags was passed — the signal that overrides a bare-TTY invocation into the non-interactive path (AC#2). */
422
- function anyFlagGiven(parsed: InitArgs): boolean {
423
- return (
424
- parsed.yes ||
425
- parsed.agents ||
426
- parsed.codex ||
427
- parsed.scaffolds.length > 0 ||
428
- parsed.noBacklog ||
429
- parsed.checkBacklog
430
- );
431
- }
432
-
433
- /**
434
- * Parse `init`'s tokens: no positionals (unchanged from before LORE-260 — a bare/`--`-terminated
435
- * positional is still a `usage` error, byte-identical wording to the router's old blanket
436
- * `rejectCommandArgs` guard so every pre-existing regression test keeps passing), plus the boolean
437
- * `--yes` (alias `--non-interactive`, NIT-2)/`--agents`/`--obsidian`/`--no-backlog`/`--check-backlog`
438
- * and the repeatable value flag `--scaffold <target>`. An unknown flag, an invalid `--scaffold`
439
- * target, a stray positional, or the mutually-exclusive `--no-backlog`+`--check-backlog` pair all
440
- * throw a `usage` {@link LoreError} (exit `2`) before any scaffold work runs.
441
- */
442
- function parseInitArgs(args: readonly string[]): InitArgs {
443
- const parsed = parseCommandArgs(args, "init");
444
- const yes = parsed.flags.has("yes") || parsed.flags.has("non-interactive");
445
- const agents = parsed.flags.has("agents") || parsed.flags.has("claude");
446
- const codex = parsed.flags.has("codex");
447
- const noBacklog = parsed.flags.has("no-backlog");
448
- const checkBacklog = parsed.flags.has("check-backlog");
449
- const scaffolds: string[] = [];
450
- for (const value of optionValues(parsed, "scaffold")) {
451
- if (value === "") {
452
- throw usage("--scaffold needs a value", "pass a value, e.g. `--scaffold mkdocs`");
453
- }
454
- if (!SCAFFOLD_TARGETS.has(value)) {
455
- throw usage(`unknown scaffold target "${value}"`, `valid targets are ${[...SCAFFOLD_TARGETS].join(", ")}`);
456
- }
457
- if (!scaffolds.includes(value)) scaffolds.push(value);
458
- }
459
- if (parsed.flags.has("obsidian") && !scaffolds.includes("obsidian")) scaffolds.push("obsidian");
460
- if (parsed.positionals.length > 0) {
461
- // Byte-identical wording to the router's pre-LORE-260 `rejectCommandArgs` guard (cli.ts), which
462
- // used to reject EVERY token this command received — `lore init` still takes no positionals.
463
- throw usage(
464
- `\`lore init\` takes no arguments, got "${parsed.positionals[0]}"`,
465
- "run `lore init` with no positional arguments",
466
- { command: "init", unexpected: [...parsed.positionals] },
467
- );
468
- }
469
- if (noBacklog && checkBacklog) {
470
- throw usage(
471
- "--no-backlog and --check-backlog are mutually exclusive",
472
- "pass at most one of --no-backlog / --check-backlog",
473
- );
474
- }
475
- return { yes, agents, codex, scaffolds, noBacklog, checkBacklog };
476
- }
477
-
478
- /** Detect installed agents without making absence or a broken PATH fatal to onboarding. */
479
- function detectAgentAvailability(options: InitOptions): AgentAvailability {
480
- if (options.agentAvailability) return options.agentAvailability();
481
- try {
482
- return { claude: Bun.which("claude") !== null, codex: Bun.which("codex") !== null };
483
- } catch {
484
- return { claude: false, codex: false };
485
- }
486
- }
487
-
488
- /** The per-result-type rendering bundle for `init` (output.ts dispatches on the mode). */
489
- function initRenderable(data: InitResult): Renderable<InitResult> {
490
- return { kind: "init", data, pretty: renderPretty, plain: renderPlain };
491
- }
492
-
493
- /** Human view: the base scaffold summary, then a section per optional consumer that ran this run. */
494
- function renderPretty(data: InitResult, opts: { color: boolean }): string {
495
- const head = data.created.length
496
- ? `Initialized lore bundle at ${data.root}`
497
- : `lore bundle already initialized at ${data.root} (nothing to create)`;
498
- const lines = [head];
499
- for (const path of data.created) {
500
- lines.push(` ${paint("+", ANSI.green, opts.color)} ${path}`);
501
- }
502
- for (const path of data.skipped) {
503
- lines.push(` ${paint(`· ${path} (exists)`, ANSI.dim, opts.color)}`);
504
- }
505
- if (data.agents) {
506
- lines.push("Claude Code bridge:");
507
- for (const file of data.agents.files) {
508
- // "protected" is a warning, not a success (LORE-260 review round 2, MINOR-4): a hand-edited
509
- // file was left untouched, which is meaningfully different from "unchanged" (nothing to do)
510
- // and must not be painted the same green as an actual write. Shared with `lore agents`' own
511
- // renderer (LORE-267) so the two commands cannot diverge on this mapping again.
512
- lines.push(` ${paint(file.action, bridgeActionColor(file.action), opts.color)} ${file.path}`);
513
- }
514
- // Reuse `lore agents`' own trailer verbatim (MINOR-4) rather than dropping it: a `protected`
515
- // file with no visible remedy reads as silent success (LORE-129 established this line as
516
- // load-bearing).
517
- const agentsTrailer = renderTrailer(data.agents);
518
- if (agentsTrailer !== undefined) {
519
- lines.push(paint(agentsTrailer, ANSI.yellow, opts.color));
520
- }
521
- }
522
- if (data.codex) {
523
- lines.push("Codex bridge:");
524
- for (const file of data.codex.files) {
525
- lines.push(` ${paint(file.action, bridgeActionColor(file.action), opts.color)} ${file.path}`);
526
- }
527
- }
528
- for (const scaffold of data.scaffolds) {
529
- lines.push(`Scaffold (${scaffold.target}):`);
530
- if (scaffold.files.length === 0) {
531
- lines.push(` ${paint("already up to date", ANSI.dim, opts.color)}`);
532
- }
533
- for (const file of scaffold.files) {
534
- lines.push(` ${paint(file.action, ANSI.green, opts.color)} ${file.path}`);
535
- }
536
- }
537
- if (data.backlog) {
538
- lines.push(
539
- data.backlog.capable
540
- ? `backlog: --json-capable${data.backlog.version ? ` (v${data.backlog.version})` : ""}`
541
- : paint("backlog: not --json-capable — see the warning above (coupling unavailable)", ANSI.yellow, opts.color),
542
- );
543
- }
544
- if (data.interactive) {
545
- lines.push("Run `lore instructions` for the canonical agent loop.");
546
- }
547
- return lines.join("\n");
548
- }
549
-
550
- /** ANSI-free, diff-stable view: one line per base-scaffold path, then one line per optional-consumer action. */
551
- function renderPlain(data: InitResult): string {
552
- const lines = [...data.created.map((path) => `created ${path}`), ...data.skipped.map((path) => `exists ${path}`)];
553
- if (data.agents) {
554
- for (const file of data.agents.files) {
555
- lines.push(`agents-${file.action} ${file.path}`);
556
- }
557
- const agentsTrailer = renderTrailer(data.agents);
558
- if (agentsTrailer !== undefined) {
559
- lines.push(agentsTrailer);
560
- }
561
- }
562
- if (data.codex) {
563
- for (const file of data.codex.files) {
564
- lines.push(`codex-${file.action} ${file.path}`);
565
- }
566
- }
567
- for (const scaffold of data.scaffolds) {
568
- // NIT-1 (review round 2): an already-up-to-date scaffold produced NO line at all in plain mode
569
- // (renderPretty said "already up to date"; renderPlain said nothing), so a --plain consumer
570
- // couldn't tell the step ran versus never having been requested.
571
- if (scaffold.files.length === 0) {
572
- lines.push(`scaffold-${scaffold.target} up-to-date`);
573
- continue;
574
- }
575
- for (const file of scaffold.files) {
576
- lines.push(`scaffold-${scaffold.target}-${file.action} ${file.path}`);
577
- }
578
- }
579
- if (data.backlog) {
580
- lines.push(data.backlog.capable ? "backlog capable" : "backlog incapable");
581
- }
582
- return lines.join("\n");
583
- }
@@ -1,91 +0,0 @@
1
- /**
2
- * commands/instructions.ts — `lore instructions [<topic>]` (cli-surface §instructions).
3
- *
4
- * The thin, read-only layer over the static topic registry in
5
- * `core/instructions.ts`: it parses the one optional positional, looks up the
6
- * topic, and emits it. There is no bundle to load and no config to read, so
7
- * unlike most commands this one needs neither `root` nor a `WarningCollector`.
8
- *
9
- * With no `<topic>` it prints `overview` (the canonical loop + a topic index);
10
- * an unrecognized `<topic>` is a `not_found` error (exit 3) whose hint lists
11
- * the valid keys. Argument parsing goes through `args.ts`'s shared Commander parser
12
- * with no known flags, so any `-`-prefixed token or a second positional is a
13
- * `usage` error (exit 2), matching every other command's strict parsing.
14
- */
15
-
16
- import { findInstructionTopic, INSTRUCTION_TOPICS, type InstructionTopic } from "../core/instructions";
17
- import { ANSI, EXIT_OK, LoreError, paint, type Writer } from "../errors";
18
- import { emit, type OutputContext, type Renderable } from "../output";
19
- import { parseCommandArgs, usage } from "./args";
20
-
21
- /** Options for {@link runInstructions}; the streams are injectable for tests. */
22
- export interface InstructionsOptions {
23
- /** The resolved output mode/color (from `output.ts`). */
24
- output: OutputContext;
25
- /** The command's normalized positional tokens from Commander. */
26
- args: readonly string[];
27
- /** stdout sink; defaults to `process.stdout`. */
28
- stdout?: Writer;
29
- }
30
-
31
- /** The `instructions.text` payload: the requested topic plus the full topic index, so a `--json` caller can discover the other keys without a second request. */
32
- export interface InstructionsData {
33
- /** The topic actually served (`overview` when no `<topic>` was given). */
34
- topic: string;
35
- /** The topic's one-line heading. */
36
- title: string;
37
- /** The topic's full guidance body. */
38
- body: string;
39
- /** Every topic `lore instructions` can serve, in index order. */
40
- topics: ReadonlyArray<{ key: string; title: string }>;
41
- }
42
-
43
- /** Run `lore instructions`: parse the optional `<topic>`, look it up, emit it, and return `0`. */
44
- export function runInstructions(options: InstructionsOptions): number {
45
- const key = parseInstructionsArgs(options.args);
46
- const topic = findInstructionTopic(key);
47
- if (topic === undefined) {
48
- const validKeys = INSTRUCTION_TOPICS.map((t) => t.key).join(", ");
49
- throw new LoreError(
50
- "not_found",
51
- `unknown instructions topic "${key}"`,
52
- `valid topics: ${validKeys}; run \`lore instructions\` for the overview`,
53
- { topic: key },
54
- );
55
- }
56
- emit(instructionsRenderable(topic), options.output, options.stdout);
57
- return EXIT_OK;
58
- }
59
-
60
- /** Parse `instructions`' tokens via the shared parser (no known flags): at most one positional (the topic key, defaulting to `overview`); any flag or second positional is a `usage` error. */
61
- function parseInstructionsArgs(args: readonly string[]): string {
62
- const { positionals } = parseCommandArgs(args, "instructions");
63
- if (positionals.length > 1) {
64
- throw usage(`unexpected argument "${positionals[1]}"`, "run `lore instructions [<topic>]`");
65
- }
66
- return positionals[0] ?? "overview";
67
- }
68
-
69
- /** Build the `instructions.text` {@link Renderable} for one topic. */
70
- function instructionsRenderable(topic: InstructionTopic): Renderable<InstructionsData> {
71
- const data: InstructionsData = {
72
- topic: topic.key,
73
- title: topic.title,
74
- body: topic.body,
75
- topics: INSTRUCTION_TOPICS.map(({ key, title }) => ({ key, title })),
76
- };
77
- return { kind: "instructions.text", data, pretty: renderPretty, plain: renderPlain };
78
- }
79
-
80
- /** `<title>` heading (painted when `color`) + a blank line + the body. Shared by pretty/plain so the two differ only in color. */
81
- function render(data: InstructionsData, color: boolean): string {
82
- return `${paint(data.title, ANSI.green, color)}\n\n${data.body}`;
83
- }
84
-
85
- function renderPretty(data: InstructionsData, opts: { color: boolean }): string {
86
- return render(data, opts.color);
87
- }
88
-
89
- function renderPlain(data: InstructionsData): string {
90
- return render(data, false);
91
- }