changeledger 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.
package/AGENTS.md CHANGED
@@ -5,15 +5,21 @@ under `.changeledger/changes/`, persistent truth under `.changeledger/specs/`.
5
5
 
6
6
  <!-- changeledger -->
7
7
  > [!IMPORTANT]
8
- > This repo uses **ChangeLedger**. Read and follow [`.changeledger/AGENTS.md`](.changeledger/AGENTS.md)
9
- > (the change contract). If it is missing, run `changeledger register`.
8
+ > This repo uses **ChangeLedger**. Before creating or modifying files, run
9
+ > `changeledger context` (or `changeledger context <change-id>`) and follow its output.
10
+ > If the command is unavailable, stop and restore/install ChangeLedger; do not proceed from memory.
10
11
 
11
- The **canonical ChangeLedger contract** (the convention every consuming repo
12
- follows) is shipped as [`templates/AGENTS.md`](templates/AGENTS.md) and linked
13
- into each repo as `.changeledger/AGENTS.md`. Edit the convention there, not here.
12
+ The canonical ChangeLedger contract is split into task-focused fragments under
13
+ [`templates/contract/`](templates/contract/). The deterministic
14
+ `changeledger context` command composes them; consuming repos keep only the
15
+ bootstrap above, not a linked or copied contract.
14
16
 
15
17
  ## Project-specific notes
16
18
 
19
+ - Product evolution follows the complexity budget and no-goals in
20
+ [`INTENT.md`](INTENT.md): prefer observed problems and a small deterministic,
21
+ local-first core; AI orchestration, memory and cloud services belong only in
22
+ optional integrations.
17
23
  - Managed with **pnpm**; lint/format via **Biome**. Runtime dependencies are
18
24
  allowed only when they are mature and justified: the CLI uses `yaml` for
19
25
  config/frontmatter parsing, and the viewer uses `lit-html`, `marked`,
@@ -21,5 +27,5 @@ into each repo as `.changeledger/AGENTS.md`. Edit the convention there, not here
21
27
  - `pnpm verify` (lint + test + `changeledger check`) is the full quality gate. The
22
28
  versioned `hooks/pre-commit` runs `lint-staged`, `pnpm test` and `changeledger check`
23
29
  so staged formatting stays compatible with partial commits.
24
- - The contract you edit for the convention is `templates/AGENTS.md` it is the
25
- artifact that `changeledger init`/`register` symlink into other repos.
30
+ - Edit the convention in `templates/contract/`; keep each rule in one fragment
31
+ so task contexts compose without duplicated truth.
package/README.md CHANGED
@@ -27,9 +27,10 @@ changeledger init
27
27
  changeledger view
28
28
  ```
29
29
 
30
- `changeledger init` creates `.changeledger/`, gives the project a stable identity and links the
31
- installed agent contract. The repository keeps only its configuration and
32
- documents; the CLI and viewer remain in the global package.
30
+ `changeledger init` creates `.changeledger/`, gives the project a stable identity
31
+ and installs a small context bootstrap in the project-owned `AGENTS.md`. The
32
+ repository keeps only its configuration and documents; the CLI, viewer and
33
+ canonical contract fragments remain in the global package.
33
34
 
34
35
  ## The workflow
35
36
 
@@ -62,8 +63,8 @@ other tools discover it through the repository's `AGENTS.md` reference.
62
63
 
63
64
  With the default Definition of Ready policy, `changeledger check` verifies that acceptance
64
65
  criteria are test-grade and mapped to actionable tasks. Repositories doing
65
- exploratory work can set `tdd: false`; the complete rules live in
66
- [`AGENTS.md`](AGENTS.md).
66
+ exploratory work can set `tdd: false`; run `changeledger context spec` or
67
+ `changeledger context implement` for the complete task-specific rules.
67
68
 
68
69
  ## Essential commands
69
70
 
@@ -71,7 +72,7 @@ exploratory work can set `tdd: false`; the complete rules live in
71
72
 
72
73
  ```sh
73
74
  changeledger init # initialize and register the current repository
74
- changeledger register # relink a moved or freshly cloned repository
75
+ changeledger register # refresh registration and the context bootstrap
75
76
  changeledger view # view every registered project
76
77
  changeledger view . # view only the current project
77
78
  changeledger check [id] # validate the repository or one change
@@ -128,17 +129,38 @@ CI:
128
129
  changeledger check || exit 1
129
130
  ```
130
131
 
131
- The contract itself ships with the CLI and is linked as `.changeledger/AGENTS.md`. It is
132
- per-machine and gitignored, so upgrades do not leave committed copies behind.
133
- Run `changeledger register` after cloning, moving a repository or updating the global
134
- installation.
132
+ The contract ships as task-focused fragments and is compiled on demand:
133
+
134
+ ```sh
135
+ changeledger context # minimal non-negotiable core
136
+ changeledger context <change-id> # lifecycle-aware rules + selected change
137
+ changeledger context review # explicit task mode
138
+ ```
139
+
140
+ `init` places a small fail-closed bootstrap in the project-owned `AGENTS.md`;
141
+ there is no linked or copied contract under `.changeledger/`. Run
142
+ `changeledger register` after upgrading to refresh that bootstrap.
143
+
144
+ ### Upgrading an existing repo's configuration
145
+
146
+ Repos created before ChangeLedger 0.6 may have an older configuration schema.
147
+ Run this to inspect and apply available migrations:
148
+
149
+ ```sh
150
+ changeledger config migrate --dry-run # preview changes without writing
151
+ changeledger config migrate # apply atomically
152
+ changeledger check # confirm the repo is valid
153
+ ```
154
+
155
+ Migrations are safe to run more than once — if the config is already current,
156
+ the command reports so and exits without modifying any file.
135
157
 
136
158
  ## Compatibility and security
137
159
 
138
160
  - Node.js **24+**.
139
161
  - Tested on Linux, macOS and Windows.
140
- - On Windows, contract linking uses a symlink when permitted and falls back to a
141
- copy when Developer Mode or administrator privileges are unavailable.
162
+ - No symlink privileges are required on Windows; context fragments remain in the
163
+ installed package and the CLI prints them on demand.
142
164
  - The viewer binds to loopback and treats repository content as untrusted input.
143
165
  See [`SECURITY.md`](SECURITY.md) for the threat model and private reporting
144
166
  instructions.
@@ -151,7 +173,7 @@ latest `0.x` release to receive fixes.
151
173
 
152
174
  Contributions are welcome. Development setup, quality gates and repository
153
175
  conventions live in [`CONTRIBUTING.md`](CONTRIBUTING.md). The canonical agent
154
- contract is [`AGENTS.md`](AGENTS.md).
176
+ contract is composed from [`templates/contract/`](templates/contract/).
155
177
 
156
178
  ## Language policy
157
179
 
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
2
3
  import { Command } from 'commander';
3
4
  import {
4
5
  archive,
@@ -13,21 +14,27 @@ import {
13
14
  task,
14
15
  } from '../src/commands/agent.mjs';
15
16
  import { check } from '../src/commands/check.mjs';
17
+ import { context } from '../src/commands/context.mjs';
16
18
  import { graduate, pendingGraduation, skipGraduation } from '../src/commands/graduate.mjs';
17
19
  import { init } from '../src/commands/init.mjs';
18
20
  import { newChange } from '../src/commands/new.mjs';
19
21
  import { registerRepo } from '../src/commands/register.mjs';
20
22
  import { initReleaseHistory, recordRelease, releasePlan } from '../src/commands/release.mjs';
21
23
  import { view } from '../src/commands/view.mjs';
24
+ import { findChangeledgerDir } from '../src/config.mjs';
25
+ import { applyMigration, SUPPORTED_SCHEMA_VERSION } from '../src/config-migration.mjs';
22
26
  import { nowUtc } from '../src/paths.mjs';
23
27
 
28
+ const { version } = createRequire(import.meta.url)('../package.json');
29
+
24
30
  const USAGE = `ChangeLedger (changeledger)
25
31
 
26
32
  changeledger init set up .changeledger/ in the current repo (+ register it)
27
- changeledger register (re)link this repo's path in the global registry
33
+ changeledger register refresh registration and context bootstrap
28
34
  changeledger new <type> <slug> <title> scaffold a new change (slug is the English filename)
29
35
  changeledger view [port] launch the local viewer (default port 4040)
30
36
  changeledger check [id] [--json] validate the repo or one change
37
+ changeledger context [mode|change-id] print deterministic task context
31
38
  changeledger status <id> <status> move a change's lifecycle status
32
39
  changeledger discard <id> "<reason>" discard a change (terminal; keeps the record)
33
40
  changeledger review <id> pass independent review passed → in-validation
@@ -43,6 +50,7 @@ const USAGE = `ChangeLedger (changeledger)
43
50
  changeledger graduate <change-id> <spec-slug> --into graduate into an existing spec
44
51
  changeledger graduate <change-id> --skip [reason] mark graduation reviewed, no spec
45
52
  changeledger graduate --pending list done changes not yet reviewed
53
+ changeledger config migrate [--dry-run] migrate .changeledger/config.yml to schema ${SUPPORTED_SCHEMA_VERSION}
46
54
  changeledger release init <version> initialize release history at X.Y.Z
47
55
  changeledger release plan [--json] calculate the next portable SemVer release
48
56
  changeledger release record <version> record the calculated release manifest`;
@@ -63,6 +71,7 @@ function action(fn) {
63
71
  program
64
72
  .name('changeledger')
65
73
  .description('ChangeLedger (changeledger)')
74
+ .version(version, '-v, --version', 'output the installed version (-V also accepted)')
66
75
  .helpOption('-h, --help', 'display help for command')
67
76
  .addHelpText('after', `\n${USAGE}`);
68
77
 
@@ -78,7 +87,7 @@ program
78
87
 
79
88
  program
80
89
  .command('register')
81
- .description("(re)link this repo's path in the global registry")
90
+ .description('refresh registration and context bootstrap')
82
91
  .action(
83
92
  action(() => {
84
93
  const { id, path: p } = registerRepo();
@@ -122,6 +131,12 @@ program
122
131
  }
123
132
  });
124
133
 
134
+ program
135
+ .command('context')
136
+ .description('print deterministic task context')
137
+ .argument('[mode-or-change-id]')
138
+ .action(action((input) => context(input)));
139
+
125
140
  program
126
141
  .command('status')
127
142
  .description("move a change's lifecycle status")
@@ -318,6 +333,24 @@ program
318
333
  }),
319
334
  );
320
335
 
336
+ const configCommand = program
337
+ .command('config')
338
+ .description('inspect and manage the repo configuration');
339
+
340
+ configCommand
341
+ .command('migrate')
342
+ .description('migrate .changeledger/config.yml to the current schema')
343
+ .option('--dry-run', 'show the migration plan and candidate YAML without writing')
344
+ .action(
345
+ action((options) => {
346
+ const changeledgerDir = findChangeledgerDir();
347
+ if (!changeledgerDir) throw new Error('Not a ChangeLedger repo.');
348
+ const configFile = `${changeledgerDir}/config.yml`;
349
+ const result = applyMigration(configFile, { dryRun: options.dryRun ?? false });
350
+ console.log(result);
351
+ }),
352
+ );
353
+
321
354
  const releaseCommand = program
322
355
  .command('release')
323
356
  .description('plan and record portable SemVer releases');
@@ -368,8 +401,10 @@ releaseCommand
368
401
  }),
369
402
  );
370
403
 
371
- if (process.argv.length <= 2) {
404
+ // Normalize -V to --version so both short aliases work identically.
405
+ const argv = process.argv.map((a) => (a === '-V' ? '--version' : a));
406
+ if (argv.length <= 2) {
372
407
  console.log(USAGE);
373
408
  } else {
374
- program.parse();
409
+ program.parse(argv);
375
410
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changeledger",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Turn conversations into buildable changes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,7 +59,7 @@ export function status(
59
59
  }
60
60
 
61
61
  // Records the verdict of the independent review (run by a delegated subagent
62
- // with clean context — see AGENTS.md §6). `pass` advances to human validation;
62
+ // with clean context — see `changeledger context review`). `pass` advances to human validation;
63
63
  // `fail` routes it back: `retry` for a defect inside the contract (the
64
64
  // implementer fixes), `block` for one that escalates to a human. Requires the
65
65
  // change to be in-review.
@@ -1,4 +1,5 @@
1
1
  import { checkRepo } from '../check.mjs';
2
+ import { getSchemaVersion, SUPPORTED_SCHEMA_VERSION } from '../config-migration.mjs';
2
3
  import { checkContract } from '../contract.mjs';
3
4
  import { loadRepo } from '../repo.mjs';
4
5
 
@@ -22,7 +23,16 @@ export function check(args = [], cwd = process.cwd(), output = console) {
22
23
 
23
24
  const { errors, warnings } = checkRepo(repo, { id });
24
25
 
25
- // Discovery validation needs the filesystem (root contract, symlink), so it
26
+ // Schema version detection warn without mutating.
27
+ const schemaVersion = getSchemaVersion(repo.config);
28
+ if (!id && schemaVersion < SUPPORTED_SCHEMA_VERSION) {
29
+ warnings.push({
30
+ file: '.changeledger/config.yml',
31
+ message: `config schema ${schemaVersion} is outdated; run \`changeledger config migrate --dry-run\``,
32
+ });
33
+ }
34
+
35
+ // Discovery validation needs the filesystem (root contract bootstrap), so it
26
36
  // lives here, not in the pure validator. Repo-wide only.
27
37
  if (!id) {
28
38
  for (const message of checkContract(repo.repoRoot, repo.changeledgerDir)) {
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseChange } from '../change.mjs';
4
+ import { findChangeledgerDir } from '../config.mjs';
5
+ import { contractTemplatesDir } from '../paths.mjs';
6
+ import { resolveChange } from '../repo.mjs';
7
+
8
+ const MODES = ['implement', 'review', 'spec', 'release'];
9
+ const MODE_CONTEXT = {
10
+ implement: ['core', 'implement', 'readiness'],
11
+ review: ['core', 'review'],
12
+ spec: ['core', 'spec', 'readiness'],
13
+ release: ['core', 'release'],
14
+ };
15
+ const STATUS_CONTEXT = {
16
+ draft: { mode: 'spec', fragments: MODE_CONTEXT.spec },
17
+ approved: { mode: 'implement', fragments: MODE_CONTEXT.implement },
18
+ 'in-progress': { mode: 'implement', fragments: MODE_CONTEXT.implement },
19
+ 'in-review': { mode: 'review', fragments: ['core', 'review'] },
20
+ blocked: { mode: 'blocked', fragments: ['core', 'blocked'] },
21
+ 'in-validation': { mode: 'validation', fragments: ['core', 'validation'] },
22
+ done: { mode: 'close', fragments: ['core', 'close'] },
23
+ discarded: { mode: 'discarded', fragments: ['core', 'discarded'] },
24
+ };
25
+
26
+ function fragment(name) {
27
+ return fs.readFileSync(path.join(contractTemplatesDir, `${name}.md`), 'utf8').trim();
28
+ }
29
+
30
+ function compose(mode, fragments, changeText) {
31
+ const sections = [`Mode: ${mode}`, ...fragments.map(fragment)];
32
+ if (changeText) sections.push('---\n\n# Selected change\n', changeText.trim());
33
+ return `${sections.join('\n\n')}\n`;
34
+ }
35
+
36
+ function requireRepo(cwd) {
37
+ if (!findChangeledgerDir(cwd)) {
38
+ throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
39
+ }
40
+ }
41
+
42
+ export function buildContext(input, cwd = process.cwd()) {
43
+ requireRepo(cwd);
44
+ if (!input) return compose('core', ['core']);
45
+ if (MODES.includes(input)) return compose(input, MODE_CONTEXT[input]);
46
+
47
+ let resolved;
48
+ try {
49
+ resolved = resolveChange(cwd, input);
50
+ } catch {
51
+ throw new Error(
52
+ `Unknown context "${input}" — valid modes: ${MODES.join(', ')} (or pass a change id)`,
53
+ );
54
+ }
55
+
56
+ const text = fs.readFileSync(resolved.file, 'utf8');
57
+ const status = parseChange(text).frontmatter.status;
58
+ const selected = STATUS_CONTEXT[status];
59
+ if (!selected) throw new Error(`No context mapping for change status "${status}"`);
60
+ return compose(selected.mode, selected.fragments, text);
61
+ }
62
+
63
+ export function context(input, cwd = process.cwd(), output = console.log) {
64
+ output(buildContext(input, cwd).trimEnd());
65
+ }
@@ -2,20 +2,19 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { writeFileAtomic } from '../atomic-write.mjs';
5
- import { ensureGitignore, ensureReference, linkContract, rootContract } from '../contract.mjs';
5
+ import { ensureReference, rootContract } from '../contract.mjs';
6
6
  import { templatesDir } from '../paths.mjs';
7
7
  import { register } from '../registry.mjs';
8
8
  import { serializeScalar } from '../yaml.mjs';
9
9
 
10
- // Sets up `.changeledger/` in the repo, gives it a stable identity, links the installed
11
- // AGENTS.md contract into `.changeledger/`, references it from the project's root
10
+ // Sets up `.changeledger/`, installs the context bootstrap in the project-owned
12
11
  // AGENTS.md, and registers the repo in the global registry.
13
12
  export function init(cwd = process.cwd()) {
14
13
  const repoRoot = path.resolve(cwd);
15
14
  const changeledgerDir = path.join(repoRoot, '.changeledger');
16
15
  if (fs.existsSync(changeledgerDir)) {
17
16
  throw new Error(
18
- '.changeledger/ already exists. Use `changeledger register` to (re)link this repo.',
17
+ '.changeledger/ already exists. Use `changeledger register` to refresh this repo.',
19
18
  );
20
19
  }
21
20
  // The root AGENTS.md is the project's own contract; we reference the tool's
@@ -34,9 +33,7 @@ export function init(cwd = process.cwd()) {
34
33
  `${fs.readFileSync(path.join(templatesDir, 'config.yml'), 'utf8')}\n# Project identity (stable; the global registry maps it to a path)\nproject_id: "${id}"\nproject_name: ${serializeScalar(name)}\n`,
35
34
  );
36
35
 
37
- linkContract(changeledgerDir);
38
36
  ensureReference(repoRoot);
39
- ensureGitignore(repoRoot);
40
37
 
41
38
  register({ id, name, path: repoRoot });
42
39
  return changeledgerDir;
@@ -11,7 +11,7 @@ const LOCK_MTIME_STALE_MS = 30_000;
11
11
 
12
12
  // Scaffolds a new change file with the active stages for its type.
13
13
  // `slug` is the English filename slug (structure); `title` is the content title
14
- // (repo language). See AGENTS.md §7-§8.
14
+ // (repo language). See `changeledger context spec`.
15
15
  export function newChange({ type, slug, title, owner, now }, cwd = process.cwd()) {
16
16
  const changeledgerDir = findChangeledgerDir(cwd);
17
17
  if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
@@ -1,13 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { findChangeledgerDir, loadConfig } from '../config.mjs';
4
- import { ensureGitignore, ensureReference, linkContract, rootContract } from '../contract.mjs';
4
+ import { getSchemaVersion, SUPPORTED_SCHEMA_VERSION } from '../config-migration.mjs';
5
+ import {
6
+ ensureReference,
7
+ removeLegacyContract,
8
+ removeLegacyGitignore,
9
+ rootContract,
10
+ } from '../contract.mjs';
5
11
  import { register } from '../registry.mjs';
6
12
 
7
- // (Re)links the current repo's path to its config project_id in the global
8
- // registry, and regenerates the per-machine `.changeledger/AGENTS.md` contract link. Use
9
- // after moving the repo or cloning it on another machine.
10
- export function registerRepo(cwd = process.cwd()) {
13
+ // Refreshes the repo bootstrap and registry path. Also migrates the per-machine
14
+ // contract artifact left by legacy versions.
15
+ export function registerRepo(cwd = process.cwd(), output = console) {
11
16
  const changeledgerDir = findChangeledgerDir(cwd);
12
17
  if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
13
18
 
@@ -16,11 +21,18 @@ export function registerRepo(cwd = process.cwd()) {
16
21
  throw new Error('config.yml has no project_id. Run `changeledger init` to create one.');
17
22
  }
18
23
 
24
+ const schemaVersion = getSchemaVersion(config);
25
+ if (schemaVersion < SUPPORTED_SCHEMA_VERSION) {
26
+ output.warn(
27
+ `warn .changeledger/config.yml: config schema ${schemaVersion} is outdated; run \`changeledger config migrate --dry-run\``,
28
+ );
29
+ }
30
+
19
31
  const repoRoot = path.dirname(changeledgerDir);
20
32
  const name = config.project_name || path.basename(repoRoot);
21
33
 
22
- linkContract(changeledgerDir);
23
- ensureGitignore(repoRoot);
34
+ removeLegacyContract(changeledgerDir);
35
+ removeLegacyGitignore(repoRoot);
24
36
  if (fs.existsSync(rootContract(repoRoot))) ensureReference(repoRoot);
25
37
 
26
38
  register({ id: config.project_id, name, path: repoRoot });
@@ -6,8 +6,12 @@ import { createRequestListener, staticFile } from '../viewer/server/router.mjs';
6
6
  import { hostnameOf, isAuthorizedWrite, isLocalHost } from '../viewer/server/security.mjs';
7
7
 
8
8
  export {
9
+ applyConfigMigration,
9
10
  changeStatus,
11
+ patchProjectConfig,
12
+ previewConfigMigration,
10
13
  readProjectConfig,
14
+ readProjectConfigStructured,
11
15
  repairProjectPath,
12
16
  resolveProjects,
13
17
  saveProjectConfig,
@@ -0,0 +1,213 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseDocument } from 'yaml';
4
+ import { writeFileAtomic } from './atomic-write.mjs';
5
+ import { templatesDir } from './paths.mjs';
6
+
7
+ export const SUPPORTED_SCHEMA_VERSION = 1;
8
+
9
+ const CANONICAL_STATUSES = [
10
+ 'draft',
11
+ 'approved',
12
+ 'in-progress',
13
+ 'in-review',
14
+ 'in-validation',
15
+ 'blocked',
16
+ 'done',
17
+ 'discarded',
18
+ ];
19
+
20
+ const BUILTIN_TYPES = ['feature', 'bug', 'refactor'];
21
+
22
+ const BUILTIN_IMPACTS = {
23
+ feature: 'minor',
24
+ bug: 'patch',
25
+ audit: 'none',
26
+ refactor: 'none',
27
+ chore: 'none',
28
+ };
29
+
30
+ export function getSchemaVersion(config) {
31
+ const v = config.schema_version;
32
+ return typeof v === 'number' ? v : 0;
33
+ }
34
+
35
+ // Returns null when no migration needed; throws on invalid/future schema.
36
+ // Otherwise returns { yaml: string, changes: string[] }.
37
+ export function buildMigration(originalText) {
38
+ let doc;
39
+ try {
40
+ doc = parseDocument(originalText, { merge: false });
41
+ } catch (e) {
42
+ throw new Error(`Invalid YAML: ${e.message}`);
43
+ }
44
+ if (doc.errors.length) {
45
+ throw new Error(`Invalid YAML: ${doc.errors[0].message}`);
46
+ }
47
+
48
+ const config = doc.toJS() ?? {};
49
+ const current = getSchemaVersion(config);
50
+
51
+ if (current > SUPPORTED_SCHEMA_VERSION) {
52
+ throw new Error(
53
+ `config schema ${current} is newer than supported schema ${SUPPORTED_SCHEMA_VERSION}`,
54
+ );
55
+ }
56
+ if (current === SUPPORTED_SCHEMA_VERSION) {
57
+ return null;
58
+ }
59
+
60
+ const changes = [];
61
+
62
+ // schema_version: 1 — remove any existing value, then prepend to appear first
63
+ if (Object.hasOwn(config, 'schema_version')) {
64
+ doc.delete('schema_version');
65
+ }
66
+ doc.contents.items.unshift(doc.createPair('schema_version', 1));
67
+ changes.push('added schema_version: 1');
68
+
69
+ // tdd: true if absent
70
+ if (!Object.hasOwn(config, 'tdd')) {
71
+ doc.set('tdd', true);
72
+ changes.push('added tdd: true');
73
+ }
74
+
75
+ // specs_dir if absent
76
+ if (!Object.hasOwn(config, 'specs_dir')) {
77
+ doc.set('specs_dir', '.changeledger/specs');
78
+ changes.push('added specs_dir: .changeledger/specs');
79
+ }
80
+
81
+ // Canonical statuses — insert missing ones in canonical order
82
+ const statusesNode = doc.get('statuses', true);
83
+ if (statusesNode) {
84
+ const current_statuses = statusesNode.items.map((n) => String(n.value));
85
+ for (const status of CANONICAL_STATUSES) {
86
+ if (current_statuses.includes(status)) continue;
87
+ const insertBefore = findInsertBefore(current_statuses, CANONICAL_STATUSES, status);
88
+ const newNode = doc.createNode(status);
89
+ if (insertBefore === -1) {
90
+ statusesNode.items.push(newNode);
91
+ current_statuses.push(status);
92
+ } else {
93
+ statusesNode.items.splice(insertBefore, 0, newNode);
94
+ current_statuses.splice(insertBefore, 0, status);
95
+ }
96
+ changes.push(`added status: ${status}`);
97
+ }
98
+ }
99
+
100
+ // review_required: true for built-in types where key is absent
101
+ const configTypes = config.types ?? {};
102
+ for (const typeName of BUILTIN_TYPES) {
103
+ if (!Object.hasOwn(configTypes, typeName)) continue;
104
+ if (Object.hasOwn(configTypes[typeName], 'review_required')) continue;
105
+ doc.setIn(['types', typeName, 'review_required'], true);
106
+ changes.push(`added types.${typeName}.review_required: true`);
107
+ }
108
+
109
+ // release.impacts defaults for built-in types that exist in config.types
110
+ const currentImpacts = config.release?.impacts ?? {};
111
+ for (const [type, impact] of Object.entries(BUILTIN_IMPACTS)) {
112
+ if (!Object.hasOwn(configTypes, type)) continue;
113
+ if (Object.hasOwn(currentImpacts, type)) continue;
114
+ doc.setIn(['release', 'impacts', type], impact);
115
+ changes.push(`added release.impacts.${type}: ${impact}`);
116
+ }
117
+
118
+ // Remove legacy id_digits
119
+ if (Object.hasOwn(config, 'id_digits')) {
120
+ doc.delete('id_digits');
121
+ changes.push('removed legacy id_digits');
122
+ }
123
+
124
+ // Refresh managed comments from the current template, preserving custom comments.
125
+ const templateComments = loadTemplateComments();
126
+ const commentChanges = refreshManagedComments(doc, templateComments);
127
+ changes.push(...commentChanges);
128
+
129
+ return { yaml: doc.toString(), changes };
130
+ }
131
+
132
+ // Apply migration to a file (or dry-run). Returns summary string.
133
+ export function applyMigration(configFile, { dryRun = false } = {}) {
134
+ let original;
135
+ try {
136
+ original = fs.readFileSync(configFile, 'utf8');
137
+ } catch (e) {
138
+ throw new Error(`Cannot read config: ${e.message}`);
139
+ }
140
+
141
+ const result = buildMigration(original);
142
+
143
+ if (!result) {
144
+ return `Config is already at schema ${SUPPORTED_SCHEMA_VERSION}. No changes needed.`;
145
+ }
146
+
147
+ const header = dryRun
148
+ ? `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION} (dry run)`
149
+ : `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION}`;
150
+
151
+ const summary = [header, ...result.changes.map((c) => ` - ${c}`)].join('\n');
152
+
153
+ if (!dryRun) {
154
+ writeFileAtomic(configFile, result.yaml);
155
+ return summary;
156
+ }
157
+
158
+ return `${summary}\n\n--- candidate YAML ---\n${result.yaml}`;
159
+ }
160
+
161
+ // Find the index in currentList where `status` should be inserted, based on
162
+ // canonical order. Returns -1 to append at end.
163
+ function findInsertBefore(currentList, canonicalOrder, status) {
164
+ const canonIdx = canonicalOrder.indexOf(status);
165
+ for (let i = canonIdx + 1; i < canonicalOrder.length; i++) {
166
+ const pos = currentList.indexOf(canonicalOrder[i]);
167
+ if (pos !== -1) return pos;
168
+ }
169
+ return -1;
170
+ }
171
+
172
+ // Reads the current template and returns a map of top-level key → commentBefore string.
173
+ // Returns an empty map if the template is unreadable (comment refresh is optional).
174
+ function loadTemplateComments() {
175
+ try {
176
+ const templateText = fs.readFileSync(path.join(templatesDir, 'config.yml'), 'utf8');
177
+ const templateDoc = parseDocument(templateText, { merge: false });
178
+ const comments = new Map();
179
+ for (const pair of templateDoc.contents.items) {
180
+ const key = pair.key?.value;
181
+ const comment = pair.key?.commentBefore;
182
+ if (key && comment !== undefined) {
183
+ comments.set(key, comment);
184
+ }
185
+ }
186
+ return comments;
187
+ } catch {
188
+ return new Map();
189
+ }
190
+ }
191
+
192
+ // Replace managed comments (those defined in the template) on existing keys,
193
+ // preserving comments on custom/unknown keys.
194
+ // doc.set() creates keys as plain strings (not Scalar nodes), so commentBefore
195
+ // cannot be set on them directly. Convert string keys to Scalar nodes first.
196
+ function refreshManagedComments(doc, templateComments) {
197
+ const changes = [];
198
+ for (const pair of doc.contents.items) {
199
+ // Normalise string-primitive keys to Scalar nodes so commentBefore is writable.
200
+ if (typeof pair.key === 'string') {
201
+ pair.key = doc.createNode(pair.key);
202
+ }
203
+ const key = pair.key?.value;
204
+ if (!key) continue;
205
+ if (!templateComments.has(key)) continue; // custom key — preserve its comment
206
+ const templateComment = templateComments.get(key);
207
+ const currentComment = pair.key?.commentBefore;
208
+ if (currentComment === templateComment) continue; // already matches
209
+ pair.key.commentBefore = templateComment;
210
+ changes.push(`refreshed comment for ${key}`);
211
+ }
212
+ return changes;
213
+ }