@kitschpatrol/chagent 0.4.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.4.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
@@ -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,10 +427,23 @@ 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;
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;
409
445
  }
446
+ return true;
410
447
  }
411
448
  /**
412
449
  * Predict the tag of a release that is not tagged yet, so its heading can link
@@ -540,25 +577,31 @@ function describedPackages(repository) {
540
577
  async function taggedReleases(repository, pkg, config) {
541
578
  const candidates = [];
542
579
  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
580
+ const match = tagVersion(tag.name, pkg, config.tagPatterns);
581
+ if (match !== void 0) candidates.push({
582
+ ...match,
583
+ tag
547
584
  });
548
585
  }
549
586
  const isRoot = pkg.directory === ".";
550
587
  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) {
588
+ const owned = [];
589
+ for (const candidate of candidates) {
553
590
  if (!isRoot) {
554
- const manifest = manifests.get(tag.commit);
555
- 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;
556
593
  if (manifest === void 0 && await git(repository.root, [
557
594
  "cat-file",
558
595
  "-t",
559
- `${tag.commit}:${pkg.directory}`
596
+ `${candidate.tag.commit}:${pkg.directory}`
560
597
  ], true) !== "tree") continue;
561
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;
562
605
  const existing = releases.find((release) => release.version === version);
563
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.`);
564
607
  if (existing) continue;
@@ -638,13 +681,25 @@ manager, manifest format, or directory layout. Use snapshots at those refs, not
638
681
  uncommitted files or newer code at HEAD. Respect repository.directory and
639
682
  repository.excludeDirectories when deciding which changes belong in this entry;
640
683
  you may inspect related files for context. The supplied evidence is a starting
641
- 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.
642
689
 
643
690
  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.
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.
648
703
 
649
704
  Cover distinct changes to behavior, public APIs and types, CLI flags and output,
650
705
  runtime compatibility, installation and distribution, documentation and examples,
@@ -673,9 +728,16 @@ Give distinct changes separate readable bullets;
673
728
  do not compress unrelated features or breaking changes into one long entry. Keep
674
729
  each entry concise without sacrificing coverage. Consolidate related commits,
675
730
  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.
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.
679
741
 
680
742
  Use commit messages and PRs to understand intent, then check the changed files
681
743
  and diffs. Inspect public interfaces at both ends of the range before claiming
@@ -714,25 +776,37 @@ Put breaking public changes in the appropriate standard category and set breakin
714
776
  to true; do not create a Breaking Changes category.
715
777
  Descriptions are single-line Markdown suitable for a list item,
716
778
  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
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
720
785
  link only when its URL and association are supplied. Do not fabricate links.
721
786
 
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
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
725
791
  absent because this output will itself become GitHub release notes.
726
792
  Do not read existing changelog files, generated release-note files, or GitHub
727
793
  release bodies as source material, even when researching the repository directly.
728
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
+
729
802
  Before returning, check coverage across behavior/API, compatibility, dependencies,
730
803
  documentation, build/test/CI, and distribution. Do not drop a change just because
731
804
  its commit is called chore, docs, build, or dependency updates. Return an empty
732
805
  changes array only when the evidence is entirely release-version bookkeeping,
733
806
  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.`;
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.`;
736
810
  /** Invalidates cached drafts whenever the instructions change. */
737
811
  const instructionVersion = hash(instructions);
738
812
 
@@ -762,6 +836,7 @@ const rootChangelogs = [
762
836
  ];
763
837
 
764
838
  // src/lib/agents.ts
839
+ const COMMIT_ID = /^[\da-f]{7,64}$/iv;
765
840
  const CONTROL_CHARACTERS = /\p{Cc}/v;
766
841
  const HEADING = /^#{1,6}\s/v;
767
842
  const LIST_MARKER = /^(?:[\-*]|\d+\.)(?:\s+|$)/v;
@@ -780,13 +855,10 @@ const responseJsonSchema = JSON.stringify(z.toJSONSchema(responseSchema, { targe
780
855
  function normalizeDescription(description) {
781
856
  return description.replaceAll(WHITESPACE_RUN, " ").trim().replace(LIST_MARKER, "");
782
857
  }
783
- function validateChanges(changes, release) {
784
- const ids = new Set(release.evidence.map((item) => item.id));
858
+ function validateDescriptions(changes, id) {
785
859
  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
860
  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)}.`);
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)}.`);
790
862
  return {
791
863
  ...change,
792
864
  description
@@ -794,8 +866,8 @@ function validateChanges(changes, release) {
794
866
  });
795
867
  }
796
868
  /**
797
- * Validate structure, completeness, release identity, and citations before
798
- * 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`.
799
871
  *
800
872
  * @returns The change sets, with descriptions normalized.
801
873
  */
@@ -809,10 +881,53 @@ function validateResponse(value, releases) {
809
881
  const omitted = expected.keys().filter((id) => !ids.includes(id)).toArray();
810
882
  if (omitted.length > 0) throw new Error(`Agent output omitted requested releases: ${omitted.join(", ")}.`);
811
883
  return response.data.releases.map(({ changes, id }) => ({
812
- changes: validateChanges(changes, expected.get(id)),
884
+ changes: validateDescriptions(changes, id),
813
885
  id
814
886
  }));
815
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
+ }
816
931
  function prompt({ releases, repair, repositoryRoot }) {
817
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.`;
818
933
  return `${instructions}${correction}\n\nInput data (JSON):\n${JSON.stringify({
@@ -936,6 +1051,83 @@ function createAgent(name) {
936
1051
  };
937
1052
  }
938
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
+
939
1131
  // src/lib/format.ts
940
1132
  const FORMATTER_PACKAGES = ["@kitschpatrol/shared-config", "@kitschpatrol/prettier-config"];
941
1133
  const manifestSchema = z.object({ exports: z.object({ ".": z.object({ import: z.string() }) }) });
@@ -1038,9 +1230,11 @@ async function writeChangelog(plan) {
1038
1230
  const edit = plan.files.find((file) => file.path === checkpoint.path);
1039
1231
  try {
1040
1232
  const written = edit === void 0 ? void 0 : await readOptional(edit.path);
1233
+ const edited = edit !== void 0 && changelogHash(edit.after) !== checkpoint.contentHash;
1041
1234
  await writeCache(path.join(stateDirectory, "checkpoints"), checkpoint.path, {
1042
1235
  ...checkpoint,
1043
- contentHash: written === void 0 ? checkpoint.contentHash : changelogHash(written)
1236
+ contentHash: written === void 0 ? checkpoint.contentHash : changelogHash(written),
1237
+ generated: checkpoint.generated && !edited
1044
1238
  });
1045
1239
  } catch {
1046
1240
  plan.diagnostics.push("Could not save incremental metadata; the next run will recheck the full release range.");
@@ -1075,83 +1269,6 @@ async function acquireLock(file) {
1075
1269
  }
1076
1270
  }
1077
1271
 
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
1272
  // src/lib/changelog.ts
1156
1273
  const LINE_BOUNDARY = /(?<=\n)/v;
1157
1274
  const FENCE = /^\s{0,3}(`{3,}|~{3,})/v;
@@ -1163,9 +1280,14 @@ const WHITESPACE = /\s+/gv;
1163
1280
  const TERMINAL_PUNCTUATION = /[.!]$/v;
1164
1281
  const NEWLINE = /\r?\n/v;
1165
1282
  const BULLET = /^\s*[\-*] /v;
1283
+ const ENTRY = /^\s*[\-*] /mv;
1284
+ const STYLE_REFERENCE_LENGTH = 2e3;
1166
1285
  const CATEGORY_HEADING = /^### /mv;
1286
+ const PACKAGES_HEADING = /^### Packages\s*$/mv;
1287
+ const LINK_TARGET = /\]\(([^\)]*)\)/gv;
1167
1288
  const SEMVER_LINE = /^.*\[Semantic Versioning\].*(?:\r?\n|$)/mv;
1168
1289
  const UNRELEASED_REFERENCE = /^\[unreleased\]:[^\r\n]*(?:\r?\n)?/imv;
1290
+ const V_PREFIX = /^v\d/v;
1169
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";
1170
1292
  /** The body of a release section without any changes to record. */
1171
1293
  const noNotableChanges = "No notable changes.";
@@ -1234,6 +1356,21 @@ function parseChangelog(content) {
1234
1356
  sections
1235
1357
  };
1236
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
+ }
1237
1374
  function normalized(description) {
1238
1375
  return description.trim().toLowerCase().replaceAll(WHITESPACE, " ").replace(TERMINAL_PUNCTUATION, "");
1239
1376
  }
@@ -1273,6 +1410,46 @@ function laterCategoryIndex(body, category) {
1273
1410
  const indexes = categories.slice(categories.indexOf(category) + 1).map((item) => categoryHeading(body, item)?.index).filter((index) => index !== void 0);
1274
1411
  return indexes.length === 0 ? void 0 : Math.min(...indexes);
1275
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
+ }
1276
1453
  /** Render a release heading with an optional comparison link. */
1277
1454
  function releaseLink(github, release) {
1278
1455
  if (github === void 0) return;
@@ -1293,12 +1470,16 @@ function updateMarkdown(original, entries, nodeException, github) {
1293
1470
  const draft = parsed.sections.find((section) => section.version === "Unreleased");
1294
1471
  const released = parsed.sections.filter((section) => section.version !== "Unreleased").map((section) => content.slice(section.start, section.end));
1295
1472
  let draftText = draft === void 0 ? "" : content.slice(draft.start, draft.end);
1296
- for (const { changes, existingBody, release } of entries) {
1473
+ for (const entry of entries) {
1474
+ const { existingBody, release } = entry;
1297
1475
  const link = releaseLink(github, release);
1298
1476
  const heading = `## ${link === void 0 ? release.version : `[${release.version}]`}${release.date === void 0 ? "" : ` - ${release.date}`}`;
1299
- let body = mergeChanges(existingBody ?? "", changes, newline);
1477
+ let body = sectionBody(entry, newline);
1300
1478
  if (body.trim() === "") {
1301
- if (release.version === "Unreleased") continue;
1479
+ if (release.version === "Unreleased") {
1480
+ if (existingBody !== void 0) draftText = "";
1481
+ continue;
1482
+ }
1302
1483
  body = newline + noNotableChanges + newline;
1303
1484
  }
1304
1485
  const section = heading + newline + newline + body.trim() + newline + newline;
@@ -1335,8 +1516,15 @@ function addNodeNote(header, newline, nodeException) {
1335
1516
  }
1336
1517
  return header;
1337
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
+ */
1338
1523
  function normalizeVersion(raw) {
1339
- 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;
1340
1528
  }
1341
1529
  function releaseHeadingVersion(line) {
1342
1530
  const match = RELEASE_HEADING.exec(line.trimEnd());
@@ -1352,17 +1540,26 @@ function sectionSpacing(content, newline) {
1352
1540
 
1353
1541
  // src/lib/evidence.ts
1354
1542
  const COMMIT_MARKER = "";
1355
- const GENERATED_DIRECTORY = /^(?:build|changelog|coverage|dist)(?:\/|$)/iv;
1356
- 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;
1545
+ /**
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.
1549
+ */
1550
+ function excluded(file) {
1551
+ return LOCKFILE_PATH.test(file) || NEVER_SOURCE.test(file);
1552
+ }
1357
1553
  /**
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.
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`.
1361
1558
  */
1362
- function ignored(file, packages) {
1559
+ function likelyGenerated(file, packages) {
1363
1560
  const owner = packages.findLast(({ directory }) => file.startsWith(`${directory}/`));
1364
1561
  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);
1562
+ return OUTPUT_DIRECTORY.test(relative);
1366
1563
  }
1367
1564
  /**
1368
1565
  * A package's directory and, transitively, those of the workspace packages it
@@ -1435,7 +1632,10 @@ async function commitsInRange(root, range, paths) {
1435
1632
  }
1436
1633
  return commits.values().toArray();
1437
1634
  }
1438
- /** 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
+ */
1439
1639
  async function collectEvidence(repository, pkg, release, excludeDirectories = []) {
1440
1640
  const { packages, root } = repository;
1441
1641
  const paths = [...(pkg.directory === "." ? ["."] : await packageScope(repository, pkg, release.ref)).map((directory) => `:(literal)${directory}`), ...excludeDirectories.map((directory) => `:(exclude,literal)${directory}`)];
@@ -1457,20 +1657,25 @@ async function collectEvidence(repository, pkg, release, excludeDirectories = []
1457
1657
  release.ref,
1458
1658
  "--",
1459
1659
  ...paths
1460
- ])).split("\0").filter((file) => file !== "" && !ignored(file, packages));
1660
+ ])).split("\0").filter((file) => file !== "" && !excluded(file));
1461
1661
  const rangeBlobs = await readBlobs(root, manifestObjects(candidates, from, release.ref));
1462
1662
  const changed = new Set(candidates.filter((file) => !isManifest(file) || !isVersionOnlyEdit(rangeBlobs.get(`${from}:${file}`), rangeBlobs.get(`${release.ref}:${file}`))));
1463
1663
  if (changed.size === 0) return [];
1464
1664
  const sourceCommits = (await commitsInRange(root, range, paths)).map((item) => ({
1465
1665
  ...item,
1466
- files: item.files.filter((file) => !ignored(file, packages))
1666
+ files: item.files.filter((file) => !excluded(file) && !likelyGenerated(file, packages))
1467
1667
  }));
1468
1668
  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 }) => ({
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({
1470
1675
  id,
1471
1676
  kind: "commit",
1472
1677
  text: `${message}\nFiles: ${files.join(", ")}`
1473
- }));
1678
+ });
1474
1679
  if (release.pending === true) {
1475
1680
  const file = path.posix.join(pkg.directory, "package.json");
1476
1681
  const diff = await git(root, [
@@ -1487,11 +1692,6 @@ async function collectEvidence(repository, pkg, release, excludeDirectories = []
1487
1692
  text: diff
1488
1693
  });
1489
1694
  }
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
1695
  return evidence;
1496
1696
  }
1497
1697
  function githubUnavailable(error) {
@@ -1628,7 +1828,7 @@ function releaseBatches(requests, limit) {
1628
1828
  for (const release of requests) {
1629
1829
  const minimum = {
1630
1830
  ...release,
1631
- evidence: release.evidence.filter((item) => item.kind === "range")
1831
+ evidence: release.evidence.filter((item) => required(item))
1632
1832
  };
1633
1833
  const length = JSON.stringify(minimum).length + 1;
1634
1834
  if (size + length > limit && batch.length > 0) {
@@ -1644,7 +1844,7 @@ function releaseBatches(requests, limit) {
1644
1844
  const allowance = Math.max(0, Math.floor((limit - JSON.stringify(items).length) / items.length));
1645
1845
  const seeded = items.map((item) => ({
1646
1846
  ...item,
1647
- 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)]
1648
1848
  }));
1649
1849
  let remaining = limit - JSON.stringify(seeded).length;
1650
1850
  for (const item of seeded) {
@@ -1657,6 +1857,10 @@ function releaseBatches(requests, limit) {
1657
1857
  return seeded;
1658
1858
  });
1659
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
+ }
1660
1864
  /** Bound the starting context; adapters can read the complete range themselves. */
1661
1865
  function researchEvidence(evidence, limit) {
1662
1866
  const seed = [];
@@ -1671,19 +1875,32 @@ function researchEvidence(evidence, limit) {
1671
1875
  }
1672
1876
 
1673
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
+ }
1674
1888
  /**
1675
1889
  * Prepare one release without launching a model, so repository research can be
1676
1890
  * shared.
1677
1891
  */
1678
- async function prepareRelease(context, pkg, release, existingEntries, mode, from, onlyRepositoryFiles = false) {
1892
+ async function prepareRelease(context, pkg, release, details) {
1679
1893
  const { options, repository } = context;
1894
+ const { existingEntries = "", from, mode, onlyRepositoryFiles = false, styleReference } = details;
1680
1895
  options.signal?.throwIfAborted();
1681
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);
1682
1902
  return {
1683
- evidence: await collectEvidence(repository, pkg, from === void 0 ? release : {
1684
- ...release,
1685
- previousRef: from
1686
- }, excludeDirectories),
1903
+ evidence,
1687
1904
  existingEntries,
1688
1905
  id: `${pkg.directory}@${release.version}`,
1689
1906
  initialRelease: release.previousRef === void 0 && from === void 0,
@@ -1695,7 +1912,8 @@ async function prepareRelease(context, pkg, release, existingEntries, mode, from
1695
1912
  from: from ?? release.previousRef,
1696
1913
  to: release.ref
1697
1914
  },
1698
- scope: pkg.directory === "." && repository.packages.length > 0 && options.rootChangelog === "summary" ? "repository" : "package",
1915
+ scope: summarizesPackages(context, pkg) ? "repository" : "package",
1916
+ styleReference,
1699
1917
  version: release.version
1700
1918
  };
1701
1919
  }
@@ -1761,9 +1979,11 @@ function progressMessage(pending, sessions) {
1761
1979
  }
1762
1980
  /**
1763
1981
  * Run one agent session. A long session is worth saving, so invalid output gets
1764
- * 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.
1765
1985
  */
1766
- async function runSession(context, releases) {
1986
+ async function runSession(context, releases, untrimmed) {
1767
1987
  const { agent, options, repository } = context;
1768
1988
  const request = {
1769
1989
  releases,
@@ -1776,24 +1996,24 @@ async function runSession(context, releases) {
1776
1996
  }
1777
1997
  };
1778
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);
1779
2000
  const started = performance.now();
1780
2001
  const response = await agent.generate(request);
1781
2002
  log.debug(`The ${agent.name} session finished in ${Math.round(performance.now() - started)} ms.`);
1782
2003
  try {
1783
- return validateResponse(response, releases);
2004
+ return await accept(response);
1784
2005
  } catch (error) {
1785
2006
  const message = error instanceof Error ? error.message : String(error);
1786
2007
  log.warn(`Requesting a correction of invalid agent output. ${message}`);
1787
2008
  options.onProgress?.("Requesting a correction of invalid agent output.");
1788
2009
  options.signal?.throwIfAborted();
1789
- const corrected = await agent.generate({
2010
+ return accept(await agent.generate({
1790
2011
  ...request,
1791
2012
  repair: {
1792
2013
  error: message,
1793
2014
  response
1794
2015
  }
1795
- });
1796
- return validateResponse(corrected, releases);
2016
+ }));
1797
2017
  }
1798
2018
  }
1799
2019
  /**
@@ -1826,7 +2046,7 @@ async function generateReleases(context, requests) {
1826
2046
  options.onProgress?.(status);
1827
2047
  for (const releases of batches) {
1828
2048
  options.signal?.throwIfAborted();
1829
- const changeSets = await runSession(context, releases);
2049
+ const changeSets = await runSession(context, releases, releases.map(({ id }) => unabridged.get(id)));
1830
2050
  for (const { changes, id } of changeSets) {
1831
2051
  results.set(id, changes);
1832
2052
  await saveChanges(context, unabridged.get(id), changes);
@@ -1839,8 +2059,9 @@ async function generateReleases(context, requests) {
1839
2059
  const checkpointSchema = z.object({
1840
2060
  baseRef: z.string(),
1841
2061
  contentHash: z.string(),
2062
+ generated: z.boolean(),
1842
2063
  head: z.string(),
1843
- instructionVersion: z.literal(instructionVersion),
2064
+ instructionVersion: z.string(),
1844
2065
  path: z.string()
1845
2066
  });
1846
2067
  const HEADING_PREFIX = /^#{1,3}\s+/v;
@@ -1849,27 +2070,43 @@ const ANCHOR_PUNCTUATION = /[^\p{L}\p{N}_\s\-]/gv;
1849
2070
  const ANCHOR_SPACE = /\s/gv;
1850
2071
  /**
1851
2072
  * Remember what this run covered, so the next one can research only newer
1852
- * commits.
2073
+ * commits, and whether the draft it leaves is entirely generated.
1853
2074
  */
1854
- function recordCheckpoint(context, file, content, release) {
2075
+ function recordCheckpoint(context, file, content, release, generated) {
1855
2076
  if (release === void 0 || !context.options.cache) return;
1856
2077
  context.plan.checkpoints.push({
1857
2078
  baseRef: release.previousRef ?? "initial",
1858
2079
  contentHash: changelogHash(content),
2080
+ generated,
1859
2081
  head: context.repository.head,
1860
2082
  instructionVersion,
1861
2083
  path: file
1862
2084
  });
1863
2085
  }
1864
2086
  /**
1865
- * Find the commit a changelog already covers, when its checkpoint is still
1866
- * valid.
2087
+ * Read what the last run recorded about a changelog, when the file still reads
2088
+ * as that run left it.
1867
2089
  */
1868
- async function incrementalBase(context, file, content, release) {
2090
+ async function readCheckpoint(context, file, content) {
1869
2091
  if (content === void 0 || !context.options.cache) return;
1870
2092
  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;
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);
1873
2110
  }
1874
2111
  /** Select the releases newer than anything the changelog already records. */
1875
2112
  function missingReleases(releases, content = "") {
@@ -1886,8 +2123,28 @@ function missingReleases(releases, content = "") {
1886
2123
  function releaseContext(options, pkg) {
1887
2124
  return pkg.directory === options.releaseDirectory ? options.release : void 0;
1888
2125
  }
1889
- /** Resolve a package's releases and prepare requests for those it still needs. */
1890
- 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) {
1891
2148
  const { repository } = context;
1892
2149
  const file = await findChangelog(path.join(repository.root, pkg.directory));
1893
2150
  const content = context.options.rebuild ? void 0 : file.content;
@@ -1895,15 +2152,17 @@ async function preparePackage(context, pkg, aggregateHistory = false, recordedAt
1895
2152
  const releases = await resolveReleases(repository, pkg, context.options, releaseContext(context.options, pkg));
1896
2153
  const missing = missingReleases(releases, content);
1897
2154
  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");
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);
1899
2158
  const newest = releases.findLast((release) => release.version !== "Unreleased");
1900
2159
  const draftPromoted = draft !== void 0 && newest !== void 0 && missing.includes(newest);
1901
2160
  const work = [];
1902
2161
  const summaries = [];
1903
2162
  for (const release of releases) {
1904
2163
  const shouldWrite = missing.includes(release);
1905
- if (!shouldWrite && !aggregateHistory) continue;
1906
- const summary = {
2164
+ if (!shouldWrite && !allSummaries) continue;
2165
+ summaries.push({
1907
2166
  changelogPath: file.path,
1908
2167
  changes: [],
1909
2168
  date: release.date,
@@ -1912,41 +2171,49 @@ async function preparePackage(context, pkg, aggregateHistory = false, recordedAt
1912
2171
  ref: release.ref,
1913
2172
  tag: release.tag,
1914
2173
  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
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
+ }));
1927
2183
  }
2184
+ const exception = await hasNodeException(repository, pkg, releases);
1928
2185
  return {
1929
2186
  content,
1930
- exception: await hasNodeException(repository, pkg, releases),
2187
+ draftGenerated: checkpoint?.generated === true,
2188
+ exception,
1931
2189
  file,
2190
+ pkg,
1932
2191
  releases,
1933
2192
  summaries,
1934
2193
  work
1935
2194
  };
1936
2195
  }
1937
- /** Write a package's edit into the plan and complete its summaries. */
1938
- 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) {
1939
2202
  const { plan, repository } = context;
1940
2203
  const { content, exception, file, releases, summaries, work } = prepared;
1941
2204
  const entries = [];
1942
2205
  const changesByVersion = /* @__PURE__ */ new Map();
1943
- for (const { existingBody, release, request, shouldWrite } of work) {
2206
+ for (const { existingBody, release, request } of work) {
1944
2207
  const changes = results.get(request.id) ?? [];
2208
+ const links = linksFor?.(release);
2209
+ const revise = request.mode === "revise" && results.has(request.id);
1945
2210
  changesByVersion.set(release.version, changes);
1946
- 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({
1947
2212
  changes,
1948
2213
  existingBody,
1949
- release
2214
+ links,
2215
+ release,
2216
+ revise
1950
2217
  });
1951
2218
  }
1952
2219
  const after = updateMarkdown(content, entries, exception, repository.github);
@@ -1955,7 +2222,7 @@ function renderPackage(context, prepared, results) {
1955
2222
  before: file.content,
1956
2223
  path: file.path
1957
2224
  });
1958
- recordCheckpoint(context, file.path, after, releases.at(-1));
2225
+ recordCheckpoint(context, file.path, after, releases.at(-1), leavesGeneratedDraft(entries, prepared.draftGenerated));
1959
2226
  const { sections } = parseChangelog(after);
1960
2227
  return summaries.map((summary) => ({
1961
2228
  ...summary,
@@ -1984,74 +2251,40 @@ function missingPackageSummaries(summaries, content = "") {
1984
2251
  }
1985
2252
 
1986
2253
  // src/lib/workspace.ts
2254
+ /** Link to a package's release section, when its changelog has one. */
1987
2255
  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) {
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) {
2049
2268
  const { repository } = context;
2050
2269
  const file = await findChangelog(repository.root);
2051
2270
  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));
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));
2055
2288
  const release = {
2056
2289
  previousRef: (await git(repository.root, [
2057
2290
  "rev-list",
@@ -2061,44 +2294,54 @@ async function prepareIndependent(context, summaries) {
2061
2294
  ref: repository.head,
2062
2295
  version: "Unreleased"
2063
2296
  };
2064
- const draft = parseChangelog(remaining ?? "").sections.find((section) => section.version === "Unreleased")?.body;
2065
- 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;
2066
2301
  return {
2067
2302
  content,
2303
+ draft,
2304
+ draftGenerated,
2068
2305
  file,
2069
- release,
2070
- 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
+ }
2071
2317
  };
2072
2318
  }
2073
- 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) {
2074
2324
  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,
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),
2098
2329
  release: {
2099
- ref: repository.head,
2100
- version: "Unreleased"
2330
+ date: summary.date,
2331
+ ref: summary.ref,
2332
+ tag: summary.tag,
2333
+ version: `${summary.packageName}@${summary.version}`
2101
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
2102
2345
  });
2103
2346
  const after = updateMarkdown(content, entries, exception, repository.github);
2104
2347
  if (after !== file.content) plan.files.push({
@@ -2106,7 +2349,7 @@ function aggregateIndependent(context, prepared, results, summaries, exception)
2106
2349
  before: file.content,
2107
2350
  path: file.path
2108
2351
  });
2109
- recordCheckpoint(context, file.path, after, prepared.release);
2352
+ recordCheckpoint(context, file.path, after, unreleased.release, leavesGeneratedDraft(entries, prepared.draftGenerated));
2110
2353
  }
2111
2354
  /**
2112
2355
  * Find package releases whose own section records no notable changes, so a
@@ -2120,18 +2363,6 @@ function quietReleases(plan, packages) {
2120
2363
  }
2121
2364
  return quiet;
2122
2365
  }
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
2366
  /** Generate package changelogs and the root changelog that summarizes them. */
2136
2367
  async function generateWorkspace(context) {
2137
2368
  const { options, plan, repository } = context;
@@ -2139,21 +2370,24 @@ async function generateWorkspace(context) {
2139
2370
  const shared = rootReleases.some((release) => release.version !== "Unreleased");
2140
2371
  const rootFile = await findChangelog(repository.root);
2141
2372
  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);
2373
+ const linksHistory = !shared || missingReleases(rootReleases, rootContent).some((release) => release.version !== "Unreleased");
2145
2374
  const packages = [];
2146
- for (const pkg of describedPackages(repository)) packages.push(await preparePackage(context, pkg, needsPackageHistory, recordedAtRoot));
2375
+ for (const pkg of describedPackages(repository)) packages.push(await preparePackage(context, pkg, linksHistory));
2147
2376
  const root = shared ? await preparePackage(context, repository.rootPackage) : void 0;
2148
- const independent = shared ? void 0 : await prepareIndependent(context, packages.flatMap((pkg) => pkg.summaries));
2377
+ const independent = shared ? void 0 : await prepareIndependent(context, packages);
2149
2378
  const requests = [...packages, ...root === void 0 ? [] : [root]].flatMap((pkg) => pkg.work.map((item) => item.request));
2150
- if (independent !== void 0) requests.push(independent.request);
2379
+ if (independent !== void 0) requests.push(...independent.releases, independent.unreleased.request);
2151
2380
  const results = await generateReleases(context, requests);
2152
2381
  const summaries = packages.flatMap((pkg) => renderPackage(context, pkg, results));
2153
2382
  plan.releases.push(...summaries);
2154
2383
  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);
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);
2157
2391
  }
2158
2392
 
2159
2393
  // src/lib/generate.ts
@@ -2379,4 +2613,4 @@ async function reviewChangelog(plan, options = {}) {
2379
2613
  }
2380
2614
  }
2381
2615
 
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 };
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 };