@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/commands/agent.ts
DELETED
|
@@ -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
|
-
}
|
package/src/commands/agents.ts
DELETED
|
@@ -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
|
-
}
|