@llm4ts/shell 2.17.0 → 2.18.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.
@@ -4,6 +4,7 @@
4
4
  // llm4ts run epic-design --repo ~/work/portal "Current account: balance, movements, statements"
5
5
  // llm4ts run epic-design --repo ~/work/portal -- --epic <id> # revise, or validate once approved
6
6
  // llm4ts run epic-design --repo ~/work/portal -- --list # the repository's briefs
7
+ // llm4ts run epic-design --repo ~/work/portal -- --coverage # the ledger across every brief
7
8
  //
8
9
  // Runs rooted at the TARGET repository. The legacy repository must hold
9
10
  // modernize-extract's pack (docs/modernization/); modernize-refine's
@@ -18,34 +19,57 @@
18
19
  // from the approved brief. Seat: LLM4TS_REASONER (read-only, default
19
20
  // claude). LLM4TS_PACK describes the target stack when the target is new.
20
21
  // LLM4TS_CONTEXT_BUDGET bounds the legacy evidence in the prompt.
22
+ //
23
+ // Briefs of one repository are read together. What the approved briefs of
24
+ // other epics decided is inherited: a scenario they dropped or found in the
25
+ // target is not restated, one they own is not proposed again, one they
26
+ // deferred is offered; deciding otherwise is a `[check]` open point naming
27
+ // the other epic. --coverage writes .llm4ts/epics/coverage.md, the ledger of
28
+ // every scenario of the pack against every brief, without calling a model.
21
29
  import { access, readdir } from "node:fs/promises";
22
- import { join } from "node:path";
30
+ import { join, resolve } from "node:path";
23
31
  import * as Console from "effect/Console";
24
32
  import * as Effect from "effect/Effect";
25
33
  import { budget, cap } from "@llm4ts/flow/Context";
26
34
  import { Info, ScriptUsage, asReadOnly, nodePlainFileStore, openPack, resolveFlowInput, runFlowMain, runNode, stage } from "@llm4ts/runner";
27
- import { designEpic, parseEpicDesignArgs, renderBriefList, renderOutcome, resolveDesignTarget } from "./lib/epic-design.js";
28
- import { epicDirs, listBriefs, reasonerFromEnvironment } from "./lib/epic-stories.js";
35
+ import { coverageReport, designEpic, parseEpicDesignArgs, renderBriefList, renderOutcome, resolveDesignTarget } from "./lib/epic-design.js";
36
+ import { epicDirs, listBriefs, listEpics, loadBriefs, reasonerFromEnvironment } from "./lib/epic-stories.js";
29
37
  import { ModDir } from "./lib/modernize-extract.js";
30
38
  const program = Effect.gen(function* () {
31
39
  const flags = yield* parseEpicDesignArgs(process.argv.slice(2));
32
40
  const given = yield* resolveFlowInput("", flags.rest);
33
41
  const files = nodePlainFileStore;
34
- const briefs = yield* listBriefs(files, given.workDir, yield* epicDirs(given.workDir));
42
+ const dirs = yield* epicDirs(given.workDir);
43
+ const briefs = yield* listBriefs(files, given.workDir, dirs);
35
44
  if (flags.list) {
36
45
  yield* Console.log(renderBriefList(briefs));
37
46
  return;
38
47
  }
39
- const legacyRepo = process.env.LLM4TS_LEGACY_REPO?.trim();
40
- if (legacyRepo === undefined || legacyRepo.length === 0) {
48
+ const legacyGiven = process.env.LLM4TS_LEGACY_REPO?.trim();
49
+ if (legacyGiven === undefined || legacyGiven.length === 0) {
41
50
  return yield* ScriptUsage.make({
42
51
  message: "LLM4TS_LEGACY_REPO is not set: point it at the legacy repository holding docs/modernization/"
43
52
  });
44
53
  }
45
- const target = yield* resolveDesignTarget({ text: given.prompt, epic: flags.epic, briefs });
54
+ // One spelling of the path, so every brief of this repository names the same legacy.
55
+ const legacyRepo = resolve(given.workspace, legacyGiven);
46
56
  const specNames = yield* Effect.tryPromise(() => readdir(join(legacyRepo, ModDir, "specs"))).pipe(Effect.map((names) => names
47
57
  .filter((name) => name.endsWith(".md") && name !== "README.md")
48
58
  .map((name) => name.slice(0, -".md".length))), Effect.catch(() => Effect.succeed([])));
59
+ if (flags.coverage) {
60
+ const report = yield* coverageReport({
61
+ files,
62
+ targetDir: given.workDir,
63
+ legacyRepo,
64
+ specNames,
65
+ dirs,
66
+ epics: yield* listEpics(files, given.workDir)
67
+ });
68
+ yield* Console.log(`${report.headline}\n${report.path}`);
69
+ return;
70
+ }
71
+ const target = yield* resolveDesignTarget({ text: given.prompt, epic: flags.epic, briefs });
72
+ const loaded = yield* loadBriefs(files, given.workDir, dirs);
49
73
  const reasoning = asReadOnly(yield* reasonerFromEnvironment(process.env));
50
74
  yield* runNode({
51
75
  workDir: given.workDir,
@@ -83,6 +107,8 @@ const program = Effect.gen(function* () {
83
107
  budget: budget(process.env),
84
108
  guidance,
85
109
  packNote,
110
+ others: loaded.briefs,
111
+ unreadable: loaded.unreadable,
86
112
  pathExists: (absolute) => Effect.tryPromise(() => access(absolute)).pipe(Effect.as(true), Effect.catch(() => Effect.succeed(false)))
87
113
  }));
88
114
  for (const line of renderOutcome(outcome, target.epicId)) {
@@ -5,12 +5,13 @@
5
5
  import { join } from "node:path";
6
6
  import * as Effect from "effect/Effect";
7
7
  import { cap } from "@llm4ts/flow/Context";
8
+ import { buildLedger, inheritedFrom, ledgerHeadline, renderLedger } from "@llm4ts/flow/CoverageLedger";
8
9
  import { parseDecisions, scenarioTitles } from "@llm4ts/flow/Decisions";
9
- import { assembleBrief, checkEpicBrief, diffBriefs, EpicBriefProposal, epicBriefProposalJsonSchema, loopAction, normalizeBrief, parseEpicBrief, ProgramSelection, programSelectionJsonSchema, providedPointers, renderBriefProblem, renderEpicBrief, unanswered } from "@llm4ts/flow/EpicBrief";
10
+ import { assembleBrief, checkEpicBrief, diffBriefs, dispositionWords, EpicBriefProposal, epicBriefProposalJsonSchema, loopAction, normalizeBrief, parseEpicBrief, ProgramSelection, programSelectionJsonSchema, providedPointers, renderBriefProblem, renderEpicBrief, unanswered } from "@llm4ts/flow/EpicBrief";
10
11
  import { structuredAndPublish } from "@llm4ts/flow/Flow";
11
12
  import { EpicBriefInvalid, ExtractPackMissing, OpenPointsPending } from "@llm4ts/flow/FlowError";
12
13
  import { ScriptUsage } from "@llm4ts/runner";
13
- import { epicIdFor, epicsDir } from "./epic-stories.js";
14
+ import { epicIdFor, epicsDir, loadBriefs } from "./epic-stories.js";
14
15
  import { ModDir } from "./modernize-extract.js";
15
16
  // ---- Arguments ----------------------------------------------------------------
16
17
  export const epicDesignUsage = [
@@ -18,10 +19,12 @@ export const epicDesignUsage = [
18
19
  " LLM4TS_LEGACY_REPO=<path> the legacy repository holding docs/modernization/ (required)",
19
20
  " LLM4TS_PACK=<name|dir> the pack describing the target stack (a new or empty target)",
20
21
  " --epic <id> revise or validate an existing brief, no text needed",
21
- " --list this repository's briefs and their status"
22
+ " --list this repository's briefs and their status",
23
+ " --coverage write the coverage ledger across every brief (no model call)"
22
24
  ].join("\n");
23
25
  export const parseEpicDesignArgs = (argv) => Effect.gen(function* () {
24
26
  let list = false;
27
+ let coverage = false;
25
28
  let epic;
26
29
  const rest = [];
27
30
  for (let index = 0; index < argv.length; index += 1) {
@@ -29,6 +32,9 @@ export const parseEpicDesignArgs = (argv) => Effect.gen(function* () {
29
32
  if (argument === "--list") {
30
33
  list = true;
31
34
  }
35
+ else if (argument === "--coverage") {
36
+ coverage = true;
37
+ }
32
38
  else if (argument === "--epic" || argument.startsWith("--epic=")) {
33
39
  const value = argument.includes("=") ? argument.slice("--epic=".length) : argv[index + 1];
34
40
  if (!argument.includes("="))
@@ -50,7 +56,7 @@ export const parseEpicDesignArgs = (argv) => Effect.gen(function* () {
50
56
  rest.push(argument);
51
57
  }
52
58
  }
53
- return { list, epic, rest };
59
+ return { list, coverage, epic, rest };
54
60
  });
55
61
  // ---- Which brief ---------------------------------------------------------------
56
62
  export const renderBriefList = (briefs) => briefs.length === 0
@@ -156,6 +162,16 @@ export const selectPrompt = (request, index) => [
156
162
  ].join("\n");
157
163
  export const proposePrompt = (options) => {
158
164
  const perProgram = Math.max(2_000, Math.floor(options.budget / Math.max(1, options.programs.length)));
165
+ const selected = new Set(options.programs.map((program) => program.name));
166
+ const inherited = (options.inherited ?? []).filter((decision) => selected.has(decision.program));
167
+ const decided = inherited.filter((decision) => decision.disposition !== "deferred");
168
+ const available = inherited.filter((decision) => decision.disposition === "deferred");
169
+ const settled = new Set(decided.map((decision) => `${decision.program}\u0000${decision.scenario}`));
170
+ const open = options.pack.index.programs
171
+ .filter((program) => selected.has(program.name))
172
+ .flatMap((program) => program.scenarios
173
+ .filter((scenario) => !settled.has(`${program.name}\u0000${scenario}`))
174
+ .map((scenario) => `- ${program.name} › ${scenario}`));
159
175
  const evidence = options.programs.flatMap((program) => [
160
176
  `===== ${program.name} — spec =====`,
161
177
  cap(options.pack.specs[program.name] ?? "", perProgram).text,
@@ -206,11 +222,27 @@ export const proposePrompt = (options) => {
206
222
  options.guidance,
207
223
  ...(options.packNote === undefined ? [] : ["", "The target stack's pack:", options.packNote]),
208
224
  "",
225
+ ...(decided.length === 0
226
+ ? []
227
+ : [
228
+ "Already decided by other epics (do not restate; they are inherited):",
229
+ ...decided.map((decision) => `- ${decision.program} › ${decision.scenario} — ${decision.disposition === "dropped"
230
+ ? `dropped by epic ${decision.epic}`
231
+ : `${dispositionWords[decision.disposition]} in epic ${decision.epic}`}${decision.note.length === 0 ? "" : ` (${decision.note})`}`),
232
+ ""
233
+ ]),
234
+ ...(available.length === 0
235
+ ? []
236
+ : [
237
+ "Deferred by other epics, available to this one (claim them if they belong here):",
238
+ ...available.map((decision) => `- ${decision.program} › ${decision.scenario} — deferred by epic ${decision.epic}${decision.note.length === 0 ? "" : `: ${decision.note}`}`),
239
+ ""
240
+ ]),
209
241
  "Every scenario to give a disposition to (the complete list; the evidence below may be",
210
242
  "abridged, these titles are not):",
211
- ...options.pack.index.programs
212
- .filter((program) => options.programs.some((selected) => selected.name === program.name))
213
- .flatMap((program) => program.scenarios.map((scenario) => `- ${program.name} › ${scenario}`)),
243
+ ...(open.length === 0
244
+ ? ["(none: every scenario of these programs is already decided by other epics)"]
245
+ : open),
214
246
  "",
215
247
  "Legacy evidence:",
216
248
  ...evidence
@@ -233,14 +265,53 @@ const verifiedPointers = (deps, brief) => Effect.gen(function* () {
233
265
  }
234
266
  return found;
235
267
  });
236
- const check = (deps, pack, brief) => Effect.map(verifiedPointers(deps, brief), (pointers) => checkEpicBrief(brief, { pack: pack.index, pointers }));
268
+ const check = (deps, pack, brief, others) => Effect.map(verifiedPointers(deps, brief), (pointers) => checkEpicBrief(brief, { pack: pack.index, pointers, others, epicId: deps.epicId }));
269
+ /** The ledger of the other epics' briefs, against this pack. */
270
+ const ledgerOfOthers = (deps, pack) => buildLedger({
271
+ pack: pack.index,
272
+ briefs: (deps.others ?? []).filter((other) => other.epicId !== deps.epicId),
273
+ epics: [],
274
+ legacy: deps.legacyRepo
275
+ });
276
+ /** Other briefs that fed nothing into this run, said in words. */
277
+ const unusedBriefs = (deps, others) => [
278
+ ...(deps.unreadable ?? [])
279
+ .filter((brief) => brief.dir !== deps.epicId)
280
+ .map((brief) => `brief '${brief.dir}' could not be read (${brief.reason}): nothing is inherited from it`),
281
+ ...others.skipped.map((epic) => `brief '${epic}' was designed against another legacy repository: nothing is inherited from it`)
282
+ ];
283
+ /** Per epic, how many scenarios of the considered programs a brief inherits. */
284
+ const inheritedSummary = (inherited, programs) => {
285
+ const considered = new Set(programs.map((program) => program.name));
286
+ const perEpic = new Map();
287
+ for (const decision of inherited) {
288
+ if (decision.disposition === "deferred" || !considered.has(decision.program))
289
+ continue;
290
+ const seen = perEpic.get(decision.epic) ?? new Set();
291
+ seen.add(`${decision.program}\u0000${decision.scenario}`);
292
+ perEpic.set(decision.epic, seen);
293
+ }
294
+ return [...perEpic].map(([epic, scenarios]) => ({ epic, scenarios: scenarios.size }));
295
+ };
237
296
  export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
238
297
  const path = briefPath(deps.targetDir, deps.epicId);
239
298
  const pack = yield* readPackIndex(deps);
240
299
  const onDisk = yield* deps.files.read(path);
241
300
  const current = onDisk === undefined ? undefined : yield* parseEpicBrief(onDisk, path);
242
301
  const action = loopAction(current);
243
- const outcome = (changes, problems, openPoints) => ({ action, path, targetDir: deps.targetDir, changes, problems, openPoints });
302
+ const others = ledgerOfOthers(deps, pack);
303
+ const inherited = inheritedFrom(others);
304
+ const warnings = unusedBriefs(deps, others);
305
+ const outcome = (changes, problems, openPoints, considered = []) => ({
306
+ action,
307
+ path,
308
+ targetDir: deps.targetDir,
309
+ changes,
310
+ problems,
311
+ openPoints,
312
+ inherited: inheritedSummary(inherited, considered),
313
+ warnings
314
+ });
244
315
  if (current !== undefined && action === "halt") {
245
316
  return yield* OpenPointsPending.make({
246
317
  path,
@@ -251,7 +322,7 @@ export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
251
322
  return outcome([], [], 0);
252
323
  }
253
324
  if (current !== undefined && action === "validate") {
254
- const problems = yield* check(deps, pack, current);
325
+ const problems = yield* check(deps, pack, current, inherited);
255
326
  if (problems.length > 0) {
256
327
  return yield* EpicBriefInvalid.make({ path, violations: problems.map(renderBriefProblem) });
257
328
  }
@@ -270,6 +341,7 @@ export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
270
341
  budget: deps.budget,
271
342
  guidance: deps.guidance,
272
343
  packNote: deps.packNote,
344
+ inherited,
273
345
  ...(current === undefined ? {} : { current }),
274
346
  ...(problems === undefined ? {} : { problems })
275
347
  }), EpicBriefProposal, epicBriefProposalJsonSchema);
@@ -290,11 +362,11 @@ export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
290
362
  ? proposed
291
363
  : EpicBriefProposal.make({ ...proposed, openPoints: [noPrograms, ...proposed.openPoints] });
292
364
  let proposal = withNotice(yield* propose());
293
- let problems = yield* check(deps, pack, assemble(proposal));
365
+ let problems = yield* check(deps, pack, assemble(proposal), inherited);
294
366
  if (problems.length > 0) {
295
367
  // One bounded fix round; what still fails is shown to the human, never hidden.
296
368
  proposal = withNotice(yield* propose(problems));
297
- problems = yield* check(deps, pack, assemble(proposal));
369
+ problems = yield* check(deps, pack, assemble(proposal), inherited);
298
370
  }
299
371
  const next = assemble(proposal, problems);
300
372
  // What is written must read back as the same brief: a file that parses to
@@ -309,10 +381,10 @@ export const designEpic = Effect.fn("epic-design.design")(function* (deps) {
309
381
  });
310
382
  }
311
383
  yield* deps.files.writeAtomic(path, written);
312
- return outcome(diffBriefs(current, next), problems, next.openPoints.length);
384
+ return outcome(diffBriefs(current, next), problems, next.openPoints.length, next.programs);
313
385
  });
314
386
  /** What the flow prints after a run. */
315
- export const renderOutcome = (outcome, epicId) => {
387
+ const outcomeLines = (outcome, epicId) => {
316
388
  const approve = `set \`Status: approved\` in ${outcome.path} once it is right`;
317
389
  const plan = `llm4ts run epic-stories --repo ${outcome.targetDir} -- --epic ${epicId}`;
318
390
  switch (outcome.action) {
@@ -330,6 +402,13 @@ export const renderOutcome = (outcome, epicId) => {
330
402
  return [
331
403
  `${outcome.action === "propose" ? "proposed" : "revised"} ${outcome.path}:`,
332
404
  ...outcome.changes.map((change) => ` ${change}`),
405
+ ...(outcome.inherited.length === 0
406
+ ? []
407
+ : [
408
+ ` inherited from ${outcome.inherited
409
+ .map((entry) => `${entry.epic} (${entry.scenarios} scenario${entry.scenarios === 1 ? "" : "s"})`)
410
+ .join(", ")}: not restated here; see \`epic-design --coverage\``
411
+ ]),
333
412
  ...(outcome.problems.length === 0
334
413
  ? []
335
414
  : [` ${outcome.problems.length} check(s) still failing, raised as [check] open points`]),
@@ -339,3 +418,35 @@ export const renderOutcome = (outcome, epicId) => {
339
418
  ];
340
419
  }
341
420
  };
421
+ /** What the flow prints after a run; briefs it could not use are always said. */
422
+ export const renderOutcome = (outcome, epicId) => [
423
+ ...outcomeLines(outcome, epicId),
424
+ ...outcome.warnings.map((warning) => `warning: ${warning}`)
425
+ ];
426
+ // ---- The coverage ledger ---------------------------------------------------------
427
+ export const coveragePath = (targetDir) => join(epicsDir(targetDir), "coverage.md");
428
+ /** What epic-stories recorded for each planned epic, as the ledger reads it. */
429
+ export const epicProgressOf = (epics) => epics.map((epic) => ({
430
+ epicId: epic.dir,
431
+ planned: true,
432
+ stories: epic.stories,
433
+ merged: epic.merged,
434
+ landed: epic.landed !== undefined
435
+ }));
436
+ /**
437
+ * The ledger across every brief of the target, written as coverage.md. A
438
+ * read of the repository: no model is called and no brief is edited.
439
+ */
440
+ export const coverageReport = Effect.fn("epic-design.coverage")(function* (options) {
441
+ const pack = yield* readPackIndex(options);
442
+ const loaded = yield* loadBriefs(options.files, options.targetDir, options.dirs);
443
+ const ledger = buildLedger({
444
+ pack: pack.index,
445
+ briefs: loaded.briefs,
446
+ epics: epicProgressOf(options.epics),
447
+ legacy: options.legacyRepo
448
+ });
449
+ const path = coveragePath(options.targetDir);
450
+ yield* options.files.writeAtomic(path, renderLedger(ledger, { legacy: options.legacyRepo, unreadable: loaded.unreadable }));
451
+ return { path, headline: ledgerHeadline(ledger), ledger, unreadable: loaded.unreadable };
452
+ });
@@ -5,6 +5,7 @@ import { readdir } from "node:fs/promises";
5
5
  import { isAbsolute, join, normalize } from "node:path";
6
6
  import * as Duration from "effect/Duration";
7
7
  import * as Effect from "effect/Effect";
8
+ import * as Result from "effect/Result";
8
9
  import * as Schema from "effect/Schema";
9
10
  import { Dimension, Sample } from "@llm4ts/core/eval/Eval";
10
11
  import { judge } from "@llm4ts/core/eval/Judge";
@@ -187,6 +188,29 @@ export const listBriefs = (files, workDir, dirs) => Effect.gen(function* () {
187
188
  }
188
189
  return briefs;
189
190
  });
191
+ /**
192
+ * Every brief of the repository, parsed, for the coverage ledger. A folder
193
+ * without a brief is skipped; a brief that does not parse is reported with
194
+ * the first thing wrong in it, never dropped silently.
195
+ */
196
+ export const loadBriefs = (files, workDir, dirs) => Effect.gen(function* () {
197
+ const briefs = [];
198
+ const unreadable = [];
199
+ for (const dir of dirs) {
200
+ const path = join(epicsDir(workDir), dir, "brief.md");
201
+ const text = yield* files.read(path);
202
+ if (text === undefined)
203
+ continue;
204
+ const parsed = yield* Effect.result(parseEpicBrief(text, path));
205
+ if (Result.isSuccess(parsed)) {
206
+ briefs.push({ epicId: dir, brief: parsed.success });
207
+ }
208
+ else {
209
+ unreadable.push({ dir, reason: parsed.failure.violations[0] ?? "does not parse" });
210
+ }
211
+ }
212
+ return { briefs, unreadable };
213
+ });
190
214
  /**
191
215
  * What the story planner reads in place of the one-line epic: the approved
192
216
  * brief in the epic's folder. Undefined when the plan is already written (the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/shell",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Interactive shell and CLI for llm4ts: flow discovery, run-a-flow, and view",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,9 +52,9 @@
52
52
  "dependencies": {
53
53
  "@effect/platform-node": "4.0.0-rc.115",
54
54
  "@effect/platform-node-shared": "4.0.0-rc.115",
55
- "@llm4ts/core": "2.17.0",
56
- "@llm4ts/flow": "2.17.0",
57
- "@llm4ts/runner": "2.17.0"
55
+ "@llm4ts/core": "2.18.0",
56
+ "@llm4ts/runner": "2.18.0",
57
+ "@llm4ts/flow": "2.18.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "effect": "4.0.0-rc.115"