@opum-ai/lore 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
@@ -1,301 +0,0 @@
1
- /** Thin CLI layer for singular task-scoped agent context profiles. */
2
-
3
- import { readFileSync } from "node:fs";
4
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
- import type { BacklogAdapter } from "../adapters/backlog";
6
- import { type AgentContextExport, compileAgentContext, renderAgentContextMarkdown } from "../core/agent-context";
7
- import {
8
- type AgentProfile,
9
- findAgentProfile,
10
- loadAgentProfiles,
11
- validateAgentProfileReferences,
12
- } from "../core/agent-profile";
13
- import { compareCodeUnits } from "../core/order";
14
- import { loadReferenceRetrievalGraph, type RetrievalGraphLoader } from "../core/retrieval";
15
- import { EXIT_OK, LoreError, WarningCollector, type Writer } from "../errors";
16
- import { emit, type OutputContext, type Renderable } from "../output";
17
- import { assertFlagAtMostOnce, parseCommandArgs, singleOptionValue, usage } from "./args";
18
- import { readSource } from "./discover";
19
- import { assertNoSymlinkInPath, classifyExistingFile, ensureDir, writeFileAtomic } from "./fswrite";
20
-
21
- export interface AgentCommandOptions {
22
- readonly root: string;
23
- readonly output: OutputContext;
24
- readonly args: readonly string[];
25
- readonly stdout?: Writer;
26
- readonly stderr?: Writer;
27
- readonly adapter?: BacklogAdapter;
28
- readonly retrieval?: RetrievalGraphLoader;
29
- readonly readTaskFile?: (path: string) => string;
30
- }
31
-
32
- export interface AgentProfilesResult {
33
- readonly profiles: readonly AgentProfileSummary[];
34
- }
35
-
36
- export interface AgentProfileSummary {
37
- readonly name: string;
38
- readonly kind: AgentProfile["kind"];
39
- readonly description: string;
40
- readonly maxTokens: number;
41
- readonly sourceCount: number;
42
- readonly delegateCount: number;
43
- }
44
-
45
- export interface AgentProfileResult extends AgentProfileSummary {
46
- readonly pinned: readonly string[];
47
- readonly sources: readonly string[];
48
- readonly delegates: readonly string[];
49
- readonly path: string;
50
- }
51
-
52
- type AgentAction =
53
- | { readonly kind: "list" }
54
- | { readonly kind: "show"; readonly name: string }
55
- | {
56
- readonly kind: "context";
57
- readonly name: string;
58
- readonly task?: string;
59
- readonly taskFile?: string;
60
- readonly maxTokens?: number;
61
- readonly out?: string;
62
- readonly force: boolean;
63
- };
64
-
65
- export async function runAgent(options: AgentCommandOptions): Promise<number> {
66
- const action = parseAgentArgs(options.args);
67
- const snapshot = loadAgentProfiles(options.root);
68
- const advisories = new WarningCollector();
69
- const retrieval = await (options.retrieval ?? loadReferenceRetrievalGraph)({
70
- root: options.root,
71
- warnings: advisories,
72
- adapter: options.adapter,
73
- });
74
- try {
75
- validateAgentProfileReferences(snapshot, retrieval.graph);
76
- advisories.flush({ color: options.output.color, stderr: options.stderr });
77
- if (action.kind === "list") {
78
- const data: AgentProfilesResult = {
79
- profiles: [...snapshot.profiles.values()].sort((a, b) => compareCodeUnits(a.name, b.name)).map(summary),
80
- };
81
- emit(profilesRenderable(data), options.output, options.stdout);
82
- return EXIT_OK;
83
- }
84
- const profile = findAgentProfile(snapshot, action.name);
85
- if (action.kind === "show") {
86
- const data: AgentProfileResult = {
87
- ...summary(profile),
88
- pinned: profile.pinned.map((reference) => reference.normalized),
89
- sources: profile.sources.map((reference) => reference.normalized),
90
- delegates: [...profile.delegates],
91
- path: profile.path,
92
- };
93
- emit(profileRenderable(data), options.output, options.stdout);
94
- return EXIT_OK;
95
- }
96
-
97
- const task = resolveTask(action, options);
98
- let data = compileAgentContext(snapshot, retrieval.graph, profile.name, task, action.maxTokens);
99
- const markdown = renderAgentContextMarkdown(data);
100
- if (action.out !== undefined) {
101
- const target = confineOutFile(action.out, options.root);
102
- assertNoSymlinkInPath(options.root, target.relPath);
103
- const state = classifyExistingFile(target.absPath, markdown);
104
- if (state === "differs" && !action.force) {
105
- throw new LoreError(
106
- "conflict",
107
- `cannot overwrite differing context file ${target.relPath}`,
108
- "pass --force to replace it, choose another --out path, or remove the existing file",
109
- { path: target.relPath },
110
- );
111
- }
112
- const writeAction = state === "missing" ? "created" : state === "unchanged" ? "unchanged" : "updated";
113
- if (state !== "unchanged") {
114
- ensureDir(options.root, dirname(target.relPath));
115
- writeFileAtomic(target.absPath, markdown, target.relPath);
116
- }
117
- data = { ...data, write: { path: target.relPath, action: writeAction } };
118
- }
119
- emit(contextRenderable(data), options.output, options.stdout);
120
- return EXIT_OK;
121
- } finally {
122
- await retrieval.dispose?.();
123
- }
124
- }
125
-
126
- function parseAgentArgs(args: readonly string[]): AgentAction {
127
- const parsed = parseCommandArgs(args, "agent");
128
- for (const flag of ["task", "task-file", "max-tokens", "out", "force"]) {
129
- assertFlagAtMostOnce(parsed, flag);
130
- }
131
- const action = parsed.positionals[0];
132
- if (action === undefined) {
133
- throw usage(
134
- "`lore agent` needs an action",
135
- "run `lore agent list`, `lore agent show <name>`, or `lore agent context <name> --task <text>`",
136
- );
137
- }
138
- if (action === "list") {
139
- assertArityAndNoFlags(parsed, 1, "`lore agent list` takes no arguments or command flags", "run `lore agent list`");
140
- return { kind: "list" };
141
- }
142
- if (action === "show") {
143
- assertArityAndNoFlags(
144
- parsed,
145
- 2,
146
- "`lore agent show` needs exactly one profile name",
147
- "run `lore agent show <name>`",
148
- );
149
- return { kind: "show", name: parsed.positionals[1] as string };
150
- }
151
- if (action !== "context") {
152
- throw usage(`unknown agent action "${action}"`, "use list, show, or context", { action });
153
- }
154
- if (parsed.positionals.length !== 2) {
155
- throw usage("`lore agent context` needs exactly one profile name", "run `lore agent context <name> --task <text>`");
156
- }
157
- const task = nonEmptyOption(parsed, "task");
158
- const taskFile = nonEmptyOption(parsed, "task-file");
159
- if ((task === undefined) === (taskFile === undefined)) {
160
- throw usage(
161
- "agent context needs exactly one of --task or --task-file",
162
- "pass task text directly or read it from one path (use --task-file - for stdin)",
163
- );
164
- }
165
- const rawMaxTokens = nonEmptyOption(parsed, "max-tokens");
166
- const maxTokens = rawMaxTokens === undefined ? undefined : parsePositiveInteger("--max-tokens", rawMaxTokens);
167
- const out = nonEmptyOption(parsed, "out");
168
- const force = parsed.flags.has("force");
169
- if (force && out === undefined) {
170
- throw usage("--force requires --out", "pass an output path or remove --force");
171
- }
172
- return {
173
- kind: "context",
174
- name: parsed.positionals[1] as string,
175
- ...(task === undefined ? {} : { task }),
176
- ...(taskFile === undefined ? {} : { taskFile }),
177
- ...(maxTokens === undefined ? {} : { maxTokens }),
178
- ...(out === undefined ? {} : { out }),
179
- force,
180
- };
181
- }
182
-
183
- function assertArityAndNoFlags(
184
- parsed: ReturnType<typeof parseCommandArgs>,
185
- arity: number,
186
- message: string,
187
- hint: string,
188
- ): void {
189
- if (parsed.positionals.length !== arity || parsed.flags.size > 0) throw usage(message, hint);
190
- }
191
-
192
- function nonEmptyOption(parsed: ReturnType<typeof parseCommandArgs>, name: string): string | undefined {
193
- const value = singleOptionValue(parsed, name);
194
- if (value === undefined) return undefined;
195
- if (value.trim() === "") throw usage(`--${name} needs a value`, `pass --${name}=<value>`);
196
- return value;
197
- }
198
-
199
- function parsePositiveInteger(flag: string, value: string): number {
200
- if (!/^\d+$/.test(value)) throw usage(`invalid ${flag} "${value}"`, `pass a positive integer, e.g. ${flag} 8000`);
201
- const parsed = Number.parseInt(value, 10);
202
- if (!Number.isSafeInteger(parsed)) throw usage(`${flag} "${value}" is too large`, "pass a smaller integer");
203
- if (parsed < 1) throw usage(`invalid ${flag} "${value}"`, `pass a positive integer, e.g. ${flag} 8000`);
204
- return parsed;
205
- }
206
-
207
- function resolveTask(action: Extract<AgentAction, { kind: "context" }>, options: AgentCommandOptions): string {
208
- if (action.task !== undefined) return action.task;
209
- const path = action.taskFile as string;
210
- if (options.readTaskFile !== undefined) return options.readTaskFile(path);
211
- if (path === "-") {
212
- try {
213
- return readFileSync(0, "utf8");
214
- } catch (cause) {
215
- throw new LoreError("denied", "cannot read task text from stdin", "pipe readable UTF-8 task text to stdin", {
216
- path: "-",
217
- cause: cause instanceof Error ? cause.message : String(cause),
218
- });
219
- }
220
- }
221
- const target = confineRepoFile(path, options.root, "--task-file");
222
- assertNoSymlinkInPath(options.root, target.relPath);
223
- return readSource(target.absPath, target.relPath);
224
- }
225
-
226
- function confineOutFile(out: string, root: string): { absPath: string; relPath: string } {
227
- return confineRepoFile(out, root, "--out");
228
- }
229
-
230
- function confineRepoFile(
231
- path: string,
232
- root: string,
233
- flag: "--out" | "--task-file",
234
- ): { absPath: string; relPath: string } {
235
- const absPath = resolve(root, path);
236
- const rel = relative(root, absPath);
237
- if (isAbsolute(path) || rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
238
- throw usage(`${flag} path "${path}" must name a file inside the repo`, `give ${flag} a repo-relative file path`);
239
- }
240
- return { absPath, relPath: rel.split(sep).join("/") };
241
- }
242
-
243
- function summary(profile: AgentProfile): AgentProfileSummary {
244
- return {
245
- name: profile.name,
246
- kind: profile.kind,
247
- description: profile.description,
248
- maxTokens: profile.maxTokens,
249
- sourceCount: profile.pinned.length + profile.sources.length,
250
- delegateCount: profile.delegates.length,
251
- };
252
- }
253
-
254
- function profilesRenderable(data: AgentProfilesResult): Renderable<AgentProfilesResult> {
255
- return {
256
- kind: "agent.profiles",
257
- data,
258
- pretty: renderProfiles,
259
- plain: renderProfiles,
260
- };
261
- }
262
-
263
- function profileRenderable(data: AgentProfileResult): Renderable<AgentProfileResult> {
264
- return {
265
- kind: "agent.profile",
266
- data,
267
- pretty: renderProfile,
268
- plain: renderProfile,
269
- };
270
- }
271
-
272
- function contextRenderable(data: AgentContextExport): Renderable<AgentContextExport> {
273
- return {
274
- kind: "agent.context.export",
275
- data,
276
- pretty: renderAgentContextMarkdown,
277
- plain: renderAgentContextMarkdown,
278
- };
279
- }
280
-
281
- function renderProfiles(data: AgentProfilesResult): string {
282
- if (data.profiles.length === 0) return "agent profiles: none";
283
- return [
284
- `agent profiles: ${data.profiles.length}`,
285
- ...data.profiles.map(
286
- (profile) =>
287
- `- ${profile.name} [${profile.kind}] — ${profile.description} (budget ${profile.maxTokens}; sources ${profile.sourceCount}; delegates ${profile.delegateCount})`,
288
- ),
289
- ].join("\n");
290
- }
291
-
292
- function renderProfile(data: AgentProfileResult): string {
293
- return [
294
- `${data.name} [${data.kind}] — ${data.description}`,
295
- `budget: ${data.maxTokens}`,
296
- `path: ${data.path}`,
297
- `pinned: ${data.pinned.length === 0 ? "none" : data.pinned.join(", ")}`,
298
- `sources: ${data.sources.length === 0 ? "none" : data.sources.join(", ")}`,
299
- `delegates: ${data.delegates.length === 0 ? "none" : data.delegates.join(", ")}`,
300
- ].join("\n");
301
- }
@@ -1,302 +0,0 @@
1
- /**
2
- * commands/agents.ts — `lore agents`: generate/refresh the Claude Code agent bridge.
3
- *
4
- * The thin, side-effecting layer over the pure {@link planBridge} (core/agent-bridge.ts): it resolves
5
- * the two bridge paths, reads their current bytes, asks core what each file's next state should be,
6
- * and applies the writes — unless `--check`, which reports drift and writes nothing. All the bytes
7
- * and all the decisions live in core; only the filesystem IO and the exit-code selection live here
8
- * (lore-design §2.1), exactly as `lore init` splits `runInit` from `buildScaffold`.
9
- *
10
- * Contract (docs/reference/cli-surface.md §agents): no positional args; `--force` overwrites a
11
- * differing (possibly hand-edited) SKILL.md; `--check` reports drift without writing — a CI gate for
12
- * a stale bridge, returning exit `6` (`drift`) when anything is out of date, `0` otherwise. Output is
13
- * `kind: agents.result`. A malformed `lore:agents` marker pair in CLAUDE.md surfaces as the
14
- * `validation` error {@link upsertManagedBlock} throws (exit 6), never a silent guess.
15
- */
16
-
17
- import { dirname, join } from "node:path";
18
- import { type BridgeAction, CLAUDE_MD_REL_PATH, planBridge, SKILL_REL_PATH } from "../core/agent-bridge";
19
- import { ANSI, EXIT_CODES, EXIT_OK, paint, readFileIfPresent, type Writer } from "../errors";
20
- import { emit, type OutputContext, type Renderable } from "../output";
21
- import { parseCommandArgs, usage } from "./args";
22
- import { assertNoSymlinkInAnyPath, ensureDir, writeFileAtomic } from "./fswrite";
23
-
24
- /** Options for {@link runAgents}; `root` and the stdout stream are injectable for tests. */
25
- export interface AgentsOptions {
26
- /** The repo root the bridge is written into. */
27
- root: string;
28
- /** The resolved output mode/color (from `output.ts`). */
29
- output: OutputContext;
30
- /** The command's normalized tokens from Commander. */
31
- args: readonly string[];
32
- /** stdout sink; defaults to `process.stdout`. */
33
- stdout?: Writer;
34
- }
35
-
36
- /** The `agents.result` payload: the run's mode plus the decided next state of each bridge file. */
37
- export interface AgentsResult {
38
- /** The repo root the bridge was generated in. */
39
- root: string;
40
- /** Whether this was a `--check` run (report only, no writes). */
41
- check: boolean;
42
- /** Whether `--force` was given. */
43
- force: boolean;
44
- /** Each bridge file and what happened (or would happen, under `--check`) to it. */
45
- files: ReadonlyArray<{ path: string; action: BridgeAction }>;
46
- }
47
-
48
- /** The parsed, validated arguments {@link applyAgentsBridge} needs — `root` plus `--force`/`--check`. */
49
- export interface ApplyAgentsOptions {
50
- /** The repo root the bridge is written into (or checked against). */
51
- root: string;
52
- /** `--force`: overwrite a differing (possibly hand-edited) SKILL.md. */
53
- force: boolean;
54
- /** `--check`: report drift without writing. */
55
- check: boolean;
56
- }
57
-
58
- /**
59
- * Plan both bridge files from their on-disk bytes and apply the writes (unless `check`) — the pure
60
- * side-effecting core of `lore agents`, extracted (LORE-260) so `lore init`'s wizard/flags can fold
61
- * the agent bridge into one onboarding run without going through `runAgents`' own arg-parsing/emit
62
- * (which would print a second, separate envelope onto the SAME stdout `lore init` owns — the
63
- * `--json` contract requires stdout be exclusively `init`'s own envelope, cli-contract §4). Returns
64
- * the {@link AgentsResult}; the caller decides what to do with it (emit it directly for `lore agents`
65
- * itself, or fold it into a larger structured result for `lore init`).
66
- */
67
- export function applyAgentsBridge(options: ApplyAgentsOptions): AgentsResult {
68
- const { root, force, check } = options;
69
-
70
- const skillOnDisk = normalizeOnDisk(readFileIfPresent(join(root, SKILL_REL_PATH), SKILL_REL_PATH));
71
- const claudeRaw = readFileIfPresent(join(root, CLAUDE_MD_REL_PATH), CLAUDE_MD_REL_PATH);
72
- const claudeOnDisk = normalizeOnDisk(claudeRaw);
73
- // Detected from the RAW (pre-normalization) bytes, so a refresh of just the managed block can
74
- // re-apply the file's own BOM/EOL convention instead of silently rewriting it to LF/no-BOM
75
- // (LORE-128) — see reapplyDiskStyle below.
76
- const claudeStyle = detectDiskStyle(claudeRaw);
77
-
78
- // Pass both flags through: a differing SKILL.md is `protected` unless `--force` was given, AND
79
- // `--check` is not in effect. `--check` never writes, so `--force` cannot actually take effect
80
- // during one — `planBridge` must not report `updated` (a claimed write) for a run that performs
81
- // none, or the printed trailer falls through to the inert plain-`lore agents` remedy, which
82
- // leaves the file `protected` again and CI stays red (LORE-129).
83
- const plan = planBridge({ skillOnDisk, claudeOnDisk, force, check });
84
-
85
- if (!check) {
86
- const targets = plan.files.filter((file) => file.contents !== null).map((file) => file.path);
87
- // Swept as a whole before either file is written (LORE-93 AC#5) — `lore agents` writes two
88
- // files per run, and a bad target reached second in the loop must not leave the first already
89
- // written; ensureDir's own per-call guard alone is reactive to loop order.
90
- assertNoSymlinkInAnyPath(root, targets);
91
- for (const file of plan.files) {
92
- if (file.contents === null) {
93
- continue; // unchanged, or protected without --force — leave the file untouched
94
- }
95
- const absPath = join(root, file.path);
96
- ensureDir(root, dirname(file.path));
97
- // CLAUDE.md is a managed-block refresh over a user's hand-authored file: re-apply its
98
- // original BOM/EOL convention before writing (LORE-128). SKILL.md is wholesale-regenerated
99
- // (planSkill), so no such preservation applies there.
100
- const contents = file.path === CLAUDE_MD_REL_PATH ? reapplyDiskStyle(file.contents, claudeStyle) : file.contents;
101
- // Atomic (temp-write + rename): `lore agents` writes two files per run, one of them the user's
102
- // hand-authored root CLAUDE.md — a crash mid-write must never leave it truncated (fswrite.ts).
103
- writeFileAtomic(absPath, contents, file.path);
104
- }
105
- }
106
-
107
- return {
108
- root,
109
- check,
110
- force,
111
- files: plan.files.map((file) => ({ path: file.path, action: file.action })),
112
- };
113
- }
114
-
115
- /**
116
- * Run `lore agents`: the thin CLI layer over {@link applyAgentsBridge} — parse the arguments, apply
117
- * the bridge, render the result, and return the exit code. `--check` writes nothing and returns `6`
118
- * (`drift`) when any file is out of date, `0` otherwise; a normal run returns `0` (a differing
119
- * SKILL.md left `protected` for lack of `--force` is reported, not an error — `--check` is the gate).
120
- */
121
- export function runAgents(options: AgentsOptions): number {
122
- const { force, check } = parseAgentsArgs(options.args);
123
- const result = applyAgentsBridge({ root: options.root, force, check });
124
- const drift = result.files.some((file) => file.action !== "unchanged");
125
- emit(agentsRenderable(result), options.output, options.stdout);
126
- return check && drift ? EXIT_CODES.drift : EXIT_OK;
127
- }
128
-
129
- /** Parse `agents`' tokens: no positionals; boolean `--force`/`--check`. A positional or unknown flag is a `usage` error (exit 2). */
130
- function parseAgentsArgs(args: readonly string[]): { force: boolean; check: boolean } {
131
- const { positionals, flags } = parseCommandArgs(args, "agents");
132
- if (positionals.length > 0) {
133
- throw usage(
134
- `\`lore agents\` takes no arguments, got "${positionals[0]}"`,
135
- "run `lore agents [--check] [--force]`",
136
- {
137
- unexpected: [...positionals],
138
- },
139
- );
140
- }
141
- return { force: flags.has("force"), check: flags.has("check") };
142
- }
143
-
144
- /**
145
- * Coerce a raw read (absent → `undefined`) into the `string | null` the planner expects, normalizing
146
- * line endings and a leading BOM to LF — the line-ending half of concept.ts's `normalizeInput`, so a
147
- * CRLF/lone-CR/BOM-prefixed file compares equal to the LF-only generated content instead of reading
148
- * as spurious drift. It deliberately does NOT strip leading whitespace the way `normalizeInput` does:
149
- * that would clobber the head of a user's hand-authored CLAUDE.md.
150
- */
151
- function normalizeOnDisk(raw: string | undefined): string | null {
152
- return raw === undefined ? null : raw.replace(/^\uFEFF+/, "").replace(/\r\n?/g, "\n");
153
- }
154
-
155
- /** A file's on-disk BOM presence and dominant line-ending convention, detected from its RAW bytes. */
156
- interface DiskStyle {
157
- /** Whether the raw file began with a UTF-8 BOM (`\uFEFF`). */
158
- readonly bom: boolean;
159
- /** The dominant EOL sequence: `\r\n` (CRLF) or `\r` (lone CR) if either appears, else `\n` (LF). */
160
- readonly eol: "\n" | "\r\n" | "\r";
161
- }
162
-
163
- /** The default style: no BOM, LF endings — a no-op for {@link reapplyDiskStyle}, and what a freshly-created file gets. */
164
- const LF_NO_BOM_STYLE: DiskStyle = { bom: false, eol: "\n" };
165
-
166
- /**
167
- * Detect `raw`'s BOM + dominant EOL convention from its UN-normalized bytes (before
168
- * {@link normalizeOnDisk} strips/collapses them), so a managed-block refresh can re-apply the
169
- * file's own convention on write-back instead of silently rewriting it (LORE-128). An absent file
170
- * reports {@link LF_NO_BOM_STYLE} — nothing to preserve, so a fresh file is written LF/no-BOM as
171
- * before. CRLF is checked before lone CR since every `\r\n` also contains a `\r`.
172
- */
173
- function detectDiskStyle(raw: string | undefined): DiskStyle {
174
- if (raw === undefined) {
175
- return LF_NO_BOM_STYLE;
176
- }
177
- const bom = raw.startsWith("\uFEFF");
178
- const eol = raw.includes("\r\n") ? "\r\n" : raw.includes("\r") ? "\r" : "\n";
179
- return { bom, eol };
180
- }
181
-
182
- /**
183
- * Re-apply a detected {@link DiskStyle} to freshly-planned (LF, no-BOM) `contents` before writing
184
- * it back, so refreshing just the `lore:agents` managed block does not silently rewrite a CRLF
185
- * and/or BOM-prefixed CLAUDE.md to LF-only / BOM-stripped beyond the block itself (LORE-128).
186
- * `contents` itself is always pure LF (built from {@link normalizeOnDisk}'d input plus template
187
- * strings that only ever use `\n`), so a blanket `\n` -> `style.eol` replace is safe and exact. A
188
- * no-op for {@link LF_NO_BOM_STYLE}.
189
- */
190
- function reapplyDiskStyle(contents: string, style: DiskStyle): string {
191
- const withEol = style.eol === "\n" ? contents : contents.replace(/\n/g, style.eol);
192
- return style.bom ? `\uFEFF${withEol}` : withEol;
193
- }
194
-
195
- /** Build the `agents.result` {@link Renderable}. */
196
- function agentsRenderable(data: AgentsResult): Renderable<AgentsResult> {
197
- return { kind: "agents.result", data, pretty: renderPretty, plain: renderPlain };
198
- }
199
-
200
- /** The per-action verb shown for a file: the drift status (`--check`) or the applied action (write path). */
201
- function actionLabel(action: BridgeAction, check: boolean): string {
202
- if (check) {
203
- if (action === "protected") {
204
- return "out of date (protected; needs --force)";
205
- }
206
- return action === "unchanged" ? "up to date" : "out of date";
207
- }
208
- return action;
209
- }
210
-
211
- /** Stable, token-shaped equivalent of {@link actionLabel} for `--plain`. */
212
- function plainActionLabel(action: BridgeAction, check: boolean): string {
213
- if (check && action === "protected") {
214
- return "out-of-date-protected";
215
- }
216
- return actionLabel(action, check).replace(/ /g, "-");
217
- }
218
-
219
- /**
220
- * The colour each {@link BridgeAction} paints in `pretty` mode: `unchanged` is dim (nothing
221
- * happened), `protected` is a warning (a hand-edited file was deliberately left untouched — see
222
- * {@link renderTrailer}), and `created`/`updated` are a success green. A total `Record`, not a
223
- * chain of `===` checks with a trailing fallback: TypeScript requires every {@link BridgeAction}
224
- * literal as a key, so a sixth/future variant added to that union without a matching entry here is
225
- * a compile error (`bun run typecheck` fails), not a silent runtime fall-through to some default
226
- * colour. That total mapping is also why there is no fallback to reconsider — an action outside the
227
- * union cannot reach {@link bridgeActionColor} at all, well-typed callers included.
228
- */
229
- const BRIDGE_ACTION_COLOR: Record<BridgeAction, string> = {
230
- unchanged: ANSI.dim,
231
- protected: ANSI.yellow,
232
- created: ANSI.green,
233
- updated: ANSI.green,
234
- };
235
-
236
- /**
237
- * The colour a bridge file's {@link BridgeAction} paints in `pretty` mode — see
238
- * {@link BRIDGE_ACTION_COLOR}. Exported (LORE-267) so `lore init`'s own renderer (init.ts) paints
239
- * the exact same {@link BridgeAction} in the exact same colour, instead of keeping a second,
240
- * hand-maintained copy of this mapping that can silently drift from this one — which is exactly how
241
- * `protected` came to render green here (the old two-way `unchanged`-or-green split) while `init`
242
- * already painted it yellow.
243
- */
244
- export function bridgeActionColor(action: BridgeAction): string {
245
- return BRIDGE_ACTION_COLOR[action];
246
- }
247
-
248
- /** Human view: a heading, one line per file, and an actionable trailer for stale/protected state. */
249
- function renderPretty(data: AgentsResult, opts: { color: boolean }): string {
250
- const head = data.check ? `Checking the lore agent bridge at ${data.root}` : `lore agent bridge at ${data.root}`;
251
- const lines = [head];
252
- for (const file of data.files) {
253
- const label = actionLabel(file.action, data.check);
254
- const color = bridgeActionColor(file.action);
255
- lines.push(` ${paint(label, color, opts.color)} ${file.path}`);
256
- }
257
- const trailer = renderTrailer(data);
258
- if (trailer !== undefined) {
259
- lines.push(paint(trailer, ANSI.yellow, opts.color));
260
- }
261
- return lines.join("\n");
262
- }
263
-
264
- /** ANSI-free, diff-stable view: one `<action> <path>` line each, plus any trailer as a plain line. */
265
- function renderPlain(data: AgentsResult): string {
266
- const lines = data.files.map((file) => `${plainActionLabel(file.action, data.check)} ${file.path}`);
267
- const trailer = renderTrailer(data);
268
- if (trailer !== undefined) {
269
- lines.push(trailer);
270
- }
271
- return lines.join("\n");
272
- }
273
-
274
- /**
275
- * The trailing advisory line, or `undefined` when none applies. The remedy depends on *why* the
276
- * bridge is stale: a differing (hand-edited) SKILL.md is `protected` and can only be regenerated with
277
- * `--force` (a plain `lore agents` would leave it untouched), so the hint must say `--force` whenever a
278
- * protected file is present — under `--check` (where the inert plain remedy would otherwise keep CI
279
- * red) and on a normal run alike.
280
- *
281
- * Exported (LORE-260 review round 2, MINOR-4) so `lore init`'s own renderers can reuse this EXACT
282
- * trailer for the agent-bridge step it folds in, instead of dropping it: LORE-129 established this
283
- * line as load-bearing (a `protected` file with no visible remedy reads as silent success), and
284
- * `init`'s own rendering must not regress it just because it's a second, thinner caller.
285
- */
286
- export function renderTrailer(data: AgentsResult): string | undefined {
287
- const hasProtected = data.files.some((file) => file.action === "protected");
288
- if (data.check) {
289
- const stale = data.files.some((file) => file.action !== "unchanged");
290
- if (!stale) {
291
- return undefined;
292
- }
293
- return hasProtected
294
- ? "bridge is out of date — a hand-edited file needs `lore agents --force` to regenerate (exit 6)"
295
- : "bridge is out of date — run `lore agents` to regenerate (exit 6)";
296
- }
297
- if (!hasProtected) {
298
- return undefined;
299
- }
300
- const count = data.files.filter((file) => file.action === "protected").length;
301
- return `${count} file(s) look hand-edited and were left untouched — run \`lore agents --force\` to overwrite`;
302
- }