@kitschpatrol/chagent 0.3.0 → 0.5.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.
@@ -9,13 +9,13 @@ import semver from "semver";
9
9
  import { parse } from "yaml";
10
10
  import crossSpawn from "cross-spawn";
11
11
  import { existsSync } from "node:fs";
12
- import { pathToFileURL } from "node:url";
13
12
  import { cosmiconfig } from "cosmiconfig";
13
+ import { pathToFileURL } from "node:url";
14
14
  import { isCancel, select } from "@clack/prompts";
15
15
 
16
16
  // package.json
17
17
  var name = "@kitschpatrol/chagent";
18
- var version = "0.3.0";
18
+ var version = "0.5.0";
19
19
  var engines = { "node": "^24.16.0 || >=26.3.0" };
20
20
 
21
21
  // src/lib/defaults.ts
@@ -25,6 +25,7 @@ const defaults = {
25
25
  effort: "xhigh",
26
26
  github: true,
27
27
  maxInputChars: 8e4,
28
+ rootChangelog: "summary",
28
29
  timeout: 20
29
30
  };
30
31
  const defaultModels = {
@@ -236,6 +237,7 @@ async function readBlobs(root, objects) {
236
237
  const LOCKFILE_PATH = /(?:^|\/)(?:pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|deno\.lock|uv\.lock|poetry\.lock|pdm\.lock|Pipfile\.lock|Cargo\.lock|Gemfile\.lock|composer\.lock|go\.sum)$/v;
237
238
  const TAG_DECORATION = "tag: refs/tags/";
238
239
  const GITHUB_REMOTE = /github\.com[:\/]([^\/]+\/[^\/]+?)(?:\.git)?$/v;
240
+ const SHORT_VERSION = /^\d+(?:\.\d+)?$/v;
239
241
  const manifestSchema$1 = z.object({
240
242
  dependencies: z.record(z.string(), z.string()).optional(),
241
243
  engines: z.object({ node: z.string().optional() }).optional(),
@@ -392,6 +394,29 @@ function packageInfo(manifest, directory, fallback) {
392
394
  version: manifest?.version
393
395
  };
394
396
  }
397
+ /**
398
+ * Read a version, with how many of its parts are spelled out. Short versions
399
+ * such as `1` or `1.2` name the first release of their series.
400
+ */
401
+ function parseVersion(value) {
402
+ const version = semver.valid(value);
403
+ if (version !== null) return {
404
+ precision: 3,
405
+ version
406
+ };
407
+ if (!SHORT_VERSION.test(value)) return;
408
+ const parts = value.split(".");
409
+ const padded = semver.valid([
410
+ ...parts,
411
+ "0",
412
+ "0"
413
+ ].slice(0, 3).join("."));
414
+ return padded === null ? void 0 : {
415
+ precision: parts.length,
416
+ version: padded
417
+ };
418
+ }
419
+ /** Read the version a tag names for a package, using its tag patterns. */
395
420
  function tagVersion(tag, pkg, patterns) {
396
421
  const templates = patterns ?? [
397
422
  "{name}@{version}",
@@ -402,10 +427,23 @@ function tagVersion(tag, pkg, patterns) {
402
427
  for (const pattern of templates) {
403
428
  const [prefix, suffix = ""] = pattern.replaceAll("{name}", () => pkg.name).split("{version}", 2);
404
429
  if (prefix === void 0 || !tag.startsWith(prefix) || !tag.endsWith(suffix)) continue;
405
- const value = tag.slice(prefix.length, suffix === "" ? void 0 : -suffix.length);
406
- const version = semver.valid(value);
407
- if (version !== null) return version;
430
+ const parsed = parseVersion(tag.slice(prefix.length, suffix === "" ? void 0 : -suffix.length));
431
+ const bareNumber = parsed?.precision === 1 && patterns === void 0 && !prefix.endsWith("v");
432
+ if (parsed !== void 0 && !bareNumber) return parsed;
433
+ }
434
+ }
435
+ /**
436
+ * Check that a short tag such as `v1` or `v1.2` precedes every more precise tag
437
+ * in its series. Floating tags that follow the latest release fail this check.
438
+ */
439
+ async function leadsSeries(root, candidate, candidates) {
440
+ const major = semver.major(candidate.version);
441
+ const minor = semver.minor(candidate.version);
442
+ for (const other of candidates) {
443
+ if (other.precision <= candidate.precision || semver.major(other.version) !== major || candidate.precision === 2 && semver.minor(other.version) !== minor || semver.lt(other.version, candidate.version)) continue;
444
+ if (other.tag.commit === candidate.tag.commit || !await isAncestor(root, candidate.tag.commit, other.tag.commit)) return false;
408
445
  }
446
+ return true;
409
447
  }
410
448
  /**
411
449
  * Predict the tag of a release that is not tagged yet, so its heading can link
@@ -486,21 +524,15 @@ function pnpmWorkspaceGlobs(text) {
486
524
  if (!workspace.success) throw new Error(`Invalid pnpm-workspace.yaml:\n${z.prettifyError(workspace.error)}`, { cause: workspace.error });
487
525
  return workspace.data?.packages;
488
526
  }
489
- async function workspacePackages(root, manifest, config) {
490
- let patterns = Array.isArray(manifest?.workspaces) ? manifest.workspaces : manifest?.workspaces?.packages ?? [];
491
- const workspaceText = await readOptional(path.join(root, "pnpm-workspace.yaml"));
492
- if (workspaceText !== void 0) patterns = pnpmWorkspaceGlobs(workspaceText) ?? patterns;
493
- patterns = config.packages ?? patterns;
527
+ /** Find directories matching globs, as POSIX paths relative to the root. */
528
+ async function matchDirectories(root, patterns, exclude = []) {
494
529
  const directories = [];
495
- const positive = patterns.filter((pattern) => !pattern.startsWith("!"));
496
- const negative = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
497
- const matches = glob(positive, {
530
+ const matches = glob(patterns, {
498
531
  cwd: root,
499
532
  exclude: [
500
533
  "**/node_modules/**",
501
534
  "**/.git/**",
502
- ...negative,
503
- ...config.exclude ?? []
535
+ ...exclude
504
536
  ],
505
537
  withFileTypes: true
506
538
  });
@@ -508,6 +540,27 @@ async function workspacePackages(root, manifest, config) {
508
540
  if (!entry.isDirectory()) continue;
509
541
  directories.push(path.relative(root, path.join(entry.parentPath, entry.name)).split(path.sep).join("/"));
510
542
  }
543
+ return directories;
544
+ }
545
+ /** Mark the packages that remain boundaries but get no changelog. */
546
+ async function markSkipped(root, packages, patterns) {
547
+ const skipped = /* @__PURE__ */ new Set();
548
+ for (const pattern of patterns) {
549
+ const matches = (await matchDirectories(root, [pattern])).filter((directory) => packages.some((pkg) => pkg.directory === directory));
550
+ if (matches.length === 0) throw new Error(`skipPackage pattern "${pattern}" matches no workspace package directory. Use a directory glob relative to the repository root, such as "packages/docs"; rootChangelog controls the root changelog.`);
551
+ for (const directory of matches) skipped.add(directory);
552
+ }
553
+ return packages.map((pkg) => skipped.has(pkg.directory) ? {
554
+ ...pkg,
555
+ skipped: true
556
+ } : pkg);
557
+ }
558
+ async function workspacePackages(root, manifest, config) {
559
+ let patterns = Array.isArray(manifest?.workspaces) ? manifest.workspaces : manifest?.workspaces?.packages ?? [];
560
+ const workspaceText = await readOptional(path.join(root, "pnpm-workspace.yaml"));
561
+ if (workspaceText !== void 0) patterns = pnpmWorkspaceGlobs(workspaceText) ?? patterns;
562
+ patterns = config.packages ?? patterns;
563
+ const directories = await matchDirectories(root, patterns.filter((pattern) => !pattern.startsWith("!")), [...patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1)), ...config.exclude ?? []]);
511
564
  const packages = [];
512
565
  for (const directory of directories.toSorted()) {
513
566
  if (!(await realpath(path.join(root, directory))).startsWith(root + path.sep)) throw new Error(`Workspace package escapes the repository: ${directory}`);
@@ -515,30 +568,40 @@ async function workspacePackages(root, manifest, config) {
515
568
  if (text === void 0 && config.packages === void 0) continue;
516
569
  packages.push(packageInfo(text === void 0 ? void 0 : parseManifest(text, `${directory}/package.json`), directory, path.posix.basename(directory)));
517
570
  }
518
- return packages;
571
+ return markSkipped(root, packages, config.skipPackage ?? []);
572
+ }
573
+ /** Select the workspace packages that get changelogs of their own. */
574
+ function describedPackages(repository) {
575
+ return repository.packages.filter((pkg) => pkg.skipped !== true);
519
576
  }
520
577
  async function taggedReleases(repository, pkg, config) {
521
578
  const candidates = [];
522
579
  for (const tag of repository.tags) {
523
- const version = tagVersion(tag.name, pkg, config.tagPatterns);
524
- if (version !== void 0) candidates.push({
525
- tag,
526
- version
580
+ const match = tagVersion(tag.name, pkg, config.tagPatterns);
581
+ if (match !== void 0) candidates.push({
582
+ ...match,
583
+ tag
527
584
  });
528
585
  }
529
586
  const isRoot = pkg.directory === ".";
530
587
  const manifests = isRoot ? /* @__PURE__ */ new Map() : await manifestsAt(repository.root, pkg.directory, candidates.map(({ tag }) => tag.commit));
531
- const releases = [];
532
- for (const { tag, version } of candidates) {
588
+ const owned = [];
589
+ for (const candidate of candidates) {
533
590
  if (!isRoot) {
534
- const manifest = manifests.get(tag.commit);
535
- if (manifest?.version !== void 0 && semver.valid(manifest.version) !== version) continue;
591
+ const manifest = manifests.get(candidate.tag.commit);
592
+ if (manifest?.version !== void 0 && semver.valid(manifest.version) !== candidate.version) continue;
536
593
  if (manifest === void 0 && await git(repository.root, [
537
594
  "cat-file",
538
595
  "-t",
539
- `${tag.commit}:${pkg.directory}`
596
+ `${candidate.tag.commit}:${pkg.directory}`
540
597
  ], true) !== "tree") continue;
541
598
  }
599
+ owned.push(candidate);
600
+ }
601
+ const releases = [];
602
+ for (const candidate of owned) {
603
+ if (candidate.precision < 3 && !await leadsSeries(repository.root, candidate, owned)) continue;
604
+ const { tag, version } = candidate;
542
605
  const existing = releases.find((release) => release.version === version);
543
606
  if (existing && existing.ref !== tag.commit) throw new Error(`Multiple reachable tags disagree about ${pkg.name} ${version}. Configure tagPatterns to select a release series.`);
544
607
  if (existing) continue;
@@ -618,13 +681,25 @@ manager, manifest format, or directory layout. Use snapshots at those refs, not
618
681
  uncommitted files or newer code at HEAD. Respect repository.directory and
619
682
  repository.excludeDirectories when deciding which changes belong in this entry;
620
683
  you may inspect related files for context. The supplied evidence is a starting
621
- point, not a limit on your research.
684
+ point, not a limit on your research. Supplied commits leave out lockfiles and
685
+ directories named build, coverage, dist, or changelog at the top of a package,
686
+ which usually hold generated output. Judge those directories yourself: when one
687
+ holds a project's source rather than generated output, its changes belong in the
688
+ changelog like any others.
622
689
 
623
690
  When mode is initialize, reconstruct this release as part of the project's full
624
- historical changelog. When mode is update, cover all missing changes in the supplied range. In both modes, review
625
- every commit, diff, and associated PR before deciding what to include. A changelog
626
- records the evolution of the project, including maintenance releases; it is not
627
- just a list of new application features.
691
+ historical changelog. When mode is update, cover all missing changes in the
692
+ supplied range. When mode is revise, the existing entries are your own earlier
693
+ draft of this release, describing it up to repository.from, and the supplied
694
+ range is what has happened since. Return the complete entries for the release as
695
+ it now stands; they replace the draft. Carry over what still holds, fold later
696
+ fixes and refinements into the entries they affect, combine entries that turn
697
+ out to describe one change, and drop an entry only when the range reverts or
698
+ supersedes it. Keep the wording of entries that need no change. In every mode,
699
+ review every commit, diff, and associated PR in the supplied range before
700
+ deciding what to include. A changelog records the evolution of the project,
701
+ including maintenance releases; it is not just a list of new application
702
+ features.
628
703
 
629
704
  Cover distinct changes to behavior, public APIs and types, CLI flags and output,
630
705
  runtime compatibility, installation and distribution, documentation and examples,
@@ -653,9 +728,16 @@ Give distinct changes separate readable bullets;
653
728
  do not compress unrelated features or breaking changes into one long entry. Keep
654
729
  each entry concise without sacrificing coverage. Consolidate related commits,
655
730
  not unrelated changes. There is no three-entry limit for package scope, including
656
- a single-package repository's root changelog. Only scope=repository denotes a
657
- short monorepo root summary; prefer three highlights there because package
658
- changelogs contain the details.
731
+ a single-package repository's root changelog.
732
+
733
+ Only scope=repository asks for a summary: an entry in a monorepo's root
734
+ changelog, which the caller ends with links to the package changelogs that hold
735
+ the details. Write what a reader of the whole repository most needs to know, in
736
+ at most three entries, rather than repeating package entries one by one. Combine
737
+ related changes across packages and name the packages they affect. When
738
+ repository.directory is a package, summarize that package's release; otherwise
739
+ summarize the repository, including changes outside any package. Do not write
740
+ the links yourself.
659
741
 
660
742
  Use commit messages and PRs to understand intent, then check the changed files
661
743
  and diffs. Inspect public interfaces at both ends of the range before claiming
@@ -694,25 +776,37 @@ Put breaking public changes in the appropriate standard category and set breakin
694
776
  to true; do not create a Breaking Changes category.
695
777
  Descriptions are single-line Markdown suitable for a list item,
696
778
  without a bullet prefix, heading, process explanation, or introductory paragraph.
697
- Every change must cite at least one evidence ID supplied for its own release in
698
- sources. Prefer the associated commit ID; use that release's range ID for changes
699
- established through your own Git research. Include a PR
779
+ Every change must cite, in sources, at least one evidence ID supplied for its own
780
+ release or the full ID of a commit within that release's range. Prefer the commit
781
+ that made the change, including one found through your own Git research; use the
782
+ release's range ID when no single commit establishes it. When mode is revise,
783
+ cite the draft evidence ID for an entry carried over or rewritten from the
784
+ existing entries, along with any commit that changed it. Include a PR
700
785
  link only when its URL and association are supplied. Do not fabricate links.
701
786
 
702
- Existing entries are provided only for deduplication and preservation. They are
703
- not independent evidence. Omit changes already expressed by those entries, even
704
- if you would phrase them differently. GitHub release-note bodies are deliberately
787
+ Existing entries are provided for deduplication and preservation, and when mode
788
+ is revise, as the draft to rewrite. They are not independent evidence. Unless
789
+ mode is revise, omit changes already expressed by those entries, even if you
790
+ would phrase them differently. GitHub release-note bodies are deliberately
705
791
  absent because this output will itself become GitHub release notes.
706
792
  Do not read existing changelog files, generated release-note files, or GitHub
707
793
  release bodies as source material, even when researching the repository directly.
708
794
 
795
+ When styleReference is present, it is a recent release section of the changelog
796
+ these entries will join. Match its writing conventions: voice and tense,
797
+ capitalization, terminal punctuation, and how code, issues, and PRs are
798
+ referenced. It is a style sample and nothing more. It describes another release,
799
+ so it is not evidence, and its length and choice of categories say nothing about
800
+ this one.
801
+
709
802
  Before returning, check coverage across behavior/API, compatibility, dependencies,
710
803
  documentation, build/test/CI, and distribution. Do not drop a change just because
711
804
  its commit is called chore, docs, build, or dependency updates. Return an empty
712
805
  changes array only when the evidence is entirely release-version bookkeeping,
713
806
  lockfile-only resolution changes, generated artifacts, formatting noise, or
714
- changes already covered by existing entries. Do not pad the result with
715
- unsupported changes.`;
807
+ changes already covered by existing entries. When mode is revise, an empty array
808
+ removes the draft, so return one only when nothing in the draft still holds. Do
809
+ not pad the result with unsupported changes.`;
716
810
  /** Invalidates cached drafts whenever the instructions change. */
717
811
  const instructionVersion = hash(instructions);
718
812
 
@@ -735,8 +829,14 @@ const efforts = [
735
829
  "xhigh",
736
830
  "max"
737
831
  ];
832
+ const rootChangelogs = [
833
+ "summary",
834
+ "package",
835
+ "none"
836
+ ];
738
837
 
739
838
  // src/lib/agents.ts
839
+ const COMMIT_ID = /^[\da-f]{7,64}$/iv;
740
840
  const CONTROL_CHARACTERS = /\p{Cc}/v;
741
841
  const HEADING = /^#{1,6}\s/v;
742
842
  const LIST_MARKER = /^(?:[\-*]|\d+\.)(?:\s+|$)/v;
@@ -755,13 +855,10 @@ const responseJsonSchema = JSON.stringify(z.toJSONSchema(responseSchema, { targe
755
855
  function normalizeDescription(description) {
756
856
  return description.replaceAll(WHITESPACE_RUN, " ").trim().replace(LIST_MARKER, "");
757
857
  }
758
- function validateChanges(changes, release) {
759
- const ids = new Set(release.evidence.map((item) => item.id));
858
+ function validateDescriptions(changes, id) {
760
859
  return changes.map((change) => {
761
- const unknown = change.sources.filter((source) => !ids.has(source));
762
- if (unknown.length > 0) throw new Error(`Agent output for ${release.id} cites evidence that was not supplied: ${unknown.join(", ")}.`);
763
860
  const description = normalizeDescription(change.description);
764
- if (description === "" || CONTROL_CHARACTERS.test(description) || HEADING.test(description) || description.includes("<!--")) throw new Error(`Agent output for ${release.id} has an empty description, heading, or hidden markup: ${JSON.stringify(change.description)}.`);
861
+ if (description === "" || CONTROL_CHARACTERS.test(description) || HEADING.test(description) || description.includes("<!--")) throw new Error(`Agent output for ${id} has an empty description, heading, or hidden markup: ${JSON.stringify(change.description)}.`);
765
862
  return {
766
863
  ...change,
767
864
  description
@@ -769,8 +866,8 @@ function validateChanges(changes, release) {
769
866
  });
770
867
  }
771
868
  /**
772
- * Validate structure, completeness, release identity, and citations before
773
- * accepting any adapter's output.
869
+ * Validate structure, completeness, and release identity before accepting any
870
+ * adapter's output. Citations are checked separately, by `verifyCitations`.
774
871
  *
775
872
  * @returns The change sets, with descriptions normalized.
776
873
  */
@@ -784,10 +881,53 @@ function validateResponse(value, releases) {
784
881
  const omitted = expected.keys().filter((id) => !ids.includes(id)).toArray();
785
882
  if (omitted.length > 0) throw new Error(`Agent output omitted requested releases: ${omitted.join(", ")}.`);
786
883
  return response.data.releases.map(({ changes, id }) => ({
787
- changes: validateChanges(changes, expected.get(id)),
884
+ changes: validateDescriptions(changes, id),
788
885
  id
789
886
  }));
790
887
  }
888
+ /** List every commit in a release's range. */
889
+ async function rangeCommits(root, { repository }) {
890
+ const { from, to } = repository;
891
+ return (await git(root, ["rev-list", from === void 0 ? to : `${from}..${to}`])).split("\n");
892
+ }
893
+ async function verifyChanges(root, release, changes) {
894
+ const supplied = new Set(release.evidence.map((item) => item.id));
895
+ let commits;
896
+ const verified = [];
897
+ for (const change of changes) {
898
+ const sources = /* @__PURE__ */ new Set();
899
+ for (const source of change.sources) if (supplied.has(source)) sources.add(source);
900
+ else if (COMMIT_ID.test(source)) {
901
+ commits ??= await rangeCommits(root, release);
902
+ const matches = commits.filter((commit) => commit.startsWith(source.toLowerCase()));
903
+ if (matches.length === 1) sources.add(matches[0]);
904
+ }
905
+ if (sources.size === 0) throw new Error(`Agent output for ${release.id} cites no supplied evidence or commit in its range: ${change.sources.join(", ")}. Cite an evidence ID supplied for that release, or the ID of a commit within its range.`);
906
+ verified.push({
907
+ ...change,
908
+ sources: [...sources]
909
+ });
910
+ }
911
+ return verified;
912
+ }
913
+ /**
914
+ * Check citations against Git instead of trusting them. A change may cite
915
+ * evidence supplied for its release or any commit within that release's range,
916
+ * which lets an agent cite what its own research found. Abbreviated commit IDs
917
+ * are completed, and sources that hold up are kept when others do not. Pass the
918
+ * requested releases with their complete evidence.
919
+ *
920
+ * @throws {Error} When a change is left without a source.
921
+ */
922
+ async function verifyCitations(root, changeSets, releases) {
923
+ const requested = new Map(releases.map((release) => [release.id, release]));
924
+ const verified = [];
925
+ for (const { changes, id } of changeSets) verified.push({
926
+ changes: await verifyChanges(root, requested.get(id), changes),
927
+ id
928
+ });
929
+ return verified;
930
+ }
791
931
  function prompt({ releases, repair, repositoryRoot }) {
792
932
  const correction = repair === void 0 ? "" : `\n\nYour previous response failed validation: ${repair.error}\nPrevious response (JSON):\n${JSON.stringify(repair.response)}\nReturn the complete corrected JSON object. Keep your previous findings, and repeat only the research the correction needs.`;
793
933
  return `${instructions}${correction}\n\nInput data (JSON):\n${JSON.stringify({
@@ -911,6 +1051,83 @@ function createAgent(name) {
911
1051
  };
912
1052
  }
913
1053
 
1054
+ // src/lib/config.ts
1055
+ const configSchema = z.strictObject({
1056
+ agent: z.enum(agentNames).optional(),
1057
+ cache: z.boolean().optional(),
1058
+ effort: z.enum(efforts).optional(),
1059
+ exclude: z.array(z.string()).optional(),
1060
+ github: z.boolean().optional(),
1061
+ /** Maximum evidence characters per agent request. */
1062
+ maxInputChars: z.number().int().min(4e3).optional(),
1063
+ model: z.string().min(1).optional(),
1064
+ /** Workspace directory globs, overriding manifest workspace discovery. */
1065
+ packages: z.array(z.string()).optional(),
1066
+ /**
1067
+ * What a monorepo's root changelog records: a summary of package releases,
1068
+ * the root package's own changes, or nothing.
1069
+ */
1070
+ rootChangelog: z.enum(rootChangelogs).optional(),
1071
+ /** Workspace package directory globs that get no changelog of their own. */
1072
+ skipPackage: z.array(z.string()).optional(),
1073
+ /** Templates containing {version} and optionally {name}. */
1074
+ tagPatterns: z.array(z.string().includes("{version}")).optional(),
1075
+ /** Minutes each agent session may run. */
1076
+ timeout: z.number().positive().max(maxTimeoutMinutes).optional()
1077
+ });
1078
+ const resolvedConfigSchema = configSchema.required({
1079
+ agent: true,
1080
+ cache: true,
1081
+ effort: true,
1082
+ github: true,
1083
+ maxInputChars: true,
1084
+ rootChangelog: true,
1085
+ timeout: true
1086
+ }).transform(({ timeout, ...config }) => ({
1087
+ ...config,
1088
+ timeoutMs: timeout * 6e4
1089
+ }));
1090
+ function invalidConfig(source, error) {
1091
+ return new Error(`Invalid options in ${source}:\n${z.prettifyError(error)}`, { cause: error });
1092
+ }
1093
+ /** Define optional repository configuration with TypeScript completion. */
1094
+ function defineConfig(config) {
1095
+ return config;
1096
+ }
1097
+ /** Load configuration from the repository root or an explicitly selected file. */
1098
+ async function loadConfig(root, file) {
1099
+ const explorer = cosmiconfig("chagent", {
1100
+ searchPlaces: [
1101
+ "chagent.config.ts",
1102
+ "chagent.config.mjs",
1103
+ "chagent.config.js",
1104
+ "chagent.config.json",
1105
+ "package.json"
1106
+ ],
1107
+ searchStrategy: "none"
1108
+ });
1109
+ const result = file === void 0 ? await explorer.search(root) : await explorer.load(file);
1110
+ if (!result) {
1111
+ log.debug("No configuration file found.");
1112
+ return {};
1113
+ }
1114
+ log.debug(`Loaded configuration from ${result.filepath}.`);
1115
+ const parsed = configSchema.safeParse(result.config);
1116
+ if (!parsed.success) throw invalidConfig(result.filepath, parsed.error);
1117
+ return parsed.data;
1118
+ }
1119
+ /**
1120
+ * Apply configuration layers over the defaults; later layers win and undefined
1121
+ * values do not.
1122
+ */
1123
+ function resolveConfig(...layers) {
1124
+ const merged = { ...defaults };
1125
+ for (const layer of layers) for (const [key, value] of Object.entries(layer)) if (value !== void 0) merged[key] = value;
1126
+ const parsed = resolvedConfigSchema.safeParse(merged);
1127
+ if (!parsed.success) throw invalidConfig("generation settings", parsed.error);
1128
+ return parsed.data;
1129
+ }
1130
+
914
1131
  // src/lib/format.ts
915
1132
  const FORMATTER_PACKAGES = ["@kitschpatrol/shared-config", "@kitschpatrol/prettier-config"];
916
1133
  const manifestSchema = z.object({ exports: z.object({ ".": z.object({ import: z.string() }) }) });
@@ -1013,9 +1230,11 @@ async function writeChangelog(plan) {
1013
1230
  const edit = plan.files.find((file) => file.path === checkpoint.path);
1014
1231
  try {
1015
1232
  const written = edit === void 0 ? void 0 : await readOptional(edit.path);
1233
+ const edited = edit !== void 0 && changelogHash(edit.after) !== checkpoint.contentHash;
1016
1234
  await writeCache(path.join(stateDirectory, "checkpoints"), checkpoint.path, {
1017
1235
  ...checkpoint,
1018
- contentHash: written === void 0 ? checkpoint.contentHash : changelogHash(written)
1236
+ contentHash: written === void 0 ? checkpoint.contentHash : changelogHash(written),
1237
+ generated: checkpoint.generated && !edited
1019
1238
  });
1020
1239
  } catch {
1021
1240
  plan.diagnostics.push("Could not save incremental metadata; the next run will recheck the full release range.");
@@ -1050,75 +1269,6 @@ async function acquireLock(file) {
1050
1269
  }
1051
1270
  }
1052
1271
 
1053
- // src/lib/config.ts
1054
- const configSchema = z.strictObject({
1055
- agent: z.enum(agentNames).optional(),
1056
- cache: z.boolean().optional(),
1057
- effort: z.enum(efforts).optional(),
1058
- exclude: z.array(z.string()).optional(),
1059
- github: z.boolean().optional(),
1060
- /** Maximum evidence characters per agent request. */
1061
- maxInputChars: z.number().int().min(4e3).optional(),
1062
- model: z.string().min(1).optional(),
1063
- /** Workspace directory globs, overriding manifest workspace discovery. */
1064
- packages: z.array(z.string()).optional(),
1065
- /** Templates containing {version} and optionally {name}. */
1066
- tagPatterns: z.array(z.string().includes("{version}")).optional(),
1067
- /** Minutes each agent session may run. */
1068
- timeout: z.number().positive().max(maxTimeoutMinutes).optional()
1069
- });
1070
- const resolvedConfigSchema = configSchema.required({
1071
- agent: true,
1072
- cache: true,
1073
- effort: true,
1074
- github: true,
1075
- maxInputChars: true,
1076
- timeout: true
1077
- }).transform(({ timeout, ...config }) => ({
1078
- ...config,
1079
- timeoutMs: timeout * 6e4
1080
- }));
1081
- function invalidConfig(source, error) {
1082
- return new Error(`Invalid options in ${source}:\n${z.prettifyError(error)}`, { cause: error });
1083
- }
1084
- /** Define optional repository configuration with TypeScript completion. */
1085
- function defineConfig(config) {
1086
- return config;
1087
- }
1088
- /** Load configuration from the repository root or an explicitly selected file. */
1089
- async function loadConfig(root, file) {
1090
- const explorer = cosmiconfig("chagent", {
1091
- searchPlaces: [
1092
- "chagent.config.ts",
1093
- "chagent.config.mjs",
1094
- "chagent.config.js",
1095
- "chagent.config.json",
1096
- "package.json"
1097
- ],
1098
- searchStrategy: "none"
1099
- });
1100
- const result = file === void 0 ? await explorer.search(root) : await explorer.load(file);
1101
- if (!result) {
1102
- log.debug("No configuration file found.");
1103
- return {};
1104
- }
1105
- log.debug(`Loaded configuration from ${result.filepath}.`);
1106
- const parsed = configSchema.safeParse(result.config);
1107
- if (!parsed.success) throw invalidConfig(result.filepath, parsed.error);
1108
- return parsed.data;
1109
- }
1110
- /**
1111
- * Apply configuration layers over the defaults; later layers win and undefined
1112
- * values do not.
1113
- */
1114
- function resolveConfig(...layers) {
1115
- const merged = { ...defaults };
1116
- for (const layer of layers) for (const [key, value] of Object.entries(layer)) if (value !== void 0) merged[key] = value;
1117
- const parsed = resolvedConfigSchema.safeParse(merged);
1118
- if (!parsed.success) throw invalidConfig("generation settings", parsed.error);
1119
- return parsed.data;
1120
- }
1121
-
1122
1272
  // src/lib/changelog.ts
1123
1273
  const LINE_BOUNDARY = /(?<=\n)/v;
1124
1274
  const FENCE = /^\s{0,3}(`{3,}|~{3,})/v;
@@ -1130,9 +1280,14 @@ const WHITESPACE = /\s+/gv;
1130
1280
  const TERMINAL_PUNCTUATION = /[.!]$/v;
1131
1281
  const NEWLINE = /\r?\n/v;
1132
1282
  const BULLET = /^\s*[\-*] /v;
1283
+ const ENTRY = /^\s*[\-*] /mv;
1284
+ const STYLE_REFERENCE_LENGTH = 2e3;
1133
1285
  const CATEGORY_HEADING = /^### /mv;
1286
+ const PACKAGES_HEADING = /^### Packages\s*$/mv;
1287
+ const LINK_TARGET = /\]\(([^\)]*)\)/gv;
1134
1288
  const SEMVER_LINE = /^.*\[Semantic Versioning\].*(?:\r?\n|$)/mv;
1135
1289
  const UNRELEASED_REFERENCE = /^\[unreleased\]:[^\r\n]*(?:\r?\n)?/imv;
1290
+ const V_PREFIX = /^v\d/v;
1136
1291
  const changelogHeader = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n";
1137
1292
  /** The body of a release section without any changes to record. */
1138
1293
  const noNotableChanges = "No notable changes.";
@@ -1201,6 +1356,21 @@ function parseChangelog(content) {
1201
1356
  sections
1202
1357
  };
1203
1358
  }
1359
+ /**
1360
+ * Pick the newest release section with entries, to show an agent how this
1361
+ * changelog is written. Long sections are cut at a line boundary.
1362
+ */
1363
+ function styleReference({ sections }) {
1364
+ const body = sections.filter(({ version }) => version !== "Unreleased").map((section) => withoutPackageLinks(section.body)).find((entries) => ENTRY.test(entries));
1365
+ if (body === void 0) return;
1366
+ const lines = body.trim().split(LINE_BOUNDARY);
1367
+ let sample = "";
1368
+ for (const line of lines) {
1369
+ if (sample.length + line.length > STYLE_REFERENCE_LENGTH) break;
1370
+ sample += line;
1371
+ }
1372
+ return sample.trim() === "" ? void 0 : sample.trimEnd();
1373
+ }
1204
1374
  function normalized(description) {
1205
1375
  return description.trim().toLowerCase().replaceAll(WHITESPACE, " ").replace(TERMINAL_PUNCTUATION, "");
1206
1376
  }
@@ -1240,6 +1410,46 @@ function laterCategoryIndex(body, category) {
1240
1410
  const indexes = categories.slice(categories.indexOf(category) + 1).map((item) => categoryHeading(body, item)?.index).filter((index) => index !== void 0);
1241
1411
  return indexes.length === 0 ? void 0 : Math.min(...indexes);
1242
1412
  }
1413
+ /** Locate the package links that end a root summary's section. */
1414
+ function packagesBlock(body) {
1415
+ const heading = PACKAGES_HEADING.exec(body);
1416
+ if (heading === null) return;
1417
+ const after = heading.index + heading[0].length;
1418
+ const next = CATEGORY_HEADING.exec(body.slice(after));
1419
+ return {
1420
+ start: heading.index,
1421
+ end: next === null ? body.length : after + next.index
1422
+ };
1423
+ }
1424
+ /** Remove the package links from a root summary's section, leaving its entries. */
1425
+ function withoutPackageLinks(body) {
1426
+ const block = packagesBlock(body);
1427
+ return block === void 0 ? body : body.slice(0, block.start) + body.slice(block.end);
1428
+ }
1429
+ /**
1430
+ * Check whether a section links to other package releases than it should.
1431
+ * Targets are compared, since a formatter may restyle the list around them.
1432
+ */
1433
+ function packageLinksDiffer(body, links) {
1434
+ const block = body === void 0 ? void 0 : packagesBlock(body);
1435
+ const targets = body === void 0 || block === void 0 ? [] : body.slice(block.start, block.end).matchAll(LINK_TARGET).map((match) => match[1]).toArray();
1436
+ return !isDeepStrictEqual(targets, links.map(({ url }) => url));
1437
+ }
1438
+ /**
1439
+ * Merge new changes into a section's draft, or replace a revised draft with
1440
+ * them, and end the section with its package links.
1441
+ */
1442
+ function sectionBody({ changes, existingBody = "", links, revise }, newline) {
1443
+ const draft = links === void 0 ? existingBody : withoutPackageLinks(existingBody);
1444
+ const body = mergeChanges(revise === true ? "" : draft, changes, newline);
1445
+ if (links === void 0 || links.length === 0) return body;
1446
+ const list = links.map(({ label, url }) => `- [${label}](${url})`);
1447
+ return body.trimEnd() + newline + newline + [
1448
+ "### Packages",
1449
+ "",
1450
+ ...list
1451
+ ].join(newline);
1452
+ }
1243
1453
  /** Render a release heading with an optional comparison link. */
1244
1454
  function releaseLink(github, release) {
1245
1455
  if (github === void 0) return;
@@ -1260,12 +1470,16 @@ function updateMarkdown(original, entries, nodeException, github) {
1260
1470
  const draft = parsed.sections.find((section) => section.version === "Unreleased");
1261
1471
  const released = parsed.sections.filter((section) => section.version !== "Unreleased").map((section) => content.slice(section.start, section.end));
1262
1472
  let draftText = draft === void 0 ? "" : content.slice(draft.start, draft.end);
1263
- for (const { changes, existingBody, release } of entries) {
1473
+ for (const entry of entries) {
1474
+ const { existingBody, release } = entry;
1264
1475
  const link = releaseLink(github, release);
1265
1476
  const heading = `## ${link === void 0 ? release.version : `[${release.version}]`}${release.date === void 0 ? "" : ` - ${release.date}`}`;
1266
- let body = mergeChanges(existingBody ?? "", changes, newline);
1477
+ let body = sectionBody(entry, newline);
1267
1478
  if (body.trim() === "") {
1268
- if (release.version === "Unreleased") continue;
1479
+ if (release.version === "Unreleased") {
1480
+ if (existingBody !== void 0) draftText = "";
1481
+ continue;
1482
+ }
1269
1483
  body = newline + noNotableChanges + newline;
1270
1484
  }
1271
1485
  const section = heading + newline + newline + body.trim() + newline + newline;
@@ -1302,8 +1516,15 @@ function addNodeNote(header, newline, nodeException) {
1302
1516
  }
1303
1517
  return header;
1304
1518
  }
1519
+ /**
1520
+ * Read a heading's version the way default tag patterns read a tag, so `1.2`
1521
+ * and `v1` name the first release of their series and a bare `1` names none.
1522
+ */
1305
1523
  function normalizeVersion(raw) {
1306
- return raw.toLowerCase() === "unreleased" ? "Unreleased" : semver.valid(raw) ?? raw;
1524
+ if (raw.toLowerCase() === "unreleased") return "Unreleased";
1525
+ const prefixed = V_PREFIX.test(raw);
1526
+ const parsed = parseVersion(prefixed ? raw.slice(1) : raw);
1527
+ return parsed === void 0 || !prefixed && parsed.precision === 1 ? raw : parsed.version;
1307
1528
  }
1308
1529
  function releaseHeadingVersion(line) {
1309
1530
  const match = RELEASE_HEADING.exec(line.trimEnd());
@@ -1319,20 +1540,32 @@ function sectionSpacing(content, newline) {
1319
1540
 
1320
1541
  // src/lib/evidence.ts
1321
1542
  const COMMIT_MARKER = "";
1322
- const GENERATED_DIRECTORY = /^(?:build|changelog|coverage|dist)(?:\/|$)/iv;
1323
- const GENERATED_ANYWHERE = /(?:^|\/)(?:changelog(?:\.md)?$|node_modules(?:\/|$))/iv;
1543
+ const OUTPUT_DIRECTORY = /^(?:build|changelog|coverage|dist)(?:\/|$)/iv;
1544
+ const NEVER_SOURCE = /(?:^|\/)(?:changelog(?:\.md)?$|node_modules(?:\/|$))/iv;
1324
1545
  /**
1325
- * Generated output lives at the top of a package, so `packages/build` and
1326
- * `src/dist` remain source. Lockfiles record resolved versions, not declared
1327
- * dependency ranges.
1546
+ * Changelogs are this tool's own output, installed dependencies are another
1547
+ * project's source, and lockfiles record resolved versions, not declared
1548
+ * dependency ranges. None of them is evidence of a change.
1328
1549
  */
1329
- function ignored(file, packages) {
1550
+ function excluded(file) {
1551
+ return LOCKFILE_PATH.test(file) || NEVER_SOURCE.test(file);
1552
+ }
1553
+ /**
1554
+ * Recognize directories at the top of a package that usually hold generated
1555
+ * output, so `packages/build` and `src/dist` remain source. Supplied commits
1556
+ * leave them out, but they still count as changes to research: only the agent
1557
+ * can tell a build artifact from a project whose source lives in `coverage`.
1558
+ */
1559
+ function likelyGenerated(file, packages) {
1330
1560
  const owner = packages.findLast(({ directory }) => file.startsWith(`${directory}/`));
1331
1561
  const relative = owner === void 0 ? file : file.slice(owner.directory.length + 1);
1332
- return LOCKFILE_PATH.test(file) || GENERATED_ANYWHERE.test(file) || GENERATED_DIRECTORY.test(relative);
1562
+ return OUTPUT_DIRECTORY.test(relative);
1333
1563
  }
1334
- async function scopes(repository, pkg, ref) {
1335
- if (pkg.directory === ".") return ["."];
1564
+ /**
1565
+ * A package's directory and, transitively, those of the workspace packages it
1566
+ * depends on at a ref.
1567
+ */
1568
+ async function packageScope(repository, pkg, ref) {
1336
1569
  const directories = /* @__PURE__ */ new Set([pkg.directory]);
1337
1570
  const visit = async (current) => {
1338
1571
  const manifest = await manifestAt(repository.root, current.directory, ref);
@@ -1399,10 +1632,13 @@ async function commitsInRange(root, range, paths) {
1399
1632
  }
1400
1633
  return commits.values().toArray();
1401
1634
  }
1402
- /** Collect source changes, excluding generated notes and release-only commits. */
1635
+ /**
1636
+ * Collect a release's starting evidence, or nothing when its range holds only
1637
+ * changelogs, lockfiles, and version bookkeeping.
1638
+ */
1403
1639
  async function collectEvidence(repository, pkg, release, excludeDirectories = []) {
1404
1640
  const { packages, root } = repository;
1405
- const paths = [...(await scopes(repository, pkg, release.ref)).map((directory) => `:(literal)${directory}`), ...excludeDirectories.map((directory) => `:(exclude,literal)${directory}`)];
1641
+ const paths = [...(pkg.directory === "." ? ["."] : await packageScope(repository, pkg, release.ref)).map((directory) => `:(literal)${directory}`), ...excludeDirectories.map((directory) => `:(exclude,literal)${directory}`)];
1406
1642
  const range = release.previousRef === void 0 ? release.ref : `${release.previousRef}..${release.ref}`;
1407
1643
  const from = release.previousRef ?? await git(root, [
1408
1644
  "hash-object",
@@ -1421,20 +1657,25 @@ async function collectEvidence(repository, pkg, release, excludeDirectories = []
1421
1657
  release.ref,
1422
1658
  "--",
1423
1659
  ...paths
1424
- ])).split("\0").filter((file) => file !== "" && !ignored(file, packages));
1660
+ ])).split("\0").filter((file) => file !== "" && !excluded(file));
1425
1661
  const rangeBlobs = await readBlobs(root, manifestObjects(candidates, from, release.ref));
1426
1662
  const changed = new Set(candidates.filter((file) => !isManifest(file) || !isVersionOnlyEdit(rangeBlobs.get(`${from}:${file}`), rangeBlobs.get(`${release.ref}:${file}`))));
1427
1663
  if (changed.size === 0) return [];
1428
1664
  const sourceCommits = (await commitsInRange(root, range, paths)).map((item) => ({
1429
1665
  ...item,
1430
- files: item.files.filter((file) => !ignored(file, packages))
1666
+ files: item.files.filter((file) => !excluded(file) && !likelyGenerated(file, packages))
1431
1667
  }));
1432
1668
  const commitBlobs = await readBlobs(root, sourceCommits.flatMap(({ files, id }) => manifestObjects(files, `${id}^`, id)));
1433
- const evidence = sourceCommits.filter(({ files, id }) => files.some((file) => changed.has(file)) && hasSourceChanges(files, id, commitBlobs)).map(({ files, id, message }) => ({
1669
+ const evidence = [{
1670
+ id: `range:${from}..${release.ref}`,
1671
+ kind: "range",
1672
+ text: `Research the complete Git range ${range} using git log, git diff, git show, and git ls-tree. The supplied commits are a starting point, not a complete description of changes. Follow the scope and exclusions in repository.`
1673
+ }];
1674
+ for (const { files, id, message } of sourceCommits) if (files.some((file) => changed.has(file)) && hasSourceChanges(files, id, commitBlobs)) evidence.push({
1434
1675
  id,
1435
1676
  kind: "commit",
1436
1677
  text: `${message}\nFiles: ${files.join(", ")}`
1437
- }));
1678
+ });
1438
1679
  if (release.pending === true) {
1439
1680
  const file = path.posix.join(pkg.directory, "package.json");
1440
1681
  const diff = await git(root, [
@@ -1451,11 +1692,6 @@ async function collectEvidence(repository, pkg, release, excludeDirectories = []
1451
1692
  text: diff
1452
1693
  });
1453
1694
  }
1454
- if (evidence.length > 0) evidence.unshift({
1455
- id: `range:${from}..${release.ref}`,
1456
- kind: "range",
1457
- text: `Research the complete Git range ${range} using git log, git diff, git show, and git ls-tree. The supplied commits are a starting point, not a complete description of changes. Follow the scope and exclusions in repository.`
1458
- });
1459
1695
  return evidence;
1460
1696
  }
1461
1697
  function githubUnavailable(error) {
@@ -1592,7 +1828,7 @@ function releaseBatches(requests, limit) {
1592
1828
  for (const release of requests) {
1593
1829
  const minimum = {
1594
1830
  ...release,
1595
- evidence: release.evidence.filter((item) => item.kind === "range")
1831
+ evidence: release.evidence.filter((item) => required(item))
1596
1832
  };
1597
1833
  const length = JSON.stringify(minimum).length + 1;
1598
1834
  if (size + length > limit && batch.length > 0) {
@@ -1608,7 +1844,7 @@ function releaseBatches(requests, limit) {
1608
1844
  const allowance = Math.max(0, Math.floor((limit - JSON.stringify(items).length) / items.length));
1609
1845
  const seeded = items.map((item) => ({
1610
1846
  ...item,
1611
- evidence: [...item.evidence, ...researchEvidence(full.get(item.id).filter((entry) => entry.kind !== "range"), allowance + 2)]
1847
+ evidence: [...item.evidence, ...researchEvidence(full.get(item.id).filter((entry) => !required(entry)), allowance + 2)]
1612
1848
  }));
1613
1849
  let remaining = limit - JSON.stringify(seeded).length;
1614
1850
  for (const item of seeded) {
@@ -1621,6 +1857,10 @@ function releaseBatches(requests, limit) {
1621
1857
  return seeded;
1622
1858
  });
1623
1859
  }
1860
+ /** Keep what a request cannot do without: its range, and a draft it may cite. */
1861
+ function required(item) {
1862
+ return item.kind === "range" || item.kind === "draft";
1863
+ }
1624
1864
  /** Bound the starting context; adapters can read the complete range themselves. */
1625
1865
  function researchEvidence(evidence, limit) {
1626
1866
  const seed = [];
@@ -1635,19 +1875,32 @@ function researchEvidence(evidence, limit) {
1635
1875
  }
1636
1876
 
1637
1877
  // src/lib/jobs.ts
1878
+ /** Lets a revised entry cite the draft it came from. */
1879
+ const draftEvidence = {
1880
+ id: "draft",
1881
+ kind: "draft",
1882
+ text: "The existing entries, which describe this release up to repository.from. Cite this ID for entries carried over or rewritten from them."
1883
+ };
1884
+ /** Check whether a package's changelog is a monorepo's root summary. */
1885
+ function summarizesPackages(context, pkg) {
1886
+ return pkg.directory === "." && context.repository.packages.length > 0 && context.options.rootChangelog === "summary";
1887
+ }
1638
1888
  /**
1639
1889
  * Prepare one release without launching a model, so repository research can be
1640
1890
  * shared.
1641
1891
  */
1642
- async function prepareRelease(context, pkg, release, existingEntries, mode, from, onlyRepositoryFiles = false) {
1892
+ async function prepareRelease(context, pkg, release, details) {
1643
1893
  const { options, repository } = context;
1894
+ const { existingEntries = "", from, mode, onlyRepositoryFiles = false, styleReference } = details;
1644
1895
  options.signal?.throwIfAborted();
1645
- const excludeDirectories = onlyRepositoryFiles ? repository.packages.map((item) => item.directory) : [];
1896
+ const excludeDirectories = await excludedPackages(context, pkg, release.ref, onlyRepositoryFiles);
1897
+ const evidence = await collectEvidence(repository, pkg, from === void 0 ? release : {
1898
+ ...release,
1899
+ previousRef: from
1900
+ }, excludeDirectories);
1901
+ if (mode === "revise" && evidence.length > 0) evidence.splice(1, 0, draftEvidence);
1646
1902
  return {
1647
- evidence: await collectEvidence(repository, pkg, from === void 0 ? release : {
1648
- ...release,
1649
- previousRef: from
1650
- }, excludeDirectories),
1903
+ evidence,
1651
1904
  existingEntries,
1652
1905
  id: `${pkg.directory}@${release.version}`,
1653
1906
  initialRelease: release.previousRef === void 0 && from === void 0,
@@ -1659,10 +1912,25 @@ async function prepareRelease(context, pkg, release, existingEntries, mode, from
1659
1912
  from: from ?? release.previousRef,
1660
1913
  to: release.ref
1661
1914
  },
1662
- scope: pkg.directory === "." && repository.packages.length > 0 ? "repository" : "package",
1915
+ scope: summarizesPackages(context, pkg) ? "repository" : "package",
1916
+ styleReference,
1663
1917
  version: release.version
1664
1918
  };
1665
1919
  }
1920
+ /**
1921
+ * Select the package directories a release leaves out: every package when
1922
+ * describing repository files alone, skipped packages in a whole-repository
1923
+ * summary, or, when the root is a package itself, those it does not depend on.
1924
+ */
1925
+ async function excludedPackages(context, pkg, ref, onlyRepositoryFiles) {
1926
+ const { options, repository } = context;
1927
+ const directories = repository.packages.map((item) => item.directory);
1928
+ if (onlyRepositoryFiles || directories.length === 0) return directories;
1929
+ if (pkg.directory !== ".") return [];
1930
+ if (options.rootChangelog === "summary") return repository.packages.filter((item) => item.skipped === true).map((item) => item.directory);
1931
+ const scope = new Set(await packageScope(repository, pkg, ref));
1932
+ return directories.filter((directory) => !scope.has(directory));
1933
+ }
1666
1934
  function cacheKey(context, release) {
1667
1935
  const { effort, maxInputChars, model } = context.options;
1668
1936
  return JSON.stringify({
@@ -1711,9 +1979,11 @@ function progressMessage(pending, sessions) {
1711
1979
  }
1712
1980
  /**
1713
1981
  * Run one agent session. A long session is worth saving, so invalid output gets
1714
- * one correction request before the run fails.
1982
+ * one correction request before the run fails. Citations are checked against
1983
+ * the releases as they were before their evidence was trimmed to fit, since an
1984
+ * agent researching a range can cite what trimming left out.
1715
1985
  */
1716
- async function runSession(context, releases) {
1986
+ async function runSession(context, releases, untrimmed) {
1717
1987
  const { agent, options, repository } = context;
1718
1988
  const request = {
1719
1989
  releases,
@@ -1726,24 +1996,24 @@ async function runSession(context, releases) {
1726
1996
  }
1727
1997
  };
1728
1998
  log.debug(`Starting a ${agent.name} session for ${releases.map(({ id }) => id).join(", ")} with model ${options.model ?? "default"}, effort ${options.effort}, and a ${options.timeoutMs} ms timeout.`);
1999
+ const accept = async (value) => verifyCitations(repository.root, validateResponse(value, releases), untrimmed);
1729
2000
  const started = performance.now();
1730
2001
  const response = await agent.generate(request);
1731
2002
  log.debug(`The ${agent.name} session finished in ${Math.round(performance.now() - started)} ms.`);
1732
2003
  try {
1733
- return validateResponse(response, releases);
2004
+ return await accept(response);
1734
2005
  } catch (error) {
1735
2006
  const message = error instanceof Error ? error.message : String(error);
1736
2007
  log.warn(`Requesting a correction of invalid agent output. ${message}`);
1737
2008
  options.onProgress?.("Requesting a correction of invalid agent output.");
1738
2009
  options.signal?.throwIfAborted();
1739
- const corrected = await agent.generate({
2010
+ return accept(await agent.generate({
1740
2011
  ...request,
1741
2012
  repair: {
1742
2013
  error: message,
1743
2014
  response
1744
2015
  }
1745
- });
1746
- return validateResponse(corrected, releases);
2016
+ }));
1747
2017
  }
1748
2018
  }
1749
2019
  /**
@@ -1776,7 +2046,7 @@ async function generateReleases(context, requests) {
1776
2046
  options.onProgress?.(status);
1777
2047
  for (const releases of batches) {
1778
2048
  options.signal?.throwIfAborted();
1779
- const changeSets = await runSession(context, releases);
2049
+ const changeSets = await runSession(context, releases, releases.map(({ id }) => unabridged.get(id)));
1780
2050
  for (const { changes, id } of changeSets) {
1781
2051
  results.set(id, changes);
1782
2052
  await saveChanges(context, unabridged.get(id), changes);
@@ -1789,8 +2059,9 @@ async function generateReleases(context, requests) {
1789
2059
  const checkpointSchema = z.object({
1790
2060
  baseRef: z.string(),
1791
2061
  contentHash: z.string(),
2062
+ generated: z.boolean(),
1792
2063
  head: z.string(),
1793
- instructionVersion: z.literal(instructionVersion),
2064
+ instructionVersion: z.string(),
1794
2065
  path: z.string()
1795
2066
  });
1796
2067
  const HEADING_PREFIX = /^#{1,3}\s+/v;
@@ -1799,27 +2070,43 @@ const ANCHOR_PUNCTUATION = /[^\p{L}\p{N}_\s\-]/gv;
1799
2070
  const ANCHOR_SPACE = /\s/gv;
1800
2071
  /**
1801
2072
  * Remember what this run covered, so the next one can research only newer
1802
- * commits.
2073
+ * commits, and whether the draft it leaves is entirely generated.
1803
2074
  */
1804
- function recordCheckpoint(context, file, content, release) {
2075
+ function recordCheckpoint(context, file, content, release, generated) {
1805
2076
  if (release === void 0 || !context.options.cache) return;
1806
2077
  context.plan.checkpoints.push({
1807
2078
  baseRef: release.previousRef ?? "initial",
1808
2079
  contentHash: changelogHash(content),
2080
+ generated,
1809
2081
  head: context.repository.head,
1810
2082
  instructionVersion,
1811
2083
  path: file
1812
2084
  });
1813
2085
  }
1814
2086
  /**
1815
- * Find the commit a changelog already covers, when its checkpoint is still
1816
- * valid.
2087
+ * Read what the last run recorded about a changelog, when the file still reads
2088
+ * as that run left it.
1817
2089
  */
1818
- async function incrementalBase(context, file, content, release) {
2090
+ async function readCheckpoint(context, file, content) {
1819
2091
  if (content === void 0 || !context.options.cache) return;
1820
2092
  const result = checkpointSchema.safeParse(await readCache(path.join(context.repository.gitDirectory, "chagent", "checkpoints"), file));
1821
- if (!result.success || result.data.contentHash !== changelogHash(content) || result.data.baseRef !== (release.previousRef ?? "initial")) return;
1822
- return await isAncestor(context.repository.root, result.data.head, release.ref) ? result.data.head : void 0;
2093
+ return result.success && result.data.contentHash === changelogHash(content) ? result.data : void 0;
2094
+ }
2095
+ /**
2096
+ * Find the commit a release's entries already cover, when the checkpoint
2097
+ * describes the same release.
2098
+ */
2099
+ async function incrementalBase(context, checkpoint, release) {
2100
+ if (checkpoint?.instructionVersion !== instructionVersion || checkpoint.baseRef !== (release.previousRef ?? "initial")) return;
2101
+ return await isAncestor(context.repository.root, checkpoint.head, release.ref) ? checkpoint.head : void 0;
2102
+ }
2103
+ /**
2104
+ * Decide whether the draft a run leaves is entirely generated: one that was
2105
+ * before stays so, and a draft written from nothing or revised becomes so.
2106
+ */
2107
+ function leavesGeneratedDraft(entries, previously) {
2108
+ const draft = entries.find((entry) => entry.release.version === "Unreleased");
2109
+ return previously || draft !== void 0 && (draft.existingBody === void 0 || draft.revise === true);
1823
2110
  }
1824
2111
  /** Select the releases newer than anything the changelog already records. */
1825
2112
  function missingReleases(releases, content = "") {
@@ -1836,8 +2123,28 @@ function missingReleases(releases, content = "") {
1836
2123
  function releaseContext(options, pkg) {
1837
2124
  return pkg.directory === options.releaseDirectory ? options.release : void 0;
1838
2125
  }
1839
- /** Resolve a package's releases and prepare requests for those it still needs. */
1840
- async function preparePackage(context, pkg, aggregateHistory = false, recordedAtRoot) {
2126
+ /** Prepare the request for a release the changelog still needs. */
2127
+ async function prepareWork(context, pkg, release, details) {
2128
+ const { checkpoint, existingBody, mode } = details;
2129
+ const from = existingBody === void 0 ? void 0 : await incrementalBase(context, checkpoint, release);
2130
+ const revise = from !== void 0 && checkpoint?.generated === true;
2131
+ return {
2132
+ existingBody,
2133
+ release,
2134
+ request: await prepareRelease(context, pkg, release, {
2135
+ existingEntries: existingBody !== void 0 && summarizesPackages(context, pkg) ? withoutPackageLinks(existingBody) : existingBody,
2136
+ from,
2137
+ mode: revise ? "revise" : mode,
2138
+ styleReference: details.styleReference
2139
+ })
2140
+ };
2141
+ }
2142
+ /**
2143
+ * Resolve a package's releases and prepare requests for those its changelog
2144
+ * still needs. Summaries cover those releases alone, or with `allSummaries`,
2145
+ * every release, so that a root changelog can link to any of them.
2146
+ */
2147
+ async function preparePackage(context, pkg, allSummaries = false) {
1841
2148
  const { repository } = context;
1842
2149
  const file = await findChangelog(path.join(repository.root, pkg.directory));
1843
2150
  const content = context.options.rebuild ? void 0 : file.content;
@@ -1845,15 +2152,17 @@ async function preparePackage(context, pkg, aggregateHistory = false, recordedAt
1845
2152
  const releases = await resolveReleases(repository, pkg, context.options, releaseContext(context.options, pkg));
1846
2153
  const missing = missingReleases(releases, content);
1847
2154
  log.debug(`${pkg.name}: ${releases.length} releases, missing [${missing.map((release) => release.version).join(", ")}], changelog ${file.path}.`);
1848
- const draft = parseChangelog(content ?? "").sections.find((section) => section.version === "Unreleased");
2155
+ const parsed = parseChangelog(content ?? "");
2156
+ const draft = parsed.sections.find((section) => section.version === "Unreleased");
2157
+ const checkpoint = draft === void 0 ? void 0 : await readCheckpoint(context, file.path, content);
1849
2158
  const newest = releases.findLast((release) => release.version !== "Unreleased");
1850
2159
  const draftPromoted = draft !== void 0 && newest !== void 0 && missing.includes(newest);
1851
2160
  const work = [];
1852
2161
  const summaries = [];
1853
2162
  for (const release of releases) {
1854
2163
  const shouldWrite = missing.includes(release);
1855
- if (!shouldWrite && !aggregateHistory) continue;
1856
- const summary = {
2164
+ if (!shouldWrite && !allSummaries) continue;
2165
+ summaries.push({
1857
2166
  changelogPath: file.path,
1858
2167
  changes: [],
1859
2168
  date: release.date,
@@ -1862,41 +2171,49 @@ async function preparePackage(context, pkg, aggregateHistory = false, recordedAt
1862
2171
  ref: release.ref,
1863
2172
  tag: release.tag,
1864
2173
  version: release.version
1865
- };
1866
- summaries.push(summary);
1867
- if (!shouldWrite && recordedAtRoot?.(summary) === true) continue;
1868
- const existingBody = (draftPromoted ? release === newest : release.version === "Unreleased") ? draft?.body : void 0;
1869
- const from = existingBody === void 0 ? void 0 : await incrementalBase(context, file.path, content, release);
1870
- const request = await prepareRelease(context, pkg, release, existingBody ?? "", mode, from);
1871
- work.push({
1872
- existingBody,
1873
- release,
1874
- request,
1875
- shouldWrite
1876
2174
  });
2175
+ if (!shouldWrite) continue;
2176
+ const ownsDraft = draftPromoted ? release === newest : release.version === "Unreleased";
2177
+ work.push(await prepareWork(context, pkg, release, {
2178
+ checkpoint,
2179
+ existingBody: ownsDraft ? draft?.body : void 0,
2180
+ mode,
2181
+ styleReference: styleReference(parsed)
2182
+ }));
1877
2183
  }
2184
+ const exception = await hasNodeException(repository, pkg, releases);
1878
2185
  return {
1879
2186
  content,
1880
- exception: await hasNodeException(repository, pkg, releases),
2187
+ draftGenerated: checkpoint?.generated === true,
2188
+ exception,
1881
2189
  file,
2190
+ pkg,
1882
2191
  releases,
1883
2192
  summaries,
1884
2193
  work
1885
2194
  };
1886
2195
  }
1887
- /** Write a package's edit into the plan and complete its summaries. */
1888
- function renderPackage(context, prepared, results) {
2196
+ /**
2197
+ * Write a package's edit into the plan and complete its summaries. A root
2198
+ * summary passes `linksFor` to end each section it writes with links to the
2199
+ * package releases that section covers.
2200
+ */
2201
+ function renderPackage(context, prepared, results, linksFor) {
1889
2202
  const { plan, repository } = context;
1890
2203
  const { content, exception, file, releases, summaries, work } = prepared;
1891
2204
  const entries = [];
1892
2205
  const changesByVersion = /* @__PURE__ */ new Map();
1893
- for (const { existingBody, release, request, shouldWrite } of work) {
2206
+ for (const { existingBody, release, request } of work) {
1894
2207
  const changes = results.get(request.id) ?? [];
2208
+ const links = linksFor?.(release);
2209
+ const revise = request.mode === "revise" && results.has(request.id);
1895
2210
  changesByVersion.set(release.version, changes);
1896
- if (shouldWrite && (release.version !== "Unreleased" || changes.length > 0)) entries.push({
2211
+ if (revise || release.version !== "Unreleased" || changes.length > 0 || links !== void 0 && packageLinksDiffer(existingBody, links)) entries.push({
1897
2212
  changes,
1898
2213
  existingBody,
1899
- release
2214
+ links,
2215
+ release,
2216
+ revise
1900
2217
  });
1901
2218
  }
1902
2219
  const after = updateMarkdown(content, entries, exception, repository.github);
@@ -1905,7 +2222,7 @@ function renderPackage(context, prepared, results) {
1905
2222
  before: file.content,
1906
2223
  path: file.path
1907
2224
  });
1908
- recordCheckpoint(context, file.path, after, releases.at(-1));
2225
+ recordCheckpoint(context, file.path, after, releases.at(-1), leavesGeneratedDraft(entries, prepared.draftGenerated));
1909
2226
  const { sections } = parseChangelog(after);
1910
2227
  return summaries.map((summary) => ({
1911
2228
  ...summary,
@@ -1934,74 +2251,40 @@ function missingPackageSummaries(summaries, content = "") {
1934
2251
  }
1935
2252
 
1936
2253
  // src/lib/workspace.ts
2254
+ /** Link to a package's release section, when its changelog has one. */
1937
2255
  function packageLink(repository, summary) {
1938
- const relative = path.relative(repository.root, summary.changelogPath).split(path.sep).join("/");
1939
- const anchor = summary.anchor ?? (summary.version === "Unreleased" ? "unreleased" : `${summary.version.replaceAll(".", "")}${summary.date === void 0 ? "" : "---" + summary.date}`);
1940
- return repository.github === void 0 ? `${relative}#${anchor}` : `https://github.com/${repository.github}/blob/HEAD/${relative}#${anchor}`;
1941
- }
1942
- function aggregateChanges(repository, summaries) {
1943
- const changes = [];
1944
- for (const summary of summaries) {
1945
- if (summary.version === "Unreleased" && summary.changes.length === 0) continue;
1946
- const link = `[${summary.packageName} ${summary.version}](${packageLink(repository, summary)})`;
1947
- const highlights = summary.changes.slice(0, 3);
1948
- if (highlights.length === 0) changes.push({
1949
- breaking: false,
1950
- category: "Maintenance",
1951
- description: `${link}: See the package changelog.`,
1952
- sources: []
1953
- });
1954
- else for (const change of highlights) changes.push({
1955
- ...change,
1956
- description: `${link}: ${change.description}`
1957
- });
1958
- }
1959
- return changes;
1960
- }
1961
- function promotePackageDraft(content, summary, repository) {
1962
- const changes = [];
1963
- const draft = parseChangelog(content ?? "").sections.find((section) => section.version === "Unreleased");
1964
- if (content === void 0 || draft === void 0) return {
1965
- changes,
1966
- content
1967
- };
1968
- const source = `[${summary.packageName} Unreleased](`;
1969
- const target = `[${summary.packageName} ${summary.version}](${packageLink(repository, summary)})`;
1970
- const remaining = [];
1971
- let category = "Changed";
1972
- for (const line of draft.body.split(LINE_BOUNDARY)) {
1973
- category = categories.find((item) => line.trimEnd() === `### ${item}`) ?? category;
1974
- const breaking = line.startsWith("- **Breaking:** ");
1975
- const prefix = breaking ? "- **Breaking:** " : "- ";
1976
- const end = line.indexOf(")", prefix.length + source.length);
1977
- if (end === -1 || !line.startsWith(prefix + source)) {
1978
- remaining.push(line);
1979
- continue;
1980
- }
1981
- changes.push({
1982
- breaking,
1983
- category,
1984
- description: target + line.slice(end + 1).trimEnd(),
1985
- sources: []
1986
- });
1987
- }
1988
- if (changes.length === 0) return {
1989
- changes,
1990
- content
1991
- };
1992
- const replacement = remaining.some((line) => line.trim() !== "" && categories.every((item) => line.trimEnd() !== `### ${item}`)) ? draft.heading + remaining.join("") : "";
1993
- return {
1994
- changes,
1995
- content: content.slice(0, draft.start) + replacement + content.slice(draft.end)
1996
- };
1997
- }
1998
- async function prepareIndependent(context, summaries) {
2256
+ if (summary.anchor === void 0) return [];
2257
+ const target = `${path.relative(repository.root, summary.changelogPath).split(path.sep).join("/")}#${summary.anchor}`;
2258
+ return [{
2259
+ label: `${summary.packageName} ${summary.version}`,
2260
+ url: repository.github === void 0 ? target : `https://github.com/${repository.github}/blob/HEAD/${target}`
2261
+ }];
2262
+ }
2263
+ /** Name the root's summary of a package release. */
2264
+ function summaryId(summary) {
2265
+ return `.@${summary.packageName}@${summary.version}`;
2266
+ }
2267
+ async function prepareIndependent(context, packages) {
1999
2268
  const { repository } = context;
2000
2269
  const file = await findChangelog(repository.root);
2001
2270
  const content = context.options.rebuild ? void 0 : file.content;
2002
- let remaining = content;
2003
- for (const summary of missingPackageSummaries(summaries, content)) remaining = promotePackageDraft(remaining, summary, repository).content;
2004
- const releaseRefs = new Set(summaries.filter((summary) => summary.version !== "Unreleased").map((summary) => summary.ref));
2271
+ const parsed = parseChangelog(content ?? "");
2272
+ const details = {
2273
+ mode: content === void 0 ? "initialize" : "update",
2274
+ styleReference: styleReference(parsed)
2275
+ };
2276
+ const releases = [];
2277
+ for (const item of packages) for (const summary of missingPackageSummaries(item.summaries, content)) {
2278
+ const release = item.releases.find((candidate) => candidate.version === summary.version);
2279
+ const request = await prepareRelease(context, item.pkg, release, details);
2280
+ releases.push({
2281
+ ...request,
2282
+ evidence: request.evidence.filter((evidence) => evidence.kind === "range"),
2283
+ id: summaryId(summary),
2284
+ scope: "repository"
2285
+ });
2286
+ }
2287
+ const releaseRefs = new Set(packages.flatMap((item) => item.summaries).filter((summary) => summary.version !== "Unreleased").map((summary) => summary.ref));
2005
2288
  const release = {
2006
2289
  previousRef: (await git(repository.root, [
2007
2290
  "rev-list",
@@ -2011,44 +2294,54 @@ async function prepareIndependent(context, summaries) {
2011
2294
  ref: repository.head,
2012
2295
  version: "Unreleased"
2013
2296
  };
2014
- const draft = parseChangelog(remaining ?? "").sections.find((section) => section.version === "Unreleased")?.body;
2015
- const from = await incrementalBase(context, file.path, remaining, release);
2297
+ const draft = parsed.sections.find((section) => section.version === "Unreleased")?.body;
2298
+ const checkpoint = await readCheckpoint(context, file.path, content);
2299
+ const from = await incrementalBase(context, checkpoint, release);
2300
+ const draftGenerated = checkpoint?.generated === true;
2016
2301
  return {
2017
2302
  content,
2303
+ draft,
2304
+ draftGenerated,
2018
2305
  file,
2019
- release,
2020
- request: await prepareRelease(context, repository.rootPackage, release, draft ?? "", content === void 0 ? "initialize" : "update", from, true)
2306
+ releases,
2307
+ unreleased: {
2308
+ release,
2309
+ request: await prepareRelease(context, repository.rootPackage, release, {
2310
+ ...details,
2311
+ existingEntries: draft === void 0 ? void 0 : withoutPackageLinks(draft),
2312
+ from,
2313
+ mode: draft !== void 0 && from !== void 0 && draftGenerated ? "revise" : details.mode,
2314
+ onlyRepositoryFiles: true
2315
+ })
2316
+ }
2021
2317
  };
2022
2318
  }
2023
- function aggregateIndependent(context, prepared, results, summaries, exception) {
2319
+ /**
2320
+ * Write a section for each package release the root does not record yet, and
2321
+ * keep the draft's links to packages with unreleased changes current.
2322
+ */
2323
+ function renderIndependent(context, prepared, results, summaries, exception) {
2024
2324
  const { plan, repository } = context;
2025
- const { file } = prepared;
2026
- let { content } = prepared;
2027
- const entries = [];
2028
- for (const summary of missingPackageSummaries(summaries, content)) {
2029
- const label = `${summary.packageName}@${summary.version}`;
2030
- const promoted = promotePackageDraft(content, summary, repository);
2031
- content = promoted.content;
2032
- entries.push({
2033
- changes: [...promoted.changes, ...aggregateChanges(repository, [summary])],
2034
- release: {
2035
- date: summary.date,
2036
- ref: summary.ref,
2037
- tag: summary.tag,
2038
- version: label
2039
- }
2040
- });
2041
- }
2042
- const unreleased = aggregateChanges(repository, summaries.filter((summary) => summary.version === "Unreleased"));
2043
- const draft = parseChangelog(content ?? "").sections.find((section) => section.version === "Unreleased")?.body;
2044
- unreleased.push(...results.get(prepared.request.id) ?? []);
2045
- if (unreleased.length > 0) entries.push({
2046
- changes: unreleased,
2047
- existingBody: draft,
2325
+ const { content, draft, file, unreleased } = prepared;
2326
+ const entries = Array.from(missingPackageSummaries(summaries, content), (summary) => ({
2327
+ changes: results.get(summaryId(summary)) ?? [],
2328
+ links: packageLink(repository, summary),
2048
2329
  release: {
2049
- ref: repository.head,
2050
- version: "Unreleased"
2330
+ date: summary.date,
2331
+ ref: summary.ref,
2332
+ tag: summary.tag,
2333
+ version: `${summary.packageName}@${summary.version}`
2051
2334
  }
2335
+ }));
2336
+ const changes = results.get(unreleased.request.id) ?? [];
2337
+ const links = summaries.filter((summary) => summary.version === "Unreleased").flatMap((summary) => packageLink(repository, summary));
2338
+ const revise = unreleased.request.mode === "revise" && results.has(unreleased.request.id);
2339
+ if (revise || changes.length > 0 || packageLinksDiffer(draft, links)) entries.push({
2340
+ changes,
2341
+ existingBody: draft,
2342
+ links,
2343
+ release: unreleased.release,
2344
+ revise
2052
2345
  });
2053
2346
  const after = updateMarkdown(content, entries, exception, repository.github);
2054
2347
  if (after !== file.content) plan.files.push({
@@ -2056,7 +2349,7 @@ function aggregateIndependent(context, prepared, results, summaries, exception)
2056
2349
  before: file.content,
2057
2350
  path: file.path
2058
2351
  });
2059
- recordCheckpoint(context, file.path, after, prepared.release);
2352
+ recordCheckpoint(context, file.path, after, unreleased.release, leavesGeneratedDraft(entries, prepared.draftGenerated));
2060
2353
  }
2061
2354
  /**
2062
2355
  * Find package releases whose own section records no notable changes, so a
@@ -2070,18 +2363,6 @@ function quietReleases(plan, packages) {
2070
2363
  }
2071
2364
  return quiet;
2072
2365
  }
2073
- function aggregateShared(context, prepared, results, exception, quiet) {
2074
- const { plan, repository } = context;
2075
- renderPackage(context, prepared, results);
2076
- const rootEdit = plan.files.find((file) => path.dirname(file.path) === repository.root);
2077
- if (rootEdit === void 0) return;
2078
- const additions = prepared.summaries.map((summary) => ({
2079
- changes: aggregateChanges(repository, plan.releases.filter((item) => item.version === summary.version && !quiet.has(`${item.changelogPath}@${item.version}`))),
2080
- existingBody: parseChangelog(rootEdit.after).sections.find((section) => section.version === summary.version)?.body,
2081
- release: prepared.releases.find((item) => item.version === summary.version)
2082
- }));
2083
- rootEdit.after = updateMarkdown(prepared.content, additions, exception || prepared.exception, repository.github);
2084
- }
2085
2366
  /** Generate package changelogs and the root changelog that summarizes them. */
2086
2367
  async function generateWorkspace(context) {
2087
2368
  const { options, plan, repository } = context;
@@ -2089,21 +2370,24 @@ async function generateWorkspace(context) {
2089
2370
  const shared = rootReleases.some((release) => release.version !== "Unreleased");
2090
2371
  const rootFile = await findChangelog(repository.root);
2091
2372
  const rootContent = options.rebuild ? void 0 : rootFile.content;
2092
- const rootMissing = shared ? new Set(missingReleases(rootReleases, rootContent).map((release) => release.version)) : void 0;
2093
- const needsPackageHistory = rootMissing === void 0 || rootMissing.values().some((version) => version !== "Unreleased");
2094
- const recordedAtRoot = (summary) => rootMissing === void 0 ? rootContent !== void 0 && missingPackageSummaries([summary], rootContent).length === 0 : !rootMissing.has(summary.version);
2373
+ const linksHistory = !shared || missingReleases(rootReleases, rootContent).some((release) => release.version !== "Unreleased");
2095
2374
  const packages = [];
2096
- for (const pkg of repository.packages) packages.push(await preparePackage(context, pkg, needsPackageHistory, recordedAtRoot));
2375
+ for (const pkg of describedPackages(repository)) packages.push(await preparePackage(context, pkg, linksHistory));
2097
2376
  const root = shared ? await preparePackage(context, repository.rootPackage) : void 0;
2098
- const independent = shared ? void 0 : await prepareIndependent(context, packages.flatMap((pkg) => pkg.summaries));
2377
+ const independent = shared ? void 0 : await prepareIndependent(context, packages);
2099
2378
  const requests = [...packages, ...root === void 0 ? [] : [root]].flatMap((pkg) => pkg.work.map((item) => item.request));
2100
- if (independent !== void 0) requests.push(independent.request);
2379
+ if (independent !== void 0) requests.push(...independent.releases, independent.unreleased.request);
2101
2380
  const results = await generateReleases(context, requests);
2102
2381
  const summaries = packages.flatMap((pkg) => renderPackage(context, pkg, results));
2103
2382
  plan.releases.push(...summaries);
2104
2383
  const exception = packages.some((pkg) => pkg.exception);
2105
- if (root !== void 0) aggregateShared(context, root, results, exception, quietReleases(plan, packages));
2106
- else if (independent !== void 0) aggregateIndependent(context, independent, results, summaries, exception);
2384
+ if (root !== void 0) {
2385
+ const quiet = quietReleases(plan, packages);
2386
+ renderPackage(context, {
2387
+ ...root,
2388
+ exception: exception || root.exception
2389
+ }, results, (release) => summaries.filter((summary) => summary.version === release.version && summary.ref === release.ref && !quiet.has(`${summary.changelogPath}@${summary.version}`)).flatMap((summary) => packageLink(repository, summary)));
2390
+ } else if (independent !== void 0) renderIndependent(context, independent, results, summaries, exception);
2107
2391
  }
2108
2392
 
2109
2393
  // src/lib/generate.ts
@@ -2127,6 +2411,7 @@ async function generateChangelog(options = {}) {
2127
2411
  log.debug(`Resolved options: ${JSON.stringify(config)}`);
2128
2412
  const repository = await inspectRepository(root, config);
2129
2413
  log.debug(`Repository ${root} at ${repository.head}: ${repository.tags.length} reachable tags, ${repository.packages.length} workspace packages.`);
2414
+ if (config.rootChangelog === "none" && describedPackages(repository).length === 0) throw new Error("The root changelog is set to \"none\", but no workspace packages remain to describe, so there is no changelog to generate. Choose another root changelog mode, or configure packages that are not skipped.");
2130
2415
  const agent = typeof selected === "object" ? selected : createAgent(config.agent);
2131
2416
  const plan = {
2132
2417
  checkpoints: [],
@@ -2154,14 +2439,20 @@ async function generateChangelog(options = {}) {
2154
2439
  plan,
2155
2440
  repository
2156
2441
  };
2157
- if (repository.packages.length === 0) {
2158
- const prepared = await preparePackage(context, repository.rootPackage);
2159
- const results = await generateReleases(context, prepared.work.map((item) => item.request));
2160
- plan.releases.push(...renderPackage(context, prepared, results));
2161
- } else await generateWorkspace(context);
2442
+ if (repository.packages.length > 0 && config.rootChangelog === "summary") await generateWorkspace(context);
2443
+ else await generatePackages(context);
2162
2444
  if (rebuild) keepEmptyRebuilds(plan);
2163
2445
  return plan;
2164
2446
  }
2447
+ /** Generate each changelog on its own, with no root summary. */
2448
+ async function generatePackages(context) {
2449
+ const { options, plan, repository } = context;
2450
+ const packages = options.rootChangelog === "none" ? describedPackages(repository) : [repository.rootPackage, ...describedPackages(repository)];
2451
+ const prepared = [];
2452
+ for (const pkg of packages) prepared.push(await preparePackage(context, pkg));
2453
+ const results = await generateReleases(context, prepared.flatMap((item) => item.work.map((work) => work.request)));
2454
+ for (const item of prepared) plan.releases.push(...renderPackage(context, item, results));
2455
+ }
2165
2456
  /** A rebuild that finds nothing to record must not erase an existing changelog. */
2166
2457
  function keepEmptyRebuilds(plan) {
2167
2458
  plan.files = plan.files.filter((edit) => {
@@ -2322,4 +2613,4 @@ async function reviewChangelog(plan, options = {}) {
2322
2613
  }
2323
2614
  }
2324
2615
 
2325
- export { version as C, maxTimeoutMinutes as S, isAncestor as _, defineConfig as a, defaultModels as b, createAgent as c, LOCKFILE_PATH as d, inspectRepository as f, git as g, manifestAt as h, generateChangelog as i, agentNames as l, localDate as m, previewChangelog as n, loadConfig as o, isVersionOnlyChange as p, reviewChangelog as r, writeChangelog as s, assertReviewTerminal as t, efforts as u, runCommand as v, defaults as x, setLogger as y };
2616
+ export { defaultModels as C, version as E, setLogger as S, maxTimeoutMinutes as T, manifestAt as _, writeChangelog as a, isAncestor as b, resolveConfig as c, efforts as d, rootChangelogs as f, localDate as g, isVersionOnlyChange as h, generateChangelog as i, createAgent as l, inspectRepository as m, previewChangelog as n, defineConfig as o, LOCKFILE_PATH as p, reviewChangelog as r, loadConfig as s, assertReviewTerminal as t, agentNames as u, tagVersion as v, defaults as w, runCommand as x, git as y };