changeledger 0.6.4 → 0.8.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,12 +5,17 @@ under `.changeledger/changes/`, persistent truth under `.changeledger/specs/`.
5
5
 
6
6
  <!-- changeledger -->
7
7
  > [!IMPORTANT]
8
- > This repo uses **ChangeLedger**. Before creating or modifying files, run
9
- > `changeledger context` directly, read its complete output, and follow it.
10
- > Do not pipe, filter, summarize, limit, or truncate the output before reading it.
11
- > If the output is truncated/incomplete, stop and restore complete context before
12
- > proceeding. If the command is unavailable, stop and restore/install
13
- > ChangeLedger; do not proceed from memory.
8
+ > This repo uses **ChangeLedger**. Immediately after reading this file — before
9
+ > planning, investigating, or acting run `changeledger context` directly.
10
+ > On the first invocation, retain complete stdout through the `CHANGELEDGER CONTEXT END` line:
11
+ > no pipes, filters, summaries, previews or voluntary output limits. If the tool
12
+ > exposes an output budget, reserve enough for the whole response. A missing END
13
+ > after that is exceptional recovery: stop and re-run with a larger capture. If
14
+ > the command is unavailable, stop and restore/install ChangeLedger; do not
15
+ > proceed from memory.
16
+ >
17
+ > Do not create or modify files without an authorized change; the core context
18
+ > defines the workflow, the task contexts, and the narrow operational exception.
14
19
 
15
20
  The canonical ChangeLedger contract is split into task-focused fragments under
16
21
  [`templates/contract/`](templates/contract/). The deterministic
@@ -25,8 +30,10 @@ bootstrap above, not a linked or copied contract.
25
30
  optional integrations.
26
31
  - Managed with **pnpm**; lint/format via **Biome**. Runtime dependencies are
27
32
  allowed only when they are mature and justified: the CLI uses `yaml` for
28
- config/frontmatter parsing, and the viewer uses `lit-html`, `marked`,
29
- `dompurify` and `mermaid` for templating, Markdown, sanitization and diagrams.
33
+ config/frontmatter parsing and `commander` for argument/option/subcommand
34
+ parsing with built-in errors and help, and the viewer uses `lit-html`,
35
+ `marked`, `dompurify` and `mermaid` for templating, Markdown, sanitization and
36
+ diagrams.
30
37
  - `pnpm verify` (lint + test + `changeledger check`) is the full quality gate. The
31
38
  versioned `hooks/pre-commit` runs `lint-staged`, `pnpm test` and `changeledger check`
32
39
  so staged formatting stays compatible with partial commits.
@@ -27,38 +27,20 @@ import { registerRepo } from '../src/commands/register.mjs';
27
27
  import { initReleaseHistory, recordRelease, releasePlan } from '../src/commands/release.mjs';
28
28
  import { view } from '../src/commands/view.mjs';
29
29
  import { findChangeledgerDir } from '../src/config.mjs';
30
- import { applyMigration, SUPPORTED_SCHEMA_VERSION } from '../src/config-migration.mjs';
30
+ import { applyMigration } from '../src/config-migration.mjs';
31
31
  import { nowUtc } from '../src/paths.mjs';
32
32
 
33
33
  const { version } = createRequire(import.meta.url)('../package.json');
34
34
 
35
35
  const USAGE = `ChangeLedger (changeledger)
36
36
 
37
- changeledger init set up .changeledger/ in the current repo (+ register it)
38
- changeledger register refresh registration and context bootstrap
39
- changeledger new <type> <slug> <title> scaffold a new change (slug is the English filename)
40
- changeledger view [port] launch the local viewer (default port 4040)
41
- changeledger check [id] [--json] validate the repo or one change
42
- changeledger context [mode|change-id] print deterministic task context
43
- changeledger status <id> <status> move a change's lifecycle status
44
- changeledger discard <id> "<reason>" discard a change (terminal; keeps the record)
45
- changeledger review <id> pass independent review passed → in-validation
46
- changeledger review <id> fail --retry|--block "<reason>" review failed → in-progress|blocked
47
- changeledger owner <id> <name|-> set or clear a change's owner
48
- changeledger archive <id> / unarchive <id> hide/show a change in the viewer
49
- changeledger archive --graduated [--dry-run] archive done changes already graduated/skipped
50
- changeledger log <id> <message> append a timestamped Log entry
51
- changeledger task <id> done|block <n> [reason] mark a Plan task
52
- changeledger list [--status S] [--type T] [--json] list changes
53
- changeledger show <id> [--json] print a change
54
- changeledger graduate <change-id> <spec-slug> --new create a spec scaffold to refine
55
- changeledger graduate <change-id> <spec-slug> --into graduate into an existing spec
56
- changeledger graduate <change-id> --skip [reason] mark graduation reviewed, no spec
57
- changeledger graduate --pending list done changes not yet reviewed
58
- changeledger config migrate [--dry-run] migrate .changeledger/config.yml to schema ${SUPPORTED_SCHEMA_VERSION}
59
- changeledger release init <version> initialize release history at X.Y.Z
60
- changeledger release plan [--json] calculate the next portable SemVer release
61
- changeledger release record <version> record the calculated release manifest`;
37
+ Run \`changeledger context\` first in any repo it is the mandatory bootstrap.
38
+
39
+ changeledger init | register | new | view | check | context
40
+ changeledger status | discard | review | owner | archive | unarchive
41
+ changeledger log | task | list | show | graduate | config | release
42
+
43
+ Run \`changeledger <command> --help\` for that command's syntax, values and examples.`;
62
44
 
63
45
  const program = new Command();
64
46
 
@@ -78,7 +60,11 @@ program
78
60
  .description('ChangeLedger (changeledger)')
79
61
  .version(version, '-v, --version', 'output the installed version (-V also accepted)')
80
62
  .helpOption('-h, --help', 'display help for command')
81
- .addHelpText('after', `\n${USAGE}`);
63
+ .addHelpText(
64
+ 'after',
65
+ '\nRun `changeledger context` first in any repo — it is the mandatory bootstrap.\n' +
66
+ "Run `changeledger <command> --help` for that command's syntax, values and examples.",
67
+ );
82
68
 
83
69
  program
84
70
  .command('init')
@@ -103,10 +89,18 @@ program
103
89
  program
104
90
  .command('new')
105
91
  .description('scaffold a new change')
106
- .argument('<type>')
107
- .argument('<slug>')
108
- .argument('<title...>')
109
- .option('--owner <name>', 'set the initial owner')
92
+ .argument('<type>', 'a type key configured in .changeledger/config.yml (types:)')
93
+ .argument('<slug>', 'English filename slug, e.g. self-describing-cli-help')
94
+ .argument('<title...>', 'content title, written in the repo language (config.yml: language)')
95
+ .option('--owner <name>', 'set the initial owner (defaults to unassigned)')
96
+ .addHelpText(
97
+ 'after',
98
+ [
99
+ '',
100
+ 'Example:',
101
+ ' changeledger new feature self-describing-cli-help "Self-describing CLI help"',
102
+ ].join('\n'),
103
+ )
110
104
  .action(
111
105
  action((type, slug, titleParts, options) => {
112
106
  const title = titleParts.join(' ').trim();
@@ -117,8 +111,19 @@ program
117
111
 
118
112
  program
119
113
  .command('view')
120
- .description('launch the local viewer')
121
- .argument('[args...]')
114
+ .description('launch the local viewer (all registered projects, or one repo with `.`)')
115
+ .argument('[args...]', 'optional "." for local-only mode and/or a port (default 4040)')
116
+ .addHelpText(
117
+ 'after',
118
+ [
119
+ '',
120
+ 'Examples:',
121
+ ' changeledger view # every registered project, port 4040',
122
+ ' changeledger view . # only the current repo, port 4040',
123
+ ' changeledger view 4041 # every registered project, port 4041',
124
+ ' changeledger view . 4041 # only the current repo, port 4041',
125
+ ].join('\n'),
126
+ )
122
127
  .action(action((args) => view(args)));
123
128
 
124
129
  program
@@ -139,14 +144,61 @@ program
139
144
  program
140
145
  .command('context')
141
146
  .description('print deterministic task context')
142
- .argument('[mode-or-change-id]')
147
+ .argument(
148
+ '[mode-or-change-id]',
149
+ 'spec|implement|review|release, or a change id (pack inferred from its status)',
150
+ )
151
+ .addHelpText(
152
+ 'after',
153
+ [
154
+ '',
155
+ 'With no argument: prints the mandatory bootstrap core. Always run this first —',
156
+ 'every mode and change id below is incremental and extends the core already read,',
157
+ 'it never replaces it.',
158
+ '',
159
+ 'Explicit modes (pass one literally):',
160
+ ' spec author or refine a change',
161
+ ' implement execute an approved change',
162
+ ' review independently verify completed work',
163
+ ' release plan portable delivery metadata',
164
+ '',
165
+ 'Change id (e.g. changeledger context 20260630-225212): loads the pack inferred',
166
+ "from that change's current lifecycle status — you never choose this pack",
167
+ 'yourself. Lifecycle overlays such as blocked, validation, close and discarded',
168
+ 'are inferred the same way from the change id; they are not modes you pass',
169
+ 'explicitly.',
170
+ '',
171
+ 'Examples:',
172
+ ' changeledger context',
173
+ ' changeledger context spec',
174
+ ' changeledger context implement',
175
+ ' changeledger context review',
176
+ ' changeledger context release',
177
+ ' changeledger context 20260630-225212',
178
+ ].join('\n'),
179
+ )
143
180
  .action(action((input) => context(input)));
144
181
 
145
182
  program
146
183
  .command('status')
147
- .description("move a change's lifecycle status")
184
+ .description("move a change's lifecycle status (agent-owned, non-terminal moves only)")
148
185
  .argument('<id>')
149
- .argument('<status>')
186
+ .argument(
187
+ '<status>',
188
+ 'a status configured in .changeledger/config.yml (statuses:), e.g. approved, in-progress, in-review, blocked',
189
+ )
190
+ .addHelpText(
191
+ 'after',
192
+ [
193
+ '',
194
+ 'Terminal moves are not accepted here: use `changeledger discard <id> "<reason>"`',
195
+ 'to discard, and human validation in the viewer to reach done.',
196
+ '',
197
+ 'Examples:',
198
+ ' changeledger status <id> in-progress',
199
+ ' changeledger status <id> blocked',
200
+ ].join('\n'),
201
+ )
150
202
  .action(
151
203
  action((id, st) => {
152
204
  status(id, st);
@@ -197,7 +249,16 @@ program
197
249
  .command('owner')
198
250
  .description("set or clear a change's owner")
199
251
  .argument('<id>')
200
- .argument('<name>')
252
+ .argument('<name>', 'owner handle, or "-" to clear it')
253
+ .addHelpText(
254
+ 'after',
255
+ [
256
+ '',
257
+ 'Examples:',
258
+ ' changeledger owner <id> jdoe',
259
+ ' changeledger owner <id> - # clears the owner',
260
+ ].join('\n'),
261
+ )
201
262
  .action(
202
263
  action((id, name) => {
203
264
  owner(id, name);
@@ -208,9 +269,19 @@ program
208
269
  program
209
270
  .command('archive')
210
271
  .description('hide a change in the viewer, or archive all graduated done changes')
211
- .argument('[id]')
212
- .option('--graduated', 'archive done changes already graduated or skipped')
213
- .option('--dry-run', 'show what would be archived without writing')
272
+ .argument('[id]', 'a change id; mutually exclusive with --graduated')
273
+ .option('--graduated', 'archive every done change already graduated or skipped (takes no id)')
274
+ .option('--dry-run', 'preview --graduated without writing; requires --graduated')
275
+ .addHelpText(
276
+ 'after',
277
+ [
278
+ '',
279
+ 'Examples:',
280
+ ' changeledger archive <id>',
281
+ ' changeledger archive --graduated',
282
+ ' changeledger archive --graduated --dry-run',
283
+ ].join('\n'),
284
+ )
214
285
  .action(
215
286
  action((id, options) => {
216
287
  if (options.graduated) {
@@ -257,8 +328,17 @@ program
257
328
  .description('mark a Plan task')
258
329
  .argument('<id>')
259
330
  .argument('<action>', 'done|block')
260
- .argument('<n>')
261
- .argument('[reason...]')
331
+ .argument('<n>', 'the Plan task index, 1-based, in document order')
332
+ .argument('[reason...]', 'required when action is block; ignored when action is done')
333
+ .addHelpText(
334
+ 'after',
335
+ [
336
+ '',
337
+ 'Examples:',
338
+ ' changeledger task <id> done 1',
339
+ ' changeledger task <id> block 2 "waiting on design decision"',
340
+ ].join('\n'),
341
+ )
262
342
  .action(
263
343
  action((id, taskAction, nStr, reasonParts) => {
264
344
  const n = Number(nStr);
@@ -270,9 +350,21 @@ program
270
350
  program
271
351
  .command('list')
272
352
  .description('list changes')
273
- .option('--status <status>', 'filter by status')
274
- .option('--type <type>', 'filter by type')
353
+ .option(
354
+ '--status <status>',
355
+ 'filter by a status configured in .changeledger/config.yml (statuses:)',
356
+ )
357
+ .option('--type <type>', 'filter by a type configured in .changeledger/config.yml (types:)')
275
358
  .option('--json', 'print JSON')
359
+ .addHelpText(
360
+ 'after',
361
+ [
362
+ '',
363
+ 'Examples:',
364
+ ' changeledger list --status approved',
365
+ ' changeledger list --type feature --json',
366
+ ].join('\n'),
367
+ )
276
368
  .action(
277
369
  action((options) => {
278
370
  const items = list({ status: options.status, type: options.type });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changeledger",
3
- "version": "0.6.4",
3
+ "version": "0.8.0",
4
4
  "description": "Turn conversations into buildable changes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/check.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // Pure validator: takes a loaded repo ({ config, changes }) and returns
2
2
  // { errors, warnings }. No IO — the `changeledger check` command does the IO and printing.
3
3
 
4
+ import { parseChange } from './change.mjs';
5
+ import { CANONICAL_STATUSES, canTransition, parseLogEvent } from './lifecycle.mjs';
4
6
  import { compareVersions, parseVersion, RELEASE_IMPACTS } from './release.mjs';
5
7
 
6
8
  const REQUIRED = ['id', 'title', 'type', 'status', 'created', 'depends_on'];
@@ -81,13 +83,14 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
81
83
 
82
84
  if (fm.status === 'done' && tasks.some((t) => t.state !== 'done')) {
83
85
  const pending = tasks.filter((t) => t.state !== 'done').length;
84
- warn(c, `status is "done" but ${pending} task(s) are not done`);
86
+ err(c, `status is "done" but ${pending} task(s) are not done`);
85
87
  }
86
88
  if (fm.status === 'blocked' && tasks.length && !tasks.some((t) => t.state === 'blocked')) {
87
89
  warn(c, 'status is "blocked" but no task is marked [!]');
88
90
  }
89
91
 
90
92
  checkCoverage(c, fm, active, config, warn, err);
93
+ checkLifecycleSequence(c, fm, err);
91
94
  }
92
95
 
93
96
  // Aggregate checks only make sense over the whole repo.
@@ -129,6 +132,41 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
129
132
  return { errors, warnings };
130
133
  }
131
134
 
135
+ // Reuses the scoped validator for a selected change, optionally replacing its
136
+ // in-memory text with a candidate that has not been written yet. Callers gate
137
+ // mutations on errors only; warnings remain informational by contract.
138
+ export function checkSelectedChange(repo, id, candidateText) {
139
+ let changes = repo.changes;
140
+ if (candidateText !== undefined) {
141
+ const index = changes.findIndex((c) => String(c.frontmatter?.id) === String(id));
142
+ if (index !== -1) {
143
+ const current = changes[index];
144
+ const candidate = { ...current, text: candidateText, ...parseChange(candidateText) };
145
+ changes = changes.with(index, candidate);
146
+ }
147
+ }
148
+ return checkRepo({ ...repo, changes }, { id });
149
+ }
150
+
151
+ export function assertSelectedChangeValid(repo, id, candidateText) {
152
+ const { errors } = checkSelectedChange(repo, id, candidateText);
153
+ if (!errors.length) return;
154
+ throw new Error(
155
+ `change ${id} failed scoped validation:\n${errors.map((error) => `- ${error.message}`).join('\n')}`,
156
+ );
157
+ }
158
+
159
+ // Validates one already-resolved file without loading or parsing siblings. This
160
+ // is the write-gate path for operations whose scope is exactly one change.
161
+ export function assertChangeTextValid(config, name, text) {
162
+ const parsed = parseChange(text);
163
+ const id = parsed.frontmatter.id;
164
+ assertSelectedChangeValid(
165
+ { config, changes: [{ name, text, ...parsed }], specs: [], releases: [] },
166
+ id,
167
+ );
168
+ }
169
+
132
170
  function checkReleases(releases, changesById, err) {
133
171
  const seenVersions = new Set();
134
172
  const releasedChanges = new Map();
@@ -288,10 +326,73 @@ function checkConflictMarkers(c, err) {
288
326
  }
289
327
  }
290
328
 
291
- // Definition-of-Ready coverage: when `tdd` is on (default), a change being built
292
- // (approved/in-progress) whose type activates `## Specification` must map
293
- // criteria Plan tasks both ways. Warnings only it nudges, never blocks.
294
- // It checks coverage, not whether a criterion is "test-grade" (not parseable).
329
+ // Lifecycle history recorded under the previous contract (before
330
+ // `in-validation` became the universal human gate) stays readable without
331
+ // relaxing the current graph for new work (20260630-225210 CR3). Two bounded
332
+ // allowances, both covered by fixtures mirroring real history:
333
+ // - LEGACY_EDGES: closing/skipping edges old flows wrote literally.
334
+ // - Gap resync: old writers did not log every early move (draft → approved,
335
+ // approved → in-progress). An explicit `status:` origin may fast-forward the
336
+ // reconstruction, but only forward and only across pre-review states —
337
+ // implied review/validation origins always require the exact sequence.
338
+ const LEGACY_EDGES = new Set(['in-review→done', 'in-progress→done', 'draft→in-progress']);
339
+ const LEGACY_RESYNC_RANK = { draft: 0, approved: 1, 'in-progress': 2 };
340
+ const CANONICAL = new Set(CANONICAL_STATUSES);
341
+
342
+ // Replays `## Log` lifecycle events from `draft` against the transition graph
343
+ // in lifecycle.mjs. Stops at the first inconsistency to avoid cascading noise.
344
+ // Changes using non-canonical statuses are left alone — the graph cannot
345
+ // reason about states it does not model.
346
+ function checkLifecycleSequence(c, fm, err) {
347
+ const lines = (c.text ?? '').split('\n');
348
+ let inLog = false;
349
+ let current = 'draft';
350
+ let events = 0;
351
+ for (let i = 0; i < lines.length; i++) {
352
+ const line = lines[i];
353
+ if (/^##\s/.test(line)) {
354
+ inLog = /^##\s+Log\s*$/.test(line);
355
+ continue;
356
+ }
357
+ if (!inLog) continue;
358
+ const event = parseLogEvent(line);
359
+ if (!event) continue;
360
+ if (!CANONICAL.has(event.from) || !CANONICAL.has(event.to)) return;
361
+ events += 1;
362
+ if (event.from !== current) {
363
+ const legacyGap =
364
+ event.explicit &&
365
+ LEGACY_RESYNC_RANK[current] !== undefined &&
366
+ LEGACY_RESYNC_RANK[event.from] !== undefined &&
367
+ LEGACY_RESYNC_RANK[event.from] > LEGACY_RESYNC_RANK[current];
368
+ if (!legacyGap) {
369
+ err(
370
+ c,
371
+ `Log line ${i + 1}: transition "${event.from} → ${event.to}" starts from "${event.from}" but the reconstructed status is "${current}"`,
372
+ );
373
+ return;
374
+ }
375
+ current = event.from;
376
+ }
377
+ if (!canTransition(event.from, event.to) && !LEGACY_EDGES.has(`${event.from}→${event.to}`)) {
378
+ err(c, `Log line ${i + 1}: invalid lifecycle transition "${event.from} → ${event.to}"`);
379
+ return;
380
+ }
381
+ current = event.to;
382
+ }
383
+ if (events && CANONICAL.has(fm.status) && current !== fm.status) {
384
+ err(c, `Log reconstructs status "${current}" but frontmatter says "${fm.status}"`);
385
+ }
386
+ }
387
+
388
+ // Definition-of-Ready coverage: when `tdd` is on (default), a change whose type
389
+ // activates `## Specification` is checked in draft, approved and in-progress.
390
+ // Draft reports everything as warnings. In approved/in-progress, readiness
391
+ // defects (criterion missing Given/When/Then, reference to an unknown
392
+ // criterion, CR-bearing task without target+verification) are errors, while
393
+ // coverage gaps (uncovered criterion, non-support task without a CR) stay
394
+ // warnings. Only the Given/When/Then structure is machine-checkable; semantic
395
+ // test-grade quality remains the documenting agent's judgment.
295
396
  function checkCoverage(c, fm, active, config, warn, err = () => {}) {
296
397
  if (config?.tdd === false) return;
297
398
  if (!active?.includes('specification')) return;
@@ -2,18 +2,21 @@
2
2
  // (list/show). Files remain the source of truth; these are optional helpers
3
3
  // that inject correct timestamps/markers and validate transitions.
4
4
 
5
+ import fs from 'node:fs';
5
6
  import path from 'node:path';
6
- import { mutateFileAtomic } from '../atomic-write.mjs';
7
+ import { mutateFileAtomic, withFileLock } from '../atomic-write.mjs';
7
8
  import { parseChange } from '../change.mjs';
9
+ import { assertChangeTextValid } from '../check.mjs';
8
10
  import { ownerHandle as defaultOwnerHandle } from '../git.mjs';
9
11
  import { assertTransition } from '../lifecycle.mjs';
10
12
  import { nowUtc } from '../paths.mjs';
13
+ import { resolveReleasesDir } from '../release.mjs';
11
14
  import { loadRepo, resolveChange } from '../repo.mjs';
12
15
  import { appendLog, setArchived, setOwner, setStatus, setTask } from '../writer.mjs';
13
16
 
14
17
  function locate(cwd, id) {
15
- const { config, file } = resolveChange(cwd, id);
16
- return { config, file };
18
+ const { config, file, repoRoot } = resolveChange(cwd, id);
19
+ return { config, file, repoRoot };
17
20
  }
18
21
 
19
22
  export function status(
@@ -37,6 +40,9 @@ export function status(
37
40
  const autoOwner = newStatus === 'in-progress' ? ownerHandle(path.dirname(file)) : '';
38
41
  mutateFileAtomic(file, (text) => {
39
42
  const fm = parseChange(text).frontmatter;
43
+ if (fm.status === 'done' && newStatus === 'in-progress') {
44
+ throw new Error('only the human-facing viewer can reopen a done change');
45
+ }
40
46
  // Validate the move before any in-memory mutation, so an illegal transition
41
47
  // leaves the file byte-for-byte unchanged. The review gate reads review_required
42
48
  // from the change's type.
@@ -126,17 +132,51 @@ export function validation(id, verdict, { reason } = {}, cwd = process.cwd()) {
126
132
  reviewRequired: Boolean(config.types?.[fm.type]?.review_required),
127
133
  });
128
134
  text = setStatus(text, target);
129
- return appendLog(
135
+ text = appendLog(
130
136
  text,
131
137
  nowUtc(),
132
138
  verdict === 'pass'
133
139
  ? 'validation → done (human accepted)'
134
140
  : `validation → in-progress (human rejected): ${reason}`,
135
141
  );
142
+ if (verdict === 'pass') assertChangeTextValid(config, path.basename(file), text);
143
+ return text;
136
144
  });
137
145
  return file;
138
146
  }
139
147
 
148
+ // Human-only correction path while `done` is still provisional. Graduation,
149
+ // skip, archive and release membership are durable boundaries and fail closed.
150
+ export function reopen(id, reason, cwd = process.cwd()) {
151
+ if (!String(reason ?? '').trim()) throw new Error('reopen requires a reason');
152
+ const { config, file, repoRoot } = locate(cwd, id);
153
+ const releasesDir = resolveReleasesDir(repoRoot);
154
+ fs.mkdirSync(releasesDir, { recursive: true });
155
+ return withFileLock(path.join(releasesDir, '.history'), () => {
156
+ const released = loadRepo(cwd).releases.some((release) =>
157
+ (release.changes ?? []).some((changeId) => String(changeId) === String(id)),
158
+ );
159
+ mutateFileAtomic(file, (text) => {
160
+ const change = { ...parseChange(text), text };
161
+ const fm = change.frontmatter;
162
+ if (fm.status !== 'done')
163
+ throw new Error(`reopen requires status done (current: ${fm.status})`);
164
+ if (fm.reviewed === true) throw new Error('cannot reopen: graduation is already reviewed');
165
+ if (hasGraduationResolution(change))
166
+ throw new Error('cannot reopen: graduation is already resolved');
167
+ if (fm.archived === true) throw new Error('cannot reopen: change is archived');
168
+ if (released) throw new Error('cannot reopen: change belongs to a recorded release');
169
+ assertTransition('done', 'in-progress', {
170
+ type: fm.type,
171
+ reviewRequired: Boolean(config.types?.[fm.type]?.review_required),
172
+ });
173
+ text = setStatus(text, 'in-progress');
174
+ return appendLog(text, nowUtc(), `status: done → in-progress (human reopened): ${reason}`);
175
+ });
176
+ return file;
177
+ });
178
+ }
179
+
140
180
  // name '-' clears the owner.
141
181
  export function owner(id, name, cwd = process.cwd()) {
142
182
  const { file } = locate(cwd, id);