@kitschpatrol/chagent 0.4.0 → 0.6.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.4.0";
18
+ var version = "0.6.0";
19
19
  var engines = { "node": "^24.16.0 || >=26.3.0" };
20
20
 
21
21
  // src/lib/defaults.ts
@@ -237,6 +237,7 @@ async function readBlobs(root, objects) {
237
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;
238
238
  const TAG_DECORATION = "tag: refs/tags/";
239
239
  const GITHUB_REMOTE = /github\.com[:\/]([^\/]+\/[^\/]+?)(?:\.git)?$/v;
240
+ const SHORT_VERSION = /^\d+(?:\.\d+)?$/v;
240
241
  const manifestSchema$1 = z.object({
241
242
  dependencies: z.record(z.string(), z.string()).optional(),
242
243
  engines: z.object({ node: z.string().optional() }).optional(),
@@ -393,6 +394,29 @@ function packageInfo(manifest, directory, fallback) {
393
394
  version: manifest?.version
394
395
  };
395
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. */
396
420
  function tagVersion(tag, pkg, patterns) {
397
421
  const templates = patterns ?? [
398
422
  "{name}@{version}",
@@ -403,12 +427,25 @@ function tagVersion(tag, pkg, patterns) {
403
427
  for (const pattern of templates) {
404
428
  const [prefix, suffix = ""] = pattern.replaceAll("{name}", () => pkg.name).split("{version}", 2);
405
429
  if (prefix === void 0 || !tag.startsWith(prefix) || !tag.endsWith(suffix)) continue;
406
- const value = tag.slice(prefix.length, suffix === "" ? void 0 : -suffix.length);
407
- const version = semver.valid(value);
408
- 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;
409
433
  }
410
434
  }
411
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;
445
+ }
446
+ return true;
447
+ }
448
+ /**
412
449
  * Predict the tag of a release that is not tagged yet, so its heading can link
413
450
  * to it. Follow the previous tag's format, then the first configured pattern,
414
451
  * then `v{version}`.
@@ -416,10 +453,26 @@ function tagVersion(tag, pkg, patterns) {
416
453
  function pendingTag(pkg, version, previous, patterns) {
417
454
  return (previous?.tag?.includes(previous.version) === true ? previous.tag.replace(previous.version, "{version}") : patterns?.[0] ?? "v{version}").replaceAll("{name}", () => pkg.name).replaceAll("{version}", () => version);
418
455
  }
456
+ /**
457
+ * Order releases by Git ancestry, using SemVer only for tags on the same
458
+ * commit.
459
+ */
460
+ async function sortReleases(repository, releases) {
461
+ const history = await git(repository.root, [
462
+ "rev-list",
463
+ "--topo-order",
464
+ "--reverse",
465
+ repository.head
466
+ ]);
467
+ const positions = new Map(history.split("\n").map((ref, index) => [ref, index]));
468
+ return releases.toSorted((a, b) => {
469
+ const difference = (positions.get(a.ref) ?? -1) - (positions.get(b.ref) ?? -1);
470
+ return difference === 0 ? semver.compare(a.version, b.version) : difference;
471
+ });
472
+ }
419
473
  /** Resolve release intervals from reachable tags and locally bumped manifests. */
420
474
  async function resolveReleases(repository, pkg, config, context) {
421
- const releases = await taggedReleases(repository, pkg, config);
422
- releases.sort((a, b) => semver.compare(a.version, b.version));
475
+ const releases = await sortReleases(repository, await taggedReleases(repository, pkg, config));
423
476
  for (let index = 1; index < releases.length; index++) {
424
477
  const current = releases[index];
425
478
  const previous = await precedingRelease(repository.root, releases.slice(0, index), current.ref);
@@ -428,7 +481,7 @@ async function resolveReleases(repository, pkg, config, context) {
428
481
  }
429
482
  const latest = releases.at(-1);
430
483
  const currentVersion = pkg.version ?? (pkg.directory === "." ? context?.version : void 0);
431
- if (await isPendingRelease(repository, pkg, latest, context) && currentVersion !== void 0) releases.push({
484
+ if (await isPendingRelease(repository, pkg, releases, context) && currentVersion !== void 0) releases.push({
432
485
  date: context?.date ?? localDate(),
433
486
  pending: true,
434
487
  previousRef: latest?.ref,
@@ -540,25 +593,31 @@ function describedPackages(repository) {
540
593
  async function taggedReleases(repository, pkg, config) {
541
594
  const candidates = [];
542
595
  for (const tag of repository.tags) {
543
- const version = tagVersion(tag.name, pkg, config.tagPatterns);
544
- if (version !== void 0) candidates.push({
545
- tag,
546
- version
596
+ const match = tagVersion(tag.name, pkg, config.tagPatterns);
597
+ if (match !== void 0) candidates.push({
598
+ ...match,
599
+ tag
547
600
  });
548
601
  }
549
602
  const isRoot = pkg.directory === ".";
550
603
  const manifests = isRoot ? /* @__PURE__ */ new Map() : await manifestsAt(repository.root, pkg.directory, candidates.map(({ tag }) => tag.commit));
551
- const releases = [];
552
- for (const { tag, version } of candidates) {
604
+ const owned = [];
605
+ for (const candidate of candidates) {
553
606
  if (!isRoot) {
554
- const manifest = manifests.get(tag.commit);
555
- if (manifest?.version !== void 0 && semver.valid(manifest.version) !== version) continue;
607
+ const manifest = manifests.get(candidate.tag.commit);
608
+ if (manifest?.version !== void 0 && semver.valid(manifest.version) !== candidate.version) continue;
556
609
  if (manifest === void 0 && await git(repository.root, [
557
610
  "cat-file",
558
611
  "-t",
559
- `${tag.commit}:${pkg.directory}`
612
+ `${candidate.tag.commit}:${pkg.directory}`
560
613
  ], true) !== "tree") continue;
561
614
  }
615
+ owned.push(candidate);
616
+ }
617
+ const releases = [];
618
+ for (const candidate of owned) {
619
+ if (candidate.precision < 3 && !await leadsSeries(repository.root, candidate, owned)) continue;
620
+ const { tag, version } = candidate;
562
621
  const existing = releases.find((release) => release.version === version);
563
622
  if (existing && existing.ref !== tag.commit) throw new Error(`Multiple reachable tags disagree about ${pkg.name} ${version}. Configure tagPatterns to select a release series.`);
564
623
  if (existing) continue;
@@ -574,13 +633,13 @@ async function taggedReleases(repository, pkg, config) {
574
633
  async function precedingRelease(root, candidates, ref) {
575
634
  for (const candidate of candidates.toReversed()) if (await isAncestor(root, candidate.ref, ref)) return candidate;
576
635
  }
577
- async function isPendingRelease(repository, pkg, latest, context) {
636
+ async function isPendingRelease(repository, pkg, releases, context) {
578
637
  const currentVersion = pkg.version ?? (pkg.directory === "." ? context?.version : void 0);
579
638
  const committed = await manifestAt(repository.root, pkg.directory, repository.head);
580
639
  const versionChanged = currentVersion !== void 0 && currentVersion !== committed?.version;
581
640
  const parent = await manifestAt(repository.root, pkg.directory, repository.head + "^");
582
641
  const committedBump = parent?.version !== void 0 && parent.version !== committed?.version;
583
- return currentVersion !== void 0 && semver.valid(currentVersion) !== null && (latest === void 0 || semver.gt(currentVersion, latest.version)) && (versionChanged || context !== void 0 || committedBump);
642
+ return currentVersion !== void 0 && semver.valid(currentVersion) !== null && releases.every((release) => release.version !== semver.valid(currentVersion)) && (versionChanged || context !== void 0 || committedBump && releases.every((release) => release.ref !== repository.head));
584
643
  }
585
644
 
586
645
  // src/lib/cache.ts
@@ -638,13 +697,25 @@ manager, manifest format, or directory layout. Use snapshots at those refs, not
638
697
  uncommitted files or newer code at HEAD. Respect repository.directory and
639
698
  repository.excludeDirectories when deciding which changes belong in this entry;
640
699
  you may inspect related files for context. The supplied evidence is a starting
641
- point, not a limit on your research.
700
+ point, not a limit on your research. Supplied commits leave out lockfiles and
701
+ directories named build, coverage, dist, or changelog at the top of a package,
702
+ which usually hold generated output. Judge those directories yourself: when one
703
+ holds a project's source rather than generated output, its changes belong in the
704
+ changelog like any others.
642
705
 
643
706
  When mode is initialize, reconstruct this release as part of the project's full
644
- historical changelog. When mode is update, cover all missing changes in the supplied range. In both modes, review
645
- every commit, diff, and associated PR before deciding what to include. A changelog
646
- records the evolution of the project, including maintenance releases; it is not
647
- just a list of new application features.
707
+ historical changelog. When mode is update, cover all missing changes in the
708
+ supplied range. When mode is revise, the existing entries are your own earlier
709
+ draft of this release, describing it up to repository.from, and the supplied
710
+ range is what has happened since. Return the complete entries for the release as
711
+ it now stands; they replace the draft. Carry over what still holds, fold later
712
+ fixes and refinements into the entries they affect, combine entries that turn
713
+ out to describe one change, and drop an entry only when the range reverts or
714
+ supersedes it. Keep the wording of entries that need no change. In every mode,
715
+ review every commit, diff, and associated PR in the supplied range before
716
+ deciding what to include. A changelog records the evolution of the project,
717
+ including maintenance releases; it is not just a list of new application
718
+ features.
648
719
 
649
720
  Cover distinct changes to behavior, public APIs and types, CLI flags and output,
650
721
  runtime compatibility, installation and distribution, documentation and examples,
@@ -673,9 +744,16 @@ Give distinct changes separate readable bullets;
673
744
  do not compress unrelated features or breaking changes into one long entry. Keep
674
745
  each entry concise without sacrificing coverage. Consolidate related commits,
675
746
  not unrelated changes. There is no three-entry limit for package scope, including
676
- a single-package repository's root changelog. Only scope=repository denotes a
677
- short monorepo root summary; prefer three highlights there because package
678
- changelogs contain the details.
747
+ a single-package repository's root changelog.
748
+
749
+ Only scope=repository asks for a summary: an entry in a monorepo's root
750
+ changelog, which the caller ends with links to the package changelogs that hold
751
+ the details. Write what a reader of the whole repository most needs to know, in
752
+ at most three entries, rather than repeating package entries one by one. Combine
753
+ related changes across packages and name the packages they affect. When
754
+ repository.directory is a package, summarize that package's release; otherwise
755
+ summarize the repository, including changes outside any package. Do not write
756
+ the links yourself.
679
757
 
680
758
  Use commit messages and PRs to understand intent, then check the changed files
681
759
  and diffs. Inspect public interfaces at both ends of the range before claiming
@@ -714,25 +792,37 @@ Put breaking public changes in the appropriate standard category and set breakin
714
792
  to true; do not create a Breaking Changes category.
715
793
  Descriptions are single-line Markdown suitable for a list item,
716
794
  without a bullet prefix, heading, process explanation, or introductory paragraph.
717
- Every change must cite at least one evidence ID supplied for its own release in
718
- sources. Prefer the associated commit ID; use that release's range ID for changes
719
- established through your own Git research. Include a PR
795
+ Every change must cite, in sources, at least one evidence ID supplied for its own
796
+ release or the full ID of a commit within that release's range. Prefer the commit
797
+ that made the change, including one found through your own Git research; use the
798
+ release's range ID when no single commit establishes it. When mode is revise,
799
+ cite the draft evidence ID for an entry carried over or rewritten from the
800
+ existing entries, along with any commit that changed it. Include a PR
720
801
  link only when its URL and association are supplied. Do not fabricate links.
721
802
 
722
- Existing entries are provided only for deduplication and preservation. They are
723
- not independent evidence. Omit changes already expressed by those entries, even
724
- if you would phrase them differently. GitHub release-note bodies are deliberately
803
+ Existing entries are provided for deduplication and preservation, and when mode
804
+ is revise, as the draft to rewrite. They are not independent evidence. Unless
805
+ mode is revise, omit changes already expressed by those entries, even if you
806
+ would phrase them differently. GitHub release-note bodies are deliberately
725
807
  absent because this output will itself become GitHub release notes.
726
808
  Do not read existing changelog files, generated release-note files, or GitHub
727
809
  release bodies as source material, even when researching the repository directly.
728
810
 
811
+ When styleReference is present, it is a recent release section of the changelog
812
+ these entries will join. Match its writing conventions: voice and tense,
813
+ capitalization, terminal punctuation, and how code, issues, and PRs are
814
+ referenced. It is a style sample and nothing more. It describes another release,
815
+ so it is not evidence, and its length and choice of categories say nothing about
816
+ this one.
817
+
729
818
  Before returning, check coverage across behavior/API, compatibility, dependencies,
730
819
  documentation, build/test/CI, and distribution. Do not drop a change just because
731
820
  its commit is called chore, docs, build, or dependency updates. Return an empty
732
821
  changes array only when the evidence is entirely release-version bookkeeping,
733
822
  lockfile-only resolution changes, generated artifacts, formatting noise, or
734
- changes already covered by existing entries. Do not pad the result with
735
- unsupported changes.`;
823
+ changes already covered by existing entries. When mode is revise, an empty array
824
+ removes the draft, so return one only when nothing in the draft still holds. Do
825
+ not pad the result with unsupported changes.`;
736
826
  /** Invalidates cached drafts whenever the instructions change. */
737
827
  const instructionVersion = hash(instructions);
738
828
 
@@ -762,6 +852,7 @@ const rootChangelogs = [
762
852
  ];
763
853
 
764
854
  // src/lib/agents.ts
855
+ const COMMIT_ID = /^[\da-f]{7,64}$/iv;
765
856
  const CONTROL_CHARACTERS = /\p{Cc}/v;
766
857
  const HEADING = /^#{1,6}\s/v;
767
858
  const LIST_MARKER = /^(?:[\-*]|\d+\.)(?:\s+|$)/v;
@@ -780,13 +871,10 @@ const responseJsonSchema = JSON.stringify(z.toJSONSchema(responseSchema, { targe
780
871
  function normalizeDescription(description) {
781
872
  return description.replaceAll(WHITESPACE_RUN, " ").trim().replace(LIST_MARKER, "");
782
873
  }
783
- function validateChanges(changes, release) {
784
- const ids = new Set(release.evidence.map((item) => item.id));
874
+ function validateDescriptions(changes, id) {
785
875
  return changes.map((change) => {
786
- const unknown = change.sources.filter((source) => !ids.has(source));
787
- if (unknown.length > 0) throw new Error(`Agent output for ${release.id} cites evidence that was not supplied: ${unknown.join(", ")}.`);
788
876
  const description = normalizeDescription(change.description);
789
- 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)}.`);
877
+ 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)}.`);
790
878
  return {
791
879
  ...change,
792
880
  description
@@ -794,8 +882,8 @@ function validateChanges(changes, release) {
794
882
  });
795
883
  }
796
884
  /**
797
- * Validate structure, completeness, release identity, and citations before
798
- * accepting any adapter's output.
885
+ * Validate structure, completeness, and release identity before accepting any
886
+ * adapter's output. Citations are checked separately, by `verifyCitations`.
799
887
  *
800
888
  * @returns The change sets, with descriptions normalized.
801
889
  */
@@ -809,10 +897,53 @@ function validateResponse(value, releases) {
809
897
  const omitted = expected.keys().filter((id) => !ids.includes(id)).toArray();
810
898
  if (omitted.length > 0) throw new Error(`Agent output omitted requested releases: ${omitted.join(", ")}.`);
811
899
  return response.data.releases.map(({ changes, id }) => ({
812
- changes: validateChanges(changes, expected.get(id)),
900
+ changes: validateDescriptions(changes, id),
813
901
  id
814
902
  }));
815
903
  }
904
+ /** List every commit in a release's range. */
905
+ async function rangeCommits(root, { repository }) {
906
+ const { from, to } = repository;
907
+ return (await git(root, ["rev-list", from === void 0 ? to : `${from}..${to}`])).split("\n");
908
+ }
909
+ async function verifyChanges(root, release, changes) {
910
+ const supplied = new Set(release.evidence.map((item) => item.id));
911
+ let commits;
912
+ const verified = [];
913
+ for (const change of changes) {
914
+ const sources = /* @__PURE__ */ new Set();
915
+ for (const source of change.sources) if (supplied.has(source)) sources.add(source);
916
+ else if (COMMIT_ID.test(source)) {
917
+ commits ??= await rangeCommits(root, release);
918
+ const matches = commits.filter((commit) => commit.startsWith(source.toLowerCase()));
919
+ if (matches.length === 1) sources.add(matches[0]);
920
+ }
921
+ 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.`);
922
+ verified.push({
923
+ ...change,
924
+ sources: [...sources]
925
+ });
926
+ }
927
+ return verified;
928
+ }
929
+ /**
930
+ * Check citations against Git instead of trusting them. A change may cite
931
+ * evidence supplied for its release or any commit within that release's range,
932
+ * which lets an agent cite what its own research found. Abbreviated commit IDs
933
+ * are completed, and sources that hold up are kept when others do not. Pass the
934
+ * requested releases with their complete evidence.
935
+ *
936
+ * @throws {Error} When a change is left without a source.
937
+ */
938
+ async function verifyCitations(root, changeSets, releases) {
939
+ const requested = new Map(releases.map((release) => [release.id, release]));
940
+ const verified = [];
941
+ for (const { changes, id } of changeSets) verified.push({
942
+ changes: await verifyChanges(root, requested.get(id), changes),
943
+ id
944
+ });
945
+ return verified;
946
+ }
816
947
  function prompt({ releases, repair, repositoryRoot }) {
817
948
  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.`;
818
949
  return `${instructions}${correction}\n\nInput data (JSON):\n${JSON.stringify({
@@ -936,6 +1067,83 @@ function createAgent(name) {
936
1067
  };
937
1068
  }
938
1069
 
1070
+ // src/lib/config.ts
1071
+ const configSchema = z.strictObject({
1072
+ agent: z.enum(agentNames).optional(),
1073
+ cache: z.boolean().optional(),
1074
+ effort: z.enum(efforts).optional(),
1075
+ exclude: z.array(z.string()).optional(),
1076
+ github: z.boolean().optional(),
1077
+ /** Maximum evidence characters per agent request. */
1078
+ maxInputChars: z.number().int().min(4e3).optional(),
1079
+ model: z.string().min(1).optional(),
1080
+ /** Workspace directory globs, overriding manifest workspace discovery. */
1081
+ packages: z.array(z.string()).optional(),
1082
+ /**
1083
+ * What a monorepo's root changelog records: a summary of package releases,
1084
+ * the root package's own changes, or nothing.
1085
+ */
1086
+ rootChangelog: z.enum(rootChangelogs).optional(),
1087
+ /** Workspace package directory globs that get no changelog of their own. */
1088
+ skipPackage: z.array(z.string()).optional(),
1089
+ /** Templates containing {version} and optionally {name}. */
1090
+ tagPatterns: z.array(z.string().includes("{version}")).optional(),
1091
+ /** Minutes each agent session may run. */
1092
+ timeout: z.number().positive().max(maxTimeoutMinutes).optional()
1093
+ });
1094
+ const resolvedConfigSchema = configSchema.required({
1095
+ agent: true,
1096
+ cache: true,
1097
+ effort: true,
1098
+ github: true,
1099
+ maxInputChars: true,
1100
+ rootChangelog: true,
1101
+ timeout: true
1102
+ }).transform(({ timeout, ...config }) => ({
1103
+ ...config,
1104
+ timeoutMs: timeout * 6e4
1105
+ }));
1106
+ function invalidConfig(source, error) {
1107
+ return new Error(`Invalid options in ${source}:\n${z.prettifyError(error)}`, { cause: error });
1108
+ }
1109
+ /** Define optional repository configuration with TypeScript completion. */
1110
+ function defineConfig(config) {
1111
+ return config;
1112
+ }
1113
+ /** Load configuration from the repository root or an explicitly selected file. */
1114
+ async function loadConfig(root, file) {
1115
+ const explorer = cosmiconfig("chagent", {
1116
+ searchPlaces: [
1117
+ "chagent.config.ts",
1118
+ "chagent.config.mjs",
1119
+ "chagent.config.js",
1120
+ "chagent.config.json",
1121
+ "package.json"
1122
+ ],
1123
+ searchStrategy: "none"
1124
+ });
1125
+ const result = file === void 0 ? await explorer.search(root) : await explorer.load(file);
1126
+ if (!result) {
1127
+ log.debug("No configuration file found.");
1128
+ return {};
1129
+ }
1130
+ log.debug(`Loaded configuration from ${result.filepath}.`);
1131
+ const parsed = configSchema.safeParse(result.config);
1132
+ if (!parsed.success) throw invalidConfig(result.filepath, parsed.error);
1133
+ return parsed.data;
1134
+ }
1135
+ /**
1136
+ * Apply configuration layers over the defaults; later layers win and undefined
1137
+ * values do not.
1138
+ */
1139
+ function resolveConfig(...layers) {
1140
+ const merged = { ...defaults };
1141
+ for (const layer of layers) for (const [key, value] of Object.entries(layer)) if (value !== void 0) merged[key] = value;
1142
+ const parsed = resolvedConfigSchema.safeParse(merged);
1143
+ if (!parsed.success) throw invalidConfig("generation settings", parsed.error);
1144
+ return parsed.data;
1145
+ }
1146
+
939
1147
  // src/lib/format.ts
940
1148
  const FORMATTER_PACKAGES = ["@kitschpatrol/shared-config", "@kitschpatrol/prettier-config"];
941
1149
  const manifestSchema = z.object({ exports: z.object({ ".": z.object({ import: z.string() }) }) });
@@ -1038,9 +1246,11 @@ async function writeChangelog(plan) {
1038
1246
  const edit = plan.files.find((file) => file.path === checkpoint.path);
1039
1247
  try {
1040
1248
  const written = edit === void 0 ? void 0 : await readOptional(edit.path);
1249
+ const edited = edit !== void 0 && changelogHash(edit.after) !== checkpoint.contentHash;
1041
1250
  await writeCache(path.join(stateDirectory, "checkpoints"), checkpoint.path, {
1042
1251
  ...checkpoint,
1043
- contentHash: written === void 0 ? checkpoint.contentHash : changelogHash(written)
1252
+ contentHash: written === void 0 ? checkpoint.contentHash : changelogHash(written),
1253
+ generated: checkpoint.generated && !edited
1044
1254
  });
1045
1255
  } catch {
1046
1256
  plan.diagnostics.push("Could not save incremental metadata; the next run will recheck the full release range.");
@@ -1075,83 +1285,6 @@ async function acquireLock(file) {
1075
1285
  }
1076
1286
  }
1077
1287
 
1078
- // src/lib/config.ts
1079
- const configSchema = z.strictObject({
1080
- agent: z.enum(agentNames).optional(),
1081
- cache: z.boolean().optional(),
1082
- effort: z.enum(efforts).optional(),
1083
- exclude: z.array(z.string()).optional(),
1084
- github: z.boolean().optional(),
1085
- /** Maximum evidence characters per agent request. */
1086
- maxInputChars: z.number().int().min(4e3).optional(),
1087
- model: z.string().min(1).optional(),
1088
- /** Workspace directory globs, overriding manifest workspace discovery. */
1089
- packages: z.array(z.string()).optional(),
1090
- /**
1091
- * What a monorepo's root changelog records: a summary of package releases,
1092
- * the root package's own changes, or nothing.
1093
- */
1094
- rootChangelog: z.enum(rootChangelogs).optional(),
1095
- /** Workspace package directory globs that get no changelog of their own. */
1096
- skipPackage: z.array(z.string()).optional(),
1097
- /** Templates containing {version} and optionally {name}. */
1098
- tagPatterns: z.array(z.string().includes("{version}")).optional(),
1099
- /** Minutes each agent session may run. */
1100
- timeout: z.number().positive().max(maxTimeoutMinutes).optional()
1101
- });
1102
- const resolvedConfigSchema = configSchema.required({
1103
- agent: true,
1104
- cache: true,
1105
- effort: true,
1106
- github: true,
1107
- maxInputChars: true,
1108
- rootChangelog: true,
1109
- timeout: true
1110
- }).transform(({ timeout, ...config }) => ({
1111
- ...config,
1112
- timeoutMs: timeout * 6e4
1113
- }));
1114
- function invalidConfig(source, error) {
1115
- return new Error(`Invalid options in ${source}:\n${z.prettifyError(error)}`, { cause: error });
1116
- }
1117
- /** Define optional repository configuration with TypeScript completion. */
1118
- function defineConfig(config) {
1119
- return config;
1120
- }
1121
- /** Load configuration from the repository root or an explicitly selected file. */
1122
- async function loadConfig(root, file) {
1123
- const explorer = cosmiconfig("chagent", {
1124
- searchPlaces: [
1125
- "chagent.config.ts",
1126
- "chagent.config.mjs",
1127
- "chagent.config.js",
1128
- "chagent.config.json",
1129
- "package.json"
1130
- ],
1131
- searchStrategy: "none"
1132
- });
1133
- const result = file === void 0 ? await explorer.search(root) : await explorer.load(file);
1134
- if (!result) {
1135
- log.debug("No configuration file found.");
1136
- return {};
1137
- }
1138
- log.debug(`Loaded configuration from ${result.filepath}.`);
1139
- const parsed = configSchema.safeParse(result.config);
1140
- if (!parsed.success) throw invalidConfig(result.filepath, parsed.error);
1141
- return parsed.data;
1142
- }
1143
- /**
1144
- * Apply configuration layers over the defaults; later layers win and undefined
1145
- * values do not.
1146
- */
1147
- function resolveConfig(...layers) {
1148
- const merged = { ...defaults };
1149
- for (const layer of layers) for (const [key, value] of Object.entries(layer)) if (value !== void 0) merged[key] = value;
1150
- const parsed = resolvedConfigSchema.safeParse(merged);
1151
- if (!parsed.success) throw invalidConfig("generation settings", parsed.error);
1152
- return parsed.data;
1153
- }
1154
-
1155
1288
  // src/lib/changelog.ts
1156
1289
  const LINE_BOUNDARY = /(?<=\n)/v;
1157
1290
  const FENCE = /^\s{0,3}(`{3,}|~{3,})/v;
@@ -1163,9 +1296,14 @@ const WHITESPACE = /\s+/gv;
1163
1296
  const TERMINAL_PUNCTUATION = /[.!]$/v;
1164
1297
  const NEWLINE = /\r?\n/v;
1165
1298
  const BULLET = /^\s*[\-*] /v;
1299
+ const ENTRY = /^\s*[\-*] /mv;
1300
+ const STYLE_REFERENCE_LENGTH = 2e3;
1166
1301
  const CATEGORY_HEADING = /^### /mv;
1302
+ const PACKAGES_HEADING = /^### Packages\s*$/mv;
1303
+ const LINK_TARGET = /\]\(([^\)]*)\)/gv;
1167
1304
  const SEMVER_LINE = /^.*\[Semantic Versioning\].*(?:\r?\n|$)/mv;
1168
1305
  const UNRELEASED_REFERENCE = /^\[unreleased\]:[^\r\n]*(?:\r?\n)?/imv;
1306
+ const V_PREFIX = /^v\d/v;
1169
1307
  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";
1170
1308
  /** The body of a release section without any changes to record. */
1171
1309
  const noNotableChanges = "No notable changes.";
@@ -1234,6 +1372,21 @@ function parseChangelog(content) {
1234
1372
  sections
1235
1373
  };
1236
1374
  }
1375
+ /**
1376
+ * Pick the newest release section with entries, to show an agent how this
1377
+ * changelog is written. Long sections are cut at a line boundary.
1378
+ */
1379
+ function styleReference({ sections }) {
1380
+ const body = sections.filter(({ version }) => version !== "Unreleased").map((section) => withoutPackageLinks(section.body)).find((entries) => ENTRY.test(entries));
1381
+ if (body === void 0) return;
1382
+ const lines = body.trim().split(LINE_BOUNDARY);
1383
+ let sample = "";
1384
+ for (const line of lines) {
1385
+ if (sample.length + line.length > STYLE_REFERENCE_LENGTH) break;
1386
+ sample += line;
1387
+ }
1388
+ return sample.trim() === "" ? void 0 : sample.trimEnd();
1389
+ }
1237
1390
  function normalized(description) {
1238
1391
  return description.trim().toLowerCase().replaceAll(WHITESPACE, " ").replace(TERMINAL_PUNCTUATION, "");
1239
1392
  }
@@ -1273,6 +1426,46 @@ function laterCategoryIndex(body, category) {
1273
1426
  const indexes = categories.slice(categories.indexOf(category) + 1).map((item) => categoryHeading(body, item)?.index).filter((index) => index !== void 0);
1274
1427
  return indexes.length === 0 ? void 0 : Math.min(...indexes);
1275
1428
  }
1429
+ /** Locate the package links that end a root summary's section. */
1430
+ function packagesBlock(body) {
1431
+ const heading = PACKAGES_HEADING.exec(body);
1432
+ if (heading === null) return;
1433
+ const after = heading.index + heading[0].length;
1434
+ const next = CATEGORY_HEADING.exec(body.slice(after));
1435
+ return {
1436
+ start: heading.index,
1437
+ end: next === null ? body.length : after + next.index
1438
+ };
1439
+ }
1440
+ /** Remove the package links from a root summary's section, leaving its entries. */
1441
+ function withoutPackageLinks(body) {
1442
+ const block = packagesBlock(body);
1443
+ return block === void 0 ? body : body.slice(0, block.start) + body.slice(block.end);
1444
+ }
1445
+ /**
1446
+ * Check whether a section links to other package releases than it should.
1447
+ * Targets are compared, since a formatter may restyle the list around them.
1448
+ */
1449
+ function packageLinksDiffer(body, links) {
1450
+ const block = body === void 0 ? void 0 : packagesBlock(body);
1451
+ const targets = body === void 0 || block === void 0 ? [] : body.slice(block.start, block.end).matchAll(LINK_TARGET).map((match) => match[1]).toArray();
1452
+ return !isDeepStrictEqual(targets, links.map(({ url }) => url));
1453
+ }
1454
+ /**
1455
+ * Merge new changes into a section's draft, or replace a revised draft with
1456
+ * them, and end the section with its package links.
1457
+ */
1458
+ function sectionBody({ changes, existingBody = "", links, revise }, newline) {
1459
+ const draft = links === void 0 ? existingBody : withoutPackageLinks(existingBody);
1460
+ const body = mergeChanges(revise === true ? "" : draft, changes, newline);
1461
+ if (links === void 0 || links.length === 0) return body;
1462
+ const list = links.map(({ label, url }) => `- [${label}](${url})`);
1463
+ return body.trimEnd() + newline + newline + [
1464
+ "### Packages",
1465
+ "",
1466
+ ...list
1467
+ ].join(newline);
1468
+ }
1276
1469
  /** Render a release heading with an optional comparison link. */
1277
1470
  function releaseLink(github, release) {
1278
1471
  if (github === void 0) return;
@@ -1282,7 +1475,7 @@ function releaseLink(github, release) {
1282
1475
  return release.previousTag === void 0 ? `${base}/releases/tag/${encodeURIComponent(release.tag)}` : `${base}/compare/${encodeURIComponent(release.previousTag)}...${encodeURIComponent(release.tag)}`;
1283
1476
  }
1284
1477
  /** Insert new sections and update only the draft and its comparison link. */
1285
- function updateMarkdown(original, entries, nodeException, github) {
1478
+ function updateMarkdown(original, entries, nodeException, github, releaseOrder) {
1286
1479
  const content = original ?? "# 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";
1287
1480
  const parsed = parseChangelog(content);
1288
1481
  const { newline } = parsed;
@@ -1291,20 +1484,30 @@ function updateMarkdown(original, entries, nodeException, github) {
1291
1484
  const definitions = [];
1292
1485
  let { footer } = parsed;
1293
1486
  const draft = parsed.sections.find((section) => section.version === "Unreleased");
1294
- const released = parsed.sections.filter((section) => section.version !== "Unreleased").map((section) => content.slice(section.start, section.end));
1487
+ const released = parsed.sections.filter((section) => section.version !== "Unreleased").map((section) => ({
1488
+ text: content.slice(section.start, section.end),
1489
+ version: section.version
1490
+ }));
1295
1491
  let draftText = draft === void 0 ? "" : content.slice(draft.start, draft.end);
1296
- for (const { changes, existingBody, release } of entries) {
1492
+ for (const entry of entries) {
1493
+ const { existingBody, release } = entry;
1297
1494
  const link = releaseLink(github, release);
1298
1495
  const heading = `## ${link === void 0 ? release.version : `[${release.version}]`}${release.date === void 0 ? "" : ` - ${release.date}`}`;
1299
- let body = mergeChanges(existingBody ?? "", changes, newline);
1496
+ let body = sectionBody(entry, newline);
1300
1497
  if (body.trim() === "") {
1301
- if (release.version === "Unreleased") continue;
1498
+ if (release.version === "Unreleased") {
1499
+ if (existingBody !== void 0) draftText = "";
1500
+ continue;
1501
+ }
1302
1502
  body = newline + noNotableChanges + newline;
1303
1503
  }
1304
1504
  const section = heading + newline + newline + body.trim() + newline + newline;
1305
1505
  if (release.version === "Unreleased") draftText = section;
1306
1506
  else {
1307
- additions.push(section);
1507
+ additions.push({
1508
+ text: section,
1509
+ version: release.version
1510
+ });
1308
1511
  if (existingBody !== void 0) draftText = "";
1309
1512
  }
1310
1513
  if (link === void 0) continue;
@@ -1316,9 +1519,13 @@ function updateMarkdown(original, entries, nodeException, github) {
1316
1519
  }
1317
1520
  if (entries.length === 0 && header === parsed.header) return content;
1318
1521
  if (draftText === "") footer = footer.replace(UNRELEASED_REFERENCE, "");
1319
- const generated = sectionSpacing(sectionSpacing(header, newline) + draftText, newline) + additions.toReversed().join("");
1320
- const preserved = released.join("") + footer;
1321
- return appendDefinitions(preserved === "" ? generated.trimEnd() + newline : generated + preserved, footer, definitions, newline);
1522
+ const sections = [...additions.toReversed(), ...released];
1523
+ if (releaseOrder !== void 0) {
1524
+ const positions = new Map(releaseOrder.map((version, index) => [version, index]));
1525
+ sections.sort((a, b) => (positions.get(b.version) ?? -1) - (positions.get(a.version) ?? -1));
1526
+ }
1527
+ const result = sectionSpacing(sectionSpacing(header, newline) + draftText, newline) + sections.map((section) => section.text).join("") + footer;
1528
+ return appendDefinitions(footer === "" && released.length === 0 ? result.trimEnd() + newline : result, footer, definitions, newline);
1322
1529
  }
1323
1530
  /** Add link reference definitions after any existing footer. */
1324
1531
  function appendDefinitions(content, footer, definitions, newline) {
@@ -1335,8 +1542,15 @@ function addNodeNote(header, newline, nodeException) {
1335
1542
  }
1336
1543
  return header;
1337
1544
  }
1545
+ /**
1546
+ * Read a heading's version the way default tag patterns read a tag, so `1.2`
1547
+ * and `v1` name the first release of their series and a bare `1` names none.
1548
+ */
1338
1549
  function normalizeVersion(raw) {
1339
- return raw.toLowerCase() === "unreleased" ? "Unreleased" : semver.valid(raw) ?? raw;
1550
+ if (raw.toLowerCase() === "unreleased") return "Unreleased";
1551
+ const prefixed = V_PREFIX.test(raw);
1552
+ const parsed = parseVersion(prefixed ? raw.slice(1) : raw);
1553
+ return parsed === void 0 || !prefixed && parsed.precision === 1 ? raw : parsed.version;
1340
1554
  }
1341
1555
  function releaseHeadingVersion(line) {
1342
1556
  const match = RELEASE_HEADING.exec(line.trimEnd());
@@ -1352,17 +1566,26 @@ function sectionSpacing(content, newline) {
1352
1566
 
1353
1567
  // src/lib/evidence.ts
1354
1568
  const COMMIT_MARKER = "";
1355
- const GENERATED_DIRECTORY = /^(?:build|changelog|coverage|dist)(?:\/|$)/iv;
1356
- const GENERATED_ANYWHERE = /(?:^|\/)(?:changelog(?:\.md)?$|node_modules(?:\/|$))/iv;
1569
+ const OUTPUT_DIRECTORY = /^(?:build|changelog|coverage|dist)(?:\/|$)/iv;
1570
+ const NEVER_SOURCE = /(?:^|\/)(?:changelog(?:\.md)?$|node_modules(?:\/|$))/iv;
1571
+ /**
1572
+ * Changelogs are this tool's own output, installed dependencies are another
1573
+ * project's source, and lockfiles record resolved versions, not declared
1574
+ * dependency ranges. None of them is evidence of a change.
1575
+ */
1576
+ function excluded(file) {
1577
+ return LOCKFILE_PATH.test(file) || NEVER_SOURCE.test(file);
1578
+ }
1357
1579
  /**
1358
- * Generated output lives at the top of a package, so `packages/build` and
1359
- * `src/dist` remain source. Lockfiles record resolved versions, not declared
1360
- * dependency ranges.
1580
+ * Recognize directories at the top of a package that usually hold generated
1581
+ * output, so `packages/build` and `src/dist` remain source. Supplied commits
1582
+ * leave them out, but they still count as changes to research: only the agent
1583
+ * can tell a build artifact from a project whose source lives in `coverage`.
1361
1584
  */
1362
- function ignored(file, packages) {
1585
+ function likelyGenerated(file, packages) {
1363
1586
  const owner = packages.findLast(({ directory }) => file.startsWith(`${directory}/`));
1364
1587
  const relative = owner === void 0 ? file : file.slice(owner.directory.length + 1);
1365
- return LOCKFILE_PATH.test(file) || GENERATED_ANYWHERE.test(file) || GENERATED_DIRECTORY.test(relative);
1588
+ return OUTPUT_DIRECTORY.test(relative);
1366
1589
  }
1367
1590
  /**
1368
1591
  * A package's directory and, transitively, those of the workspace packages it
@@ -1435,7 +1658,10 @@ async function commitsInRange(root, range, paths) {
1435
1658
  }
1436
1659
  return commits.values().toArray();
1437
1660
  }
1438
- /** Collect source changes, excluding generated notes and release-only commits. */
1661
+ /**
1662
+ * Collect a release's starting evidence, or nothing when its range holds only
1663
+ * changelogs, lockfiles, and version bookkeeping.
1664
+ */
1439
1665
  async function collectEvidence(repository, pkg, release, excludeDirectories = []) {
1440
1666
  const { packages, root } = repository;
1441
1667
  const paths = [...(pkg.directory === "." ? ["."] : await packageScope(repository, pkg, release.ref)).map((directory) => `:(literal)${directory}`), ...excludeDirectories.map((directory) => `:(exclude,literal)${directory}`)];
@@ -1457,20 +1683,25 @@ async function collectEvidence(repository, pkg, release, excludeDirectories = []
1457
1683
  release.ref,
1458
1684
  "--",
1459
1685
  ...paths
1460
- ])).split("\0").filter((file) => file !== "" && !ignored(file, packages));
1686
+ ])).split("\0").filter((file) => file !== "" && !excluded(file));
1461
1687
  const rangeBlobs = await readBlobs(root, manifestObjects(candidates, from, release.ref));
1462
1688
  const changed = new Set(candidates.filter((file) => !isManifest(file) || !isVersionOnlyEdit(rangeBlobs.get(`${from}:${file}`), rangeBlobs.get(`${release.ref}:${file}`))));
1463
1689
  if (changed.size === 0) return [];
1464
1690
  const sourceCommits = (await commitsInRange(root, range, paths)).map((item) => ({
1465
1691
  ...item,
1466
- files: item.files.filter((file) => !ignored(file, packages))
1692
+ files: item.files.filter((file) => !excluded(file) && !likelyGenerated(file, packages))
1467
1693
  }));
1468
1694
  const commitBlobs = await readBlobs(root, sourceCommits.flatMap(({ files, id }) => manifestObjects(files, `${id}^`, id)));
1469
- const evidence = sourceCommits.filter(({ files, id }) => files.some((file) => changed.has(file)) && hasSourceChanges(files, id, commitBlobs)).map(({ files, id, message }) => ({
1695
+ const evidence = [{
1696
+ id: `range:${from}..${release.ref}`,
1697
+ kind: "range",
1698
+ 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.`
1699
+ }];
1700
+ for (const { files, id, message } of sourceCommits) if (files.some((file) => changed.has(file)) && hasSourceChanges(files, id, commitBlobs)) evidence.push({
1470
1701
  id,
1471
1702
  kind: "commit",
1472
1703
  text: `${message}\nFiles: ${files.join(", ")}`
1473
- }));
1704
+ });
1474
1705
  if (release.pending === true) {
1475
1706
  const file = path.posix.join(pkg.directory, "package.json");
1476
1707
  const diff = await git(root, [
@@ -1487,11 +1718,6 @@ async function collectEvidence(repository, pkg, release, excludeDirectories = []
1487
1718
  text: diff
1488
1719
  });
1489
1720
  }
1490
- if (evidence.length > 0) evidence.unshift({
1491
- id: `range:${from}..${release.ref}`,
1492
- kind: "range",
1493
- 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.`
1494
- });
1495
1721
  return evidence;
1496
1722
  }
1497
1723
  function githubUnavailable(error) {
@@ -1628,7 +1854,7 @@ function releaseBatches(requests, limit) {
1628
1854
  for (const release of requests) {
1629
1855
  const minimum = {
1630
1856
  ...release,
1631
- evidence: release.evidence.filter((item) => item.kind === "range")
1857
+ evidence: release.evidence.filter((item) => required(item))
1632
1858
  };
1633
1859
  const length = JSON.stringify(minimum).length + 1;
1634
1860
  if (size + length > limit && batch.length > 0) {
@@ -1644,7 +1870,7 @@ function releaseBatches(requests, limit) {
1644
1870
  const allowance = Math.max(0, Math.floor((limit - JSON.stringify(items).length) / items.length));
1645
1871
  const seeded = items.map((item) => ({
1646
1872
  ...item,
1647
- evidence: [...item.evidence, ...researchEvidence(full.get(item.id).filter((entry) => entry.kind !== "range"), allowance + 2)]
1873
+ evidence: [...item.evidence, ...researchEvidence(full.get(item.id).filter((entry) => !required(entry)), allowance + 2)]
1648
1874
  }));
1649
1875
  let remaining = limit - JSON.stringify(seeded).length;
1650
1876
  for (const item of seeded) {
@@ -1657,6 +1883,10 @@ function releaseBatches(requests, limit) {
1657
1883
  return seeded;
1658
1884
  });
1659
1885
  }
1886
+ /** Keep what a request cannot do without: its range, and a draft it may cite. */
1887
+ function required(item) {
1888
+ return item.kind === "range" || item.kind === "draft";
1889
+ }
1660
1890
  /** Bound the starting context; adapters can read the complete range themselves. */
1661
1891
  function researchEvidence(evidence, limit) {
1662
1892
  const seed = [];
@@ -1671,19 +1901,32 @@ function researchEvidence(evidence, limit) {
1671
1901
  }
1672
1902
 
1673
1903
  // src/lib/jobs.ts
1904
+ /** Lets a revised entry cite the draft it came from. */
1905
+ const draftEvidence = {
1906
+ id: "draft",
1907
+ kind: "draft",
1908
+ text: "The existing entries, which describe this release up to repository.from. Cite this ID for entries carried over or rewritten from them."
1909
+ };
1910
+ /** Check whether a package's changelog is a monorepo's root summary. */
1911
+ function summarizesPackages(context, pkg) {
1912
+ return pkg.directory === "." && context.repository.packages.length > 0 && context.options.rootChangelog === "summary";
1913
+ }
1674
1914
  /**
1675
1915
  * Prepare one release without launching a model, so repository research can be
1676
1916
  * shared.
1677
1917
  */
1678
- async function prepareRelease(context, pkg, release, existingEntries, mode, from, onlyRepositoryFiles = false) {
1918
+ async function prepareRelease(context, pkg, release, details) {
1679
1919
  const { options, repository } = context;
1920
+ const { existingEntries = "", from, mode, onlyRepositoryFiles = false, styleReference } = details;
1680
1921
  options.signal?.throwIfAborted();
1681
1922
  const excludeDirectories = await excludedPackages(context, pkg, release.ref, onlyRepositoryFiles);
1923
+ const evidence = await collectEvidence(repository, pkg, from === void 0 ? release : {
1924
+ ...release,
1925
+ previousRef: from
1926
+ }, excludeDirectories);
1927
+ if (mode === "revise" && evidence.length > 0) evidence.splice(1, 0, draftEvidence);
1682
1928
  return {
1683
- evidence: await collectEvidence(repository, pkg, from === void 0 ? release : {
1684
- ...release,
1685
- previousRef: from
1686
- }, excludeDirectories),
1929
+ evidence,
1687
1930
  existingEntries,
1688
1931
  id: `${pkg.directory}@${release.version}`,
1689
1932
  initialRelease: release.previousRef === void 0 && from === void 0,
@@ -1695,7 +1938,8 @@ async function prepareRelease(context, pkg, release, existingEntries, mode, from
1695
1938
  from: from ?? release.previousRef,
1696
1939
  to: release.ref
1697
1940
  },
1698
- scope: pkg.directory === "." && repository.packages.length > 0 && options.rootChangelog === "summary" ? "repository" : "package",
1941
+ scope: summarizesPackages(context, pkg) ? "repository" : "package",
1942
+ styleReference,
1699
1943
  version: release.version
1700
1944
  };
1701
1945
  }
@@ -1761,9 +2005,11 @@ function progressMessage(pending, sessions) {
1761
2005
  }
1762
2006
  /**
1763
2007
  * Run one agent session. A long session is worth saving, so invalid output gets
1764
- * one correction request before the run fails.
2008
+ * one correction request before the run fails. Citations are checked against
2009
+ * the releases as they were before their evidence was trimmed to fit, since an
2010
+ * agent researching a range can cite what trimming left out.
1765
2011
  */
1766
- async function runSession(context, releases) {
2012
+ async function runSession(context, releases, untrimmed) {
1767
2013
  const { agent, options, repository } = context;
1768
2014
  const request = {
1769
2015
  releases,
@@ -1776,24 +2022,24 @@ async function runSession(context, releases) {
1776
2022
  }
1777
2023
  };
1778
2024
  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.`);
2025
+ const accept = async (value) => verifyCitations(repository.root, validateResponse(value, releases), untrimmed);
1779
2026
  const started = performance.now();
1780
2027
  const response = await agent.generate(request);
1781
2028
  log.debug(`The ${agent.name} session finished in ${Math.round(performance.now() - started)} ms.`);
1782
2029
  try {
1783
- return validateResponse(response, releases);
2030
+ return await accept(response);
1784
2031
  } catch (error) {
1785
2032
  const message = error instanceof Error ? error.message : String(error);
1786
2033
  log.warn(`Requesting a correction of invalid agent output. ${message}`);
1787
2034
  options.onProgress?.("Requesting a correction of invalid agent output.");
1788
2035
  options.signal?.throwIfAborted();
1789
- const corrected = await agent.generate({
2036
+ return accept(await agent.generate({
1790
2037
  ...request,
1791
2038
  repair: {
1792
2039
  error: message,
1793
2040
  response
1794
2041
  }
1795
- });
1796
- return validateResponse(corrected, releases);
2042
+ }));
1797
2043
  }
1798
2044
  }
1799
2045
  /**
@@ -1826,7 +2072,7 @@ async function generateReleases(context, requests) {
1826
2072
  options.onProgress?.(status);
1827
2073
  for (const releases of batches) {
1828
2074
  options.signal?.throwIfAborted();
1829
- const changeSets = await runSession(context, releases);
2075
+ const changeSets = await runSession(context, releases, releases.map(({ id }) => unabridged.get(id)));
1830
2076
  for (const { changes, id } of changeSets) {
1831
2077
  results.set(id, changes);
1832
2078
  await saveChanges(context, unabridged.get(id), changes);
@@ -1839,8 +2085,9 @@ async function generateReleases(context, requests) {
1839
2085
  const checkpointSchema = z.object({
1840
2086
  baseRef: z.string(),
1841
2087
  contentHash: z.string(),
2088
+ generated: z.boolean(),
1842
2089
  head: z.string(),
1843
- instructionVersion: z.literal(instructionVersion),
2090
+ instructionVersion: z.string(),
1844
2091
  path: z.string()
1845
2092
  });
1846
2093
  const HEADING_PREFIX = /^#{1,3}\s+/v;
@@ -1849,45 +2096,80 @@ const ANCHOR_PUNCTUATION = /[^\p{L}\p{N}_\s\-]/gv;
1849
2096
  const ANCHOR_SPACE = /\s/gv;
1850
2097
  /**
1851
2098
  * Remember what this run covered, so the next one can research only newer
1852
- * commits.
2099
+ * commits, and whether the draft it leaves is entirely generated.
1853
2100
  */
1854
- function recordCheckpoint(context, file, content, release) {
2101
+ function recordCheckpoint(context, file, content, release, generated) {
1855
2102
  if (release === void 0 || !context.options.cache) return;
1856
2103
  context.plan.checkpoints.push({
1857
2104
  baseRef: release.previousRef ?? "initial",
1858
2105
  contentHash: changelogHash(content),
2106
+ generated,
1859
2107
  head: context.repository.head,
1860
2108
  instructionVersion,
1861
2109
  path: file
1862
2110
  });
1863
2111
  }
1864
2112
  /**
1865
- * Find the commit a changelog already covers, when its checkpoint is still
1866
- * valid.
2113
+ * Read what the last run recorded about a changelog, when the file still reads
2114
+ * as that run left it.
1867
2115
  */
1868
- async function incrementalBase(context, file, content, release) {
2116
+ async function readCheckpoint(context, file, content) {
1869
2117
  if (content === void 0 || !context.options.cache) return;
1870
2118
  const result = checkpointSchema.safeParse(await readCache(path.join(context.repository.gitDirectory, "chagent", "checkpoints"), file));
1871
- if (!result.success || result.data.contentHash !== changelogHash(content) || result.data.baseRef !== (release.previousRef ?? "initial")) return;
1872
- return await isAncestor(context.repository.root, result.data.head, release.ref) ? result.data.head : void 0;
2119
+ return result.success && result.data.contentHash === changelogHash(content) ? result.data : void 0;
1873
2120
  }
1874
- /** Select the releases newer than anything the changelog already records. */
2121
+ /**
2122
+ * Find the commit a release's entries already cover, when the checkpoint
2123
+ * describes the same release.
2124
+ */
2125
+ async function incrementalBase(context, checkpoint, release) {
2126
+ if (checkpoint?.instructionVersion !== instructionVersion || checkpoint.baseRef !== (release.previousRef ?? "initial")) return;
2127
+ return await isAncestor(context.repository.root, checkpoint.head, release.ref) ? checkpoint.head : void 0;
2128
+ }
2129
+ /**
2130
+ * Decide whether the draft a run leaves is entirely generated: one that was
2131
+ * before stays so, and a draft written from nothing or revised becomes so.
2132
+ */
2133
+ function leavesGeneratedDraft(entries, previously) {
2134
+ const draft = entries.find((entry) => entry.release.version === "Unreleased");
2135
+ return previously || draft !== void 0 && (draft.existingBody === void 0 || draft.revise === true);
2136
+ }
2137
+ /** Select every reachable release the changelog does not already record. */
1875
2138
  function missingReleases(releases, content = "") {
1876
2139
  const { sections } = parseChangelog(content);
1877
2140
  const known = new Set(sections.map((section) => section.version));
1878
2141
  const recorded = sections.filter((section) => section.version !== "Unreleased").map((section) => section.version);
1879
2142
  const unrecognized = recorded.filter((version) => semver.valid(version) === null);
1880
2143
  if (unrecognized.length > 0) throw new Error(`The existing changelog has unrecognized release headings (${unrecognized.join(", ")}). Normalize its release headings before updating it.`);
1881
- const newest = recorded.toSorted(semver.rcompare)[0];
1882
- if (newest !== void 0 && releases.every((release) => release.version !== newest)) throw new Error(`Cannot locate the Git release boundary for existing changelog version ${newest}. Fetch its tags or configure tagPatterns.`);
1883
- return releases.filter((release) => release.version === "Unreleased" || !known.has(release.version) && (newest === void 0 || semver.gt(release.version, newest)));
2144
+ for (const version of recorded) if (releases.every((release) => release.version !== version)) throw new Error(`Cannot locate the Git release boundary for existing changelog version ${version}. Fetch its tags or configure tagPatterns.`);
2145
+ return releases.filter((release) => release.version === "Unreleased" || !known.has(release.version));
1884
2146
  }
1885
2147
  /** Apply an explicit release context only to the package being released. */
1886
2148
  function releaseContext(options, pkg) {
1887
2149
  return pkg.directory === options.releaseDirectory ? options.release : void 0;
1888
2150
  }
1889
- /** Resolve a package's releases and prepare requests for those it still needs. */
1890
- async function preparePackage(context, pkg, aggregateHistory = false, recordedAtRoot) {
2151
+ /** Prepare the request for a release the changelog still needs. */
2152
+ async function prepareWork(context, pkg, release, details) {
2153
+ const { checkpoint, existingBody, mode } = details;
2154
+ const from = existingBody === void 0 ? void 0 : await incrementalBase(context, checkpoint, release);
2155
+ const revise = from !== void 0 && checkpoint?.generated === true;
2156
+ return {
2157
+ existingBody,
2158
+ release,
2159
+ request: await prepareRelease(context, pkg, release, {
2160
+ existingEntries: existingBody !== void 0 && summarizesPackages(context, pkg) ? withoutPackageLinks(existingBody) : existingBody,
2161
+ from,
2162
+ mode: revise ? "revise" : mode,
2163
+ styleReference: details.styleReference
2164
+ })
2165
+ };
2166
+ }
2167
+ /**
2168
+ * Resolve a package's releases and prepare requests for those its changelog
2169
+ * still needs. Summaries cover those releases alone, or with `allSummaries`,
2170
+ * every release, so that a root changelog can link to any of them.
2171
+ */
2172
+ async function preparePackage(context, pkg, allSummaries = false) {
1891
2173
  const { repository } = context;
1892
2174
  const file = await findChangelog(path.join(repository.root, pkg.directory));
1893
2175
  const content = context.options.rebuild ? void 0 : file.content;
@@ -1895,15 +2177,17 @@ async function preparePackage(context, pkg, aggregateHistory = false, recordedAt
1895
2177
  const releases = await resolveReleases(repository, pkg, context.options, releaseContext(context.options, pkg));
1896
2178
  const missing = missingReleases(releases, content);
1897
2179
  log.debug(`${pkg.name}: ${releases.length} releases, missing [${missing.map((release) => release.version).join(", ")}], changelog ${file.path}.`);
1898
- const draft = parseChangelog(content ?? "").sections.find((section) => section.version === "Unreleased");
2180
+ const parsed = parseChangelog(content ?? "");
2181
+ const draft = parsed.sections.find((section) => section.version === "Unreleased");
2182
+ const checkpoint = draft === void 0 ? void 0 : await readCheckpoint(context, file.path, content);
1899
2183
  const newest = releases.findLast((release) => release.version !== "Unreleased");
1900
2184
  const draftPromoted = draft !== void 0 && newest !== void 0 && missing.includes(newest);
1901
2185
  const work = [];
1902
2186
  const summaries = [];
1903
2187
  for (const release of releases) {
1904
2188
  const shouldWrite = missing.includes(release);
1905
- if (!shouldWrite && !aggregateHistory) continue;
1906
- const summary = {
2189
+ if (!shouldWrite && !allSummaries) continue;
2190
+ summaries.push({
1907
2191
  changelogPath: file.path,
1908
2192
  changes: [],
1909
2193
  date: release.date,
@@ -1912,50 +2196,58 @@ async function preparePackage(context, pkg, aggregateHistory = false, recordedAt
1912
2196
  ref: release.ref,
1913
2197
  tag: release.tag,
1914
2198
  version: release.version
1915
- };
1916
- summaries.push(summary);
1917
- if (!shouldWrite && recordedAtRoot?.(summary) === true) continue;
1918
- const existingBody = (draftPromoted ? release === newest : release.version === "Unreleased") ? draft?.body : void 0;
1919
- const from = existingBody === void 0 ? void 0 : await incrementalBase(context, file.path, content, release);
1920
- const request = await prepareRelease(context, pkg, release, existingBody ?? "", mode, from);
1921
- work.push({
1922
- existingBody,
1923
- release,
1924
- request,
1925
- shouldWrite
1926
2199
  });
2200
+ if (!shouldWrite) continue;
2201
+ const ownsDraft = draftPromoted ? release === newest : release.version === "Unreleased";
2202
+ work.push(await prepareWork(context, pkg, release, {
2203
+ checkpoint,
2204
+ existingBody: ownsDraft ? draft?.body : void 0,
2205
+ mode,
2206
+ styleReference: styleReference(parsed)
2207
+ }));
1927
2208
  }
2209
+ const exception = await hasNodeException(repository, pkg, releases);
1928
2210
  return {
1929
2211
  content,
1930
- exception: await hasNodeException(repository, pkg, releases),
2212
+ draftGenerated: checkpoint?.generated === true,
2213
+ exception,
1931
2214
  file,
2215
+ pkg,
1932
2216
  releases,
1933
2217
  summaries,
1934
2218
  work
1935
2219
  };
1936
2220
  }
1937
- /** Write a package's edit into the plan and complete its summaries. */
1938
- function renderPackage(context, prepared, results) {
2221
+ /**
2222
+ * Write a package's edit into the plan and complete its summaries. A root
2223
+ * summary passes `linksFor` to end each section it writes with links to the
2224
+ * package releases that section covers.
2225
+ */
2226
+ function renderPackage(context, prepared, results, linksFor) {
1939
2227
  const { plan, repository } = context;
1940
2228
  const { content, exception, file, releases, summaries, work } = prepared;
1941
2229
  const entries = [];
1942
2230
  const changesByVersion = /* @__PURE__ */ new Map();
1943
- for (const { existingBody, release, request, shouldWrite } of work) {
2231
+ for (const { existingBody, release, request } of work) {
1944
2232
  const changes = results.get(request.id) ?? [];
2233
+ const links = linksFor?.(release);
2234
+ const revise = request.mode === "revise" && results.has(request.id);
1945
2235
  changesByVersion.set(release.version, changes);
1946
- if (shouldWrite && (release.version !== "Unreleased" || changes.length > 0)) entries.push({
2236
+ if (revise || release.version !== "Unreleased" || changes.length > 0 || links !== void 0 && packageLinksDiffer(existingBody, links)) entries.push({
1947
2237
  changes,
1948
2238
  existingBody,
1949
- release
2239
+ links,
2240
+ release,
2241
+ revise
1950
2242
  });
1951
2243
  }
1952
- const after = updateMarkdown(content, entries, exception, repository.github);
2244
+ const after = updateMarkdown(content, entries, exception, repository.github, releases.map((release) => release.version));
1953
2245
  if (after !== file.content) plan.files.push({
1954
2246
  after,
1955
2247
  before: file.content,
1956
2248
  path: file.path
1957
2249
  });
1958
- recordCheckpoint(context, file.path, after, releases.at(-1));
2250
+ recordCheckpoint(context, file.path, after, releases.at(-1), leavesGeneratedDraft(entries, prepared.draftGenerated));
1959
2251
  const { sections } = parseChangelog(after);
1960
2252
  return summaries.map((summary) => ({
1961
2253
  ...summary,
@@ -1973,85 +2265,44 @@ function headingAnchor(heading) {
1973
2265
  function missingPackageSummaries(summaries, content = "") {
1974
2266
  const parsed = parseChangelog(content);
1975
2267
  const known = new Set(parsed.sections.map((section) => section.version));
1976
- return summaries.toSorted((a, b) => (a.date ?? "9999").localeCompare(b.date ?? "9999")).filter((summary) => {
1977
- if (summary.version === "Unreleased" || known.has(`${summary.packageName}@${summary.version}`)) return false;
1978
- return parsed.sections.every((section) => {
1979
- const prefix = summary.packageName + "@";
1980
- const version = section.version.startsWith(prefix) ? section.version.slice(prefix.length) : "";
1981
- return semver.valid(version) === null || semver.lt(version, summary.version);
1982
- });
1983
- });
2268
+ return summaries.filter((summary) => summary.version !== "Unreleased" && !known.has(`${summary.packageName}@${summary.version}`));
1984
2269
  }
1985
2270
 
1986
2271
  // src/lib/workspace.ts
2272
+ /** Link to a package's release section, when its changelog has one. */
1987
2273
  function packageLink(repository, summary) {
1988
- const relative = path.relative(repository.root, summary.changelogPath).split(path.sep).join("/");
1989
- const anchor = summary.anchor ?? (summary.version === "Unreleased" ? "unreleased" : `${summary.version.replaceAll(".", "")}${summary.date === void 0 ? "" : "---" + summary.date}`);
1990
- return repository.github === void 0 ? `${relative}#${anchor}` : `https://github.com/${repository.github}/blob/HEAD/${relative}#${anchor}`;
1991
- }
1992
- function aggregateChanges(repository, summaries) {
1993
- const changes = [];
1994
- for (const summary of summaries) {
1995
- if (summary.version === "Unreleased" && summary.changes.length === 0) continue;
1996
- const link = `[${summary.packageName} ${summary.version}](${packageLink(repository, summary)})`;
1997
- const highlights = summary.changes.slice(0, 3);
1998
- if (highlights.length === 0) changes.push({
1999
- breaking: false,
2000
- category: "Maintenance",
2001
- description: `${link}: See the package changelog.`,
2002
- sources: []
2003
- });
2004
- else for (const change of highlights) changes.push({
2005
- ...change,
2006
- description: `${link}: ${change.description}`
2007
- });
2008
- }
2009
- return changes;
2010
- }
2011
- function promotePackageDraft(content, summary, repository) {
2012
- const changes = [];
2013
- const draft = parseChangelog(content ?? "").sections.find((section) => section.version === "Unreleased");
2014
- if (content === void 0 || draft === void 0) return {
2015
- changes,
2016
- content
2017
- };
2018
- const source = `[${summary.packageName} Unreleased](`;
2019
- const target = `[${summary.packageName} ${summary.version}](${packageLink(repository, summary)})`;
2020
- const remaining = [];
2021
- let category = "Changed";
2022
- for (const line of draft.body.split(LINE_BOUNDARY)) {
2023
- category = categories.find((item) => line.trimEnd() === `### ${item}`) ?? category;
2024
- const breaking = line.startsWith("- **Breaking:** ");
2025
- const prefix = breaking ? "- **Breaking:** " : "- ";
2026
- const end = line.indexOf(")", prefix.length + source.length);
2027
- if (end === -1 || !line.startsWith(prefix + source)) {
2028
- remaining.push(line);
2029
- continue;
2030
- }
2031
- changes.push({
2032
- breaking,
2033
- category,
2034
- description: target + line.slice(end + 1).trimEnd(),
2035
- sources: []
2036
- });
2037
- }
2038
- if (changes.length === 0) return {
2039
- changes,
2040
- content
2041
- };
2042
- const replacement = remaining.some((line) => line.trim() !== "" && categories.every((item) => line.trimEnd() !== `### ${item}`)) ? draft.heading + remaining.join("") : "";
2043
- return {
2044
- changes,
2045
- content: content.slice(0, draft.start) + replacement + content.slice(draft.end)
2046
- };
2047
- }
2048
- async function prepareIndependent(context, summaries) {
2274
+ if (summary.anchor === void 0) return [];
2275
+ const target = `${path.relative(repository.root, summary.changelogPath).split(path.sep).join("/")}#${summary.anchor}`;
2276
+ return [{
2277
+ label: `${summary.packageName} ${summary.version}`,
2278
+ url: repository.github === void 0 ? target : `https://github.com/${repository.github}/blob/HEAD/${target}`
2279
+ }];
2280
+ }
2281
+ /** Name the root's summary of a package release. */
2282
+ function summaryId(summary) {
2283
+ return `.@${summary.packageName}@${summary.version}`;
2284
+ }
2285
+ async function prepareIndependent(context, packages) {
2049
2286
  const { repository } = context;
2050
2287
  const file = await findChangelog(repository.root);
2051
2288
  const content = context.options.rebuild ? void 0 : file.content;
2052
- let remaining = content;
2053
- for (const summary of missingPackageSummaries(summaries, content)) remaining = promotePackageDraft(remaining, summary, repository).content;
2054
- const releaseRefs = new Set(summaries.filter((summary) => summary.version !== "Unreleased").map((summary) => summary.ref));
2289
+ const parsed = parseChangelog(content ?? "");
2290
+ const details = {
2291
+ mode: content === void 0 ? "initialize" : "update",
2292
+ styleReference: styleReference(parsed)
2293
+ };
2294
+ const releases = [];
2295
+ for (const item of packages) for (const summary of missingPackageSummaries(item.summaries, content)) {
2296
+ const release = item.releases.find((candidate) => candidate.version === summary.version);
2297
+ const request = await prepareRelease(context, item.pkg, release, details);
2298
+ releases.push({
2299
+ ...request,
2300
+ evidence: request.evidence.filter((evidence) => evidence.kind === "range"),
2301
+ id: summaryId(summary),
2302
+ scope: "repository"
2303
+ });
2304
+ }
2305
+ const releaseRefs = new Set(packages.flatMap((item) => item.summaries).filter((summary) => summary.version !== "Unreleased").map((summary) => summary.ref));
2055
2306
  const release = {
2056
2307
  previousRef: (await git(repository.root, [
2057
2308
  "rev-list",
@@ -2061,52 +2312,63 @@ async function prepareIndependent(context, summaries) {
2061
2312
  ref: repository.head,
2062
2313
  version: "Unreleased"
2063
2314
  };
2064
- const draft = parseChangelog(remaining ?? "").sections.find((section) => section.version === "Unreleased")?.body;
2065
- const from = await incrementalBase(context, file.path, remaining, release);
2315
+ const draft = parsed.sections.find((section) => section.version === "Unreleased")?.body;
2316
+ const checkpoint = await readCheckpoint(context, file.path, content);
2317
+ const from = await incrementalBase(context, checkpoint, release);
2318
+ const draftGenerated = checkpoint?.generated === true;
2066
2319
  return {
2067
2320
  content,
2321
+ draft,
2322
+ draftGenerated,
2068
2323
  file,
2069
- release,
2070
- request: await prepareRelease(context, repository.rootPackage, release, draft ?? "", content === void 0 ? "initialize" : "update", from, true)
2324
+ releases,
2325
+ unreleased: {
2326
+ release,
2327
+ request: await prepareRelease(context, repository.rootPackage, release, {
2328
+ ...details,
2329
+ existingEntries: draft === void 0 ? void 0 : withoutPackageLinks(draft),
2330
+ from,
2331
+ mode: draft !== void 0 && from !== void 0 && draftGenerated ? "revise" : details.mode,
2332
+ onlyRepositoryFiles: true
2333
+ })
2334
+ }
2071
2335
  };
2072
2336
  }
2073
- function aggregateIndependent(context, prepared, results, summaries, exception) {
2337
+ /**
2338
+ * Write a section for each package release the root does not record yet, and
2339
+ * keep the draft's links to packages with unreleased changes current.
2340
+ */
2341
+ async function renderIndependent(context, prepared, results, summaries, exception) {
2074
2342
  const { plan, repository } = context;
2075
- const { file } = prepared;
2076
- let { content } = prepared;
2077
- const entries = [];
2078
- for (const summary of missingPackageSummaries(summaries, content)) {
2079
- const label = `${summary.packageName}@${summary.version}`;
2080
- const promoted = promotePackageDraft(content, summary, repository);
2081
- content = promoted.content;
2082
- entries.push({
2083
- changes: [...promoted.changes, ...aggregateChanges(repository, [summary])],
2084
- release: {
2085
- date: summary.date,
2086
- ref: summary.ref,
2087
- tag: summary.tag,
2088
- version: label
2089
- }
2090
- });
2091
- }
2092
- const unreleased = aggregateChanges(repository, summaries.filter((summary) => summary.version === "Unreleased"));
2093
- const draft = parseChangelog(content ?? "").sections.find((section) => section.version === "Unreleased")?.body;
2094
- unreleased.push(...results.get(prepared.request.id) ?? []);
2095
- if (unreleased.length > 0) entries.push({
2096
- changes: unreleased,
2097
- existingBody: draft,
2343
+ const { content, draft, file, unreleased } = prepared;
2344
+ const ordered = await sortReleases(repository, summaries.filter((summary) => summary.version !== "Unreleased"));
2345
+ const entries = Array.from(missingPackageSummaries(ordered, content), (summary) => ({
2346
+ changes: results.get(summaryId(summary)) ?? [],
2347
+ links: packageLink(repository, summary),
2098
2348
  release: {
2099
- ref: repository.head,
2100
- version: "Unreleased"
2349
+ date: summary.date,
2350
+ ref: summary.ref,
2351
+ tag: summary.tag,
2352
+ version: `${summary.packageName}@${summary.version}`
2101
2353
  }
2354
+ }));
2355
+ const changes = results.get(unreleased.request.id) ?? [];
2356
+ const links = summaries.filter((summary) => summary.version === "Unreleased").flatMap((summary) => packageLink(repository, summary));
2357
+ const revise = unreleased.request.mode === "revise" && results.has(unreleased.request.id);
2358
+ if (revise || changes.length > 0 || packageLinksDiffer(draft, links)) entries.push({
2359
+ changes,
2360
+ existingBody: draft,
2361
+ links,
2362
+ release: unreleased.release,
2363
+ revise
2102
2364
  });
2103
- const after = updateMarkdown(content, entries, exception, repository.github);
2365
+ const after = updateMarkdown(content, entries, exception, repository.github, ordered.map((summary) => `${summary.packageName}@${summary.version}`));
2104
2366
  if (after !== file.content) plan.files.push({
2105
2367
  after,
2106
2368
  before: file.content,
2107
2369
  path: file.path
2108
2370
  });
2109
- recordCheckpoint(context, file.path, after, prepared.release);
2371
+ recordCheckpoint(context, file.path, after, unreleased.release, leavesGeneratedDraft(entries, prepared.draftGenerated));
2110
2372
  }
2111
2373
  /**
2112
2374
  * Find package releases whose own section records no notable changes, so a
@@ -2120,18 +2382,6 @@ function quietReleases(plan, packages) {
2120
2382
  }
2121
2383
  return quiet;
2122
2384
  }
2123
- function aggregateShared(context, prepared, results, exception, quiet) {
2124
- const { plan, repository } = context;
2125
- renderPackage(context, prepared, results);
2126
- const rootEdit = plan.files.find((file) => path.dirname(file.path) === repository.root);
2127
- if (rootEdit === void 0) return;
2128
- const additions = prepared.summaries.map((summary) => ({
2129
- changes: aggregateChanges(repository, plan.releases.filter((item) => item.version === summary.version && item.ref === summary.ref && !quiet.has(`${item.changelogPath}@${item.version}`))),
2130
- existingBody: parseChangelog(rootEdit.after).sections.find((section) => section.version === summary.version)?.body,
2131
- release: prepared.releases.find((item) => item.version === summary.version)
2132
- }));
2133
- rootEdit.after = updateMarkdown(prepared.content, additions, exception || prepared.exception, repository.github);
2134
- }
2135
2385
  /** Generate package changelogs and the root changelog that summarizes them. */
2136
2386
  async function generateWorkspace(context) {
2137
2387
  const { options, plan, repository } = context;
@@ -2139,21 +2389,24 @@ async function generateWorkspace(context) {
2139
2389
  const shared = rootReleases.some((release) => release.version !== "Unreleased");
2140
2390
  const rootFile = await findChangelog(repository.root);
2141
2391
  const rootContent = options.rebuild ? void 0 : rootFile.content;
2142
- const rootMissing = shared ? missingReleases(rootReleases, rootContent) : void 0;
2143
- const needsPackageHistory = rootMissing === void 0 || rootMissing.some((release) => release.version !== "Unreleased");
2144
- const recordedAtRoot = (summary) => rootMissing === void 0 ? rootContent !== void 0 && missingPackageSummaries([summary], rootContent).length === 0 : rootMissing.every((release) => release.version !== summary.version || release.ref !== summary.ref);
2392
+ const linksHistory = !shared || missingReleases(rootReleases, rootContent).some((release) => release.version !== "Unreleased");
2145
2393
  const packages = [];
2146
- for (const pkg of describedPackages(repository)) packages.push(await preparePackage(context, pkg, needsPackageHistory, recordedAtRoot));
2394
+ for (const pkg of describedPackages(repository)) packages.push(await preparePackage(context, pkg, linksHistory));
2147
2395
  const root = shared ? await preparePackage(context, repository.rootPackage) : void 0;
2148
- const independent = shared ? void 0 : await prepareIndependent(context, packages.flatMap((pkg) => pkg.summaries));
2396
+ const independent = shared ? void 0 : await prepareIndependent(context, packages);
2149
2397
  const requests = [...packages, ...root === void 0 ? [] : [root]].flatMap((pkg) => pkg.work.map((item) => item.request));
2150
- if (independent !== void 0) requests.push(independent.request);
2398
+ if (independent !== void 0) requests.push(...independent.releases, independent.unreleased.request);
2151
2399
  const results = await generateReleases(context, requests);
2152
2400
  const summaries = packages.flatMap((pkg) => renderPackage(context, pkg, results));
2153
2401
  plan.releases.push(...summaries);
2154
2402
  const exception = packages.some((pkg) => pkg.exception);
2155
- if (root !== void 0) aggregateShared(context, root, results, exception, quietReleases(plan, packages));
2156
- else if (independent !== void 0) aggregateIndependent(context, independent, results, summaries, exception);
2403
+ if (root !== void 0) {
2404
+ const quiet = quietReleases(plan, packages);
2405
+ renderPackage(context, {
2406
+ ...root,
2407
+ exception: exception || root.exception
2408
+ }, results, (release) => summaries.filter((summary) => summary.version === release.version && summary.ref === release.ref && !quiet.has(`${summary.changelogPath}@${summary.version}`)).flatMap((summary) => packageLink(repository, summary)));
2409
+ } else if (independent !== void 0) await renderIndependent(context, independent, results, summaries, exception);
2157
2410
  }
2158
2411
 
2159
2412
  // src/lib/generate.ts
@@ -2379,4 +2632,4 @@ async function reviewChangelog(plan, options = {}) {
2379
2632
  }
2380
2633
  }
2381
2634
 
2382
- export { maxTimeoutMinutes as C, defaults as S, git as _, defineConfig as a, setLogger as b, createAgent as c, rootChangelogs as d, LOCKFILE_PATH as f, manifestAt as g, localDate as h, generateChangelog as i, agentNames as l, isVersionOnlyChange as m, previewChangelog as n, loadConfig as o, inspectRepository as p, reviewChangelog as r, writeChangelog as s, assertReviewTerminal as t, efforts as u, isAncestor as v, version as w, defaultModels as x, runCommand as y };
2635
+ 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 };