changeledger 0.6.3 → 0.7.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,15 @@ 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 and
10
+ > read its complete output through the `CHANGELEDGER CONTEXT END` line. If that
11
+ > line is missing, the output was truncated: stop and re-run the command
12
+ > directly, without pipes or filters. If the command is unavailable, stop and
13
+ > restore/install ChangeLedger; do not proceed from memory.
14
+ >
15
+ > Do not create or modify files without an authorized change; the core context
16
+ > defines the workflow, the task contexts, and the narrow operational exception.
14
17
 
15
18
  The canonical ChangeLedger contract is split into task-focused fragments under
16
19
  [`templates/contract/`](templates/contract/). The deterministic
@@ -25,8 +28,10 @@ bootstrap above, not a linked or copied contract.
25
28
  optional integrations.
26
29
  - Managed with **pnpm**; lint/format via **Biome**. Runtime dependencies are
27
30
  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.
31
+ config/frontmatter parsing and `commander` for argument/option/subcommand
32
+ parsing with built-in errors and help, and the viewer uses `lit-html`,
33
+ `marked`, `dompurify` and `mermaid` for templating, Markdown, sanitization and
34
+ diagrams.
30
35
  - `pnpm verify` (lint + test + `changeledger check`) is the full quality gate. The
31
36
  versioned `hooks/pre-commit` runs `lint-staged`, `pnpm test` and `changeledger check`
32
37
  so staged formatting stays compatible with partial commits.
package/README.md CHANGED
@@ -96,9 +96,11 @@ changeledger discard <id> <reason>
96
96
  ### Preserve completed truth
97
97
 
98
98
  ```sh
99
- changeledger graduate <id> <spec-slug> # create a persistent spec
100
- changeledger graduate <id> <spec-slug> --into # update an existing spec's provenance
99
+ changeledger graduate <id> <spec-slug> --new # create a marked scaffold; remains pending
100
+ # refine the scaffold and remove its marker
101
+ changeledger graduate <id> <spec-slug> --into # finalize an existing refined spec
101
102
  changeledger graduate <id> --skip [reason] # record that no spec is needed
103
+ changeledger graduate --pending # list unresolved graduation decisions
102
104
  changeledger archive --graduated [--dry-run] # hide resolved changes from the board
103
105
  ```
104
106
 
@@ -15,7 +15,12 @@ import {
15
15
  } from '../src/commands/agent.mjs';
16
16
  import { check } from '../src/commands/check.mjs';
17
17
  import { context } from '../src/commands/context.mjs';
18
- import { graduate, pendingGraduation, skipGraduation } from '../src/commands/graduate.mjs';
18
+ import {
19
+ graduate,
20
+ pendingGraduation,
21
+ scaffoldSpec,
22
+ skipGraduation,
23
+ } from '../src/commands/graduate.mjs';
19
24
  import { init } from '../src/commands/init.mjs';
20
25
  import { newChange } from '../src/commands/new.mjs';
21
26
  import { registerRepo } from '../src/commands/register.mjs';
@@ -29,31 +34,13 @@ const { version } = createRequire(import.meta.url)('../package.json');
29
34
 
30
35
  const USAGE = `ChangeLedger (changeledger)
31
36
 
32
- changeledger init set up .changeledger/ in the current repo (+ register it)
33
- changeledger register refresh registration and context bootstrap
34
- changeledger new <type> <slug> <title> scaffold a new change (slug is the English filename)
35
- changeledger view [port] launch the local viewer (default port 4040)
36
- changeledger check [id] [--json] validate the repo or one change
37
- changeledger context [mode|change-id] print deterministic task context
38
- changeledger status <id> <status> move a change's lifecycle status
39
- changeledger discard <id> "<reason>" discard a change (terminal; keeps the record)
40
- changeledger review <id> pass independent review passed → in-validation
41
- changeledger review <id> fail --retry|--block "<reason>" review failed → in-progress|blocked
42
- changeledger owner <id> <name|-> set or clear a change's owner
43
- changeledger archive <id> / unarchive <id> hide/show a change in the viewer
44
- changeledger archive --graduated [--dry-run] archive done changes already graduated/skipped
45
- changeledger log <id> <message> append a timestamped Log entry
46
- changeledger task <id> done|block <n> [reason] mark a Plan task
47
- changeledger list [--status S] [--type T] [--json] list changes
48
- changeledger show <id> [--json] print a change
49
- changeledger graduate <change-id> <spec-slug> graduate a change to a new spec
50
- changeledger graduate <change-id> <spec-slug> --into graduate into an existing spec
51
- changeledger graduate <change-id> --skip [reason] mark graduation reviewed, no spec
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}
54
- changeledger release init <version> initialize release history at X.Y.Z
55
- changeledger release plan [--json] calculate the next portable SemVer release
56
- 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.`;
57
44
 
58
45
  const program = new Command();
59
46
 
@@ -73,7 +60,11 @@ program
73
60
  .description('ChangeLedger (changeledger)')
74
61
  .version(version, '-v, --version', 'output the installed version (-V also accepted)')
75
62
  .helpOption('-h, --help', 'display help for command')
76
- .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
+ );
77
68
 
78
69
  program
79
70
  .command('init')
@@ -98,10 +89,18 @@ program
98
89
  program
99
90
  .command('new')
100
91
  .description('scaffold a new change')
101
- .argument('<type>')
102
- .argument('<slug>')
103
- .argument('<title...>')
104
- .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
+ )
105
104
  .action(
106
105
  action((type, slug, titleParts, options) => {
107
106
  const title = titleParts.join(' ').trim();
@@ -112,8 +111,19 @@ program
112
111
 
113
112
  program
114
113
  .command('view')
115
- .description('launch the local viewer')
116
- .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
+ )
117
127
  .action(action((args) => view(args)));
118
128
 
119
129
  program
@@ -134,14 +144,61 @@ program
134
144
  program
135
145
  .command('context')
136
146
  .description('print deterministic task context')
137
- .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
+ )
138
180
  .action(action((input) => context(input)));
139
181
 
140
182
  program
141
183
  .command('status')
142
- .description("move a change's lifecycle status")
184
+ .description("move a change's lifecycle status (agent-owned, non-terminal moves only)")
143
185
  .argument('<id>')
144
- .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
+ )
145
202
  .action(
146
203
  action((id, st) => {
147
204
  status(id, st);
@@ -192,7 +249,16 @@ program
192
249
  .command('owner')
193
250
  .description("set or clear a change's owner")
194
251
  .argument('<id>')
195
- .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
+ )
196
262
  .action(
197
263
  action((id, name) => {
198
264
  owner(id, name);
@@ -203,9 +269,19 @@ program
203
269
  program
204
270
  .command('archive')
205
271
  .description('hide a change in the viewer, or archive all graduated done changes')
206
- .argument('[id]')
207
- .option('--graduated', 'archive done changes already graduated or skipped')
208
- .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
+ )
209
285
  .action(
210
286
  action((id, options) => {
211
287
  if (options.graduated) {
@@ -252,8 +328,17 @@ program
252
328
  .description('mark a Plan task')
253
329
  .argument('<id>')
254
330
  .argument('<action>', 'done|block')
255
- .argument('<n>')
256
- .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
+ )
257
342
  .action(
258
343
  action((id, taskAction, nStr, reasonParts) => {
259
344
  const n = Number(nStr);
@@ -265,9 +350,21 @@ program
265
350
  program
266
351
  .command('list')
267
352
  .description('list changes')
268
- .option('--status <status>', 'filter by status')
269
- .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:)')
270
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
+ )
271
368
  .action(
272
369
  action((options) => {
273
370
  const items = list({ status: options.status, type: options.type });
@@ -298,7 +395,8 @@ program
298
395
  .argument('[change-id]')
299
396
  .argument('[spec-slug]')
300
397
  .argument('[reason...]')
301
- .option('--into', 'graduate into an existing spec')
398
+ .option('--new', 'create a spec scaffold without resolving graduation')
399
+ .option('--into', 'finalize graduation into an existing refined spec')
302
400
  .option('--skip', 'mark graduation reviewed without a spec')
303
401
  .option('--pending', 'list done changes not yet reviewed')
304
402
  .addHelpText(
@@ -306,7 +404,7 @@ program
306
404
  [
307
405
  '',
308
406
  'Examples:',
309
- ' changeledger graduate <change-id> <spec-slug>',
407
+ ' changeledger graduate <change-id> <spec-slug> --new',
310
408
  ' changeledger graduate <change-id> <spec-slug> --into',
311
409
  ' changeledger graduate <change-id> --skip [reason]',
312
410
  ' changeledger graduate --pending',
@@ -314,7 +412,15 @@ program
314
412
  )
315
413
  .action(
316
414
  action((id, slug, reasonParts, options) => {
415
+ const modeCount = [options.new, options.into, options.skip, options.pending].filter(
416
+ Boolean,
417
+ ).length;
418
+ const modeUsage =
419
+ 'Usage: changeledger graduate requires exactly one mode: --new, --into, --skip, or --pending';
420
+ if (modeCount !== 1) throw new Error(modeUsage);
421
+
317
422
  if (options.pending) {
423
+ if (id || slug || reasonParts.length) throw new Error(modeUsage);
318
424
  const items = pendingGraduation();
319
425
  if (!items.length) console.log('No changes pending graduation.');
320
426
  for (const c of items) console.log(`#${c.id} ${c.title}`);
@@ -327,7 +433,15 @@ program
327
433
  console.log(`#${id} graduation skipped`);
328
434
  return;
329
435
  }
330
- if (!id || !slug) throw new Error('Usage: changeledger graduate <change-id> <spec-slug>');
436
+
437
+ if (!id || !slug || reasonParts.length) throw new Error(modeUsage);
438
+ if (options.new) {
439
+ const file = scaffoldSpec(id, slug);
440
+ console.log(
441
+ `Created spec scaffold ${file}. Refine it, then run: changeledger graduate ${id} ${slug} --into`,
442
+ );
443
+ return;
444
+ }
331
445
  const file = graduate(id, slug, process.cwd(), { into: options.into });
332
446
  console.log(`Graduated #${id} → ${file}`);
333
447
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changeledger",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Turn conversations into buildable changes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/check.mjs CHANGED
@@ -1,6 +1,7 @@
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 { CANONICAL_STATUSES, canTransition, parseLogEvent } from './lifecycle.mjs';
4
5
  import { compareVersions, parseVersion, RELEASE_IMPACTS } from './release.mjs';
5
6
 
6
7
  const REQUIRED = ['id', 'title', 'type', 'status', 'created', 'depends_on'];
@@ -88,6 +89,7 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
88
89
  }
89
90
 
90
91
  checkCoverage(c, fm, active, config, warn, err);
92
+ checkLifecycleSequence(c, fm, err);
91
93
  }
92
94
 
93
95
  // Aggregate checks only make sense over the whole repo.
@@ -288,10 +290,73 @@ function checkConflictMarkers(c, err) {
288
290
  }
289
291
  }
290
292
 
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).
293
+ // Lifecycle history recorded under the previous contract (before
294
+ // `in-validation` became the universal human gate) stays readable without
295
+ // relaxing the current graph for new work (20260630-225210 CR3). Two bounded
296
+ // allowances, both covered by fixtures mirroring real history:
297
+ // - LEGACY_EDGES: closing/skipping edges old flows wrote literally.
298
+ // - Gap resync: old writers did not log every early move (draft → approved,
299
+ // approved → in-progress). An explicit `status:` origin may fast-forward the
300
+ // reconstruction, but only forward and only across pre-review states —
301
+ // implied review/validation origins always require the exact sequence.
302
+ const LEGACY_EDGES = new Set(['in-review→done', 'in-progress→done', 'draft→in-progress']);
303
+ const LEGACY_RESYNC_RANK = { draft: 0, approved: 1, 'in-progress': 2 };
304
+ const CANONICAL = new Set(CANONICAL_STATUSES);
305
+
306
+ // Replays `## Log` lifecycle events from `draft` against the transition graph
307
+ // in lifecycle.mjs. Stops at the first inconsistency to avoid cascading noise.
308
+ // Changes using non-canonical statuses are left alone — the graph cannot
309
+ // reason about states it does not model.
310
+ function checkLifecycleSequence(c, fm, err) {
311
+ const lines = (c.text ?? '').split('\n');
312
+ let inLog = false;
313
+ let current = 'draft';
314
+ let events = 0;
315
+ for (let i = 0; i < lines.length; i++) {
316
+ const line = lines[i];
317
+ if (/^##\s/.test(line)) {
318
+ inLog = /^##\s+Log\s*$/.test(line);
319
+ continue;
320
+ }
321
+ if (!inLog) continue;
322
+ const event = parseLogEvent(line);
323
+ if (!event) continue;
324
+ if (!CANONICAL.has(event.from) || !CANONICAL.has(event.to)) return;
325
+ events += 1;
326
+ if (event.from !== current) {
327
+ const legacyGap =
328
+ event.explicit &&
329
+ LEGACY_RESYNC_RANK[current] !== undefined &&
330
+ LEGACY_RESYNC_RANK[event.from] !== undefined &&
331
+ LEGACY_RESYNC_RANK[event.from] > LEGACY_RESYNC_RANK[current];
332
+ if (!legacyGap) {
333
+ err(
334
+ c,
335
+ `Log line ${i + 1}: transition "${event.from} → ${event.to}" starts from "${event.from}" but the reconstructed status is "${current}"`,
336
+ );
337
+ return;
338
+ }
339
+ current = event.from;
340
+ }
341
+ if (!canTransition(event.from, event.to) && !LEGACY_EDGES.has(`${event.from}→${event.to}`)) {
342
+ err(c, `Log line ${i + 1}: invalid lifecycle transition "${event.from} → ${event.to}"`);
343
+ return;
344
+ }
345
+ current = event.to;
346
+ }
347
+ if (events && CANONICAL.has(fm.status) && current !== fm.status) {
348
+ err(c, `Log reconstructs status "${current}" but frontmatter says "${fm.status}"`);
349
+ }
350
+ }
351
+
352
+ // Definition-of-Ready coverage: when `tdd` is on (default), a change whose type
353
+ // activates `## Specification` is checked in draft, approved and in-progress.
354
+ // Draft reports everything as warnings. In approved/in-progress, readiness
355
+ // defects (criterion missing Given/When/Then, reference to an unknown
356
+ // criterion, CR-bearing task without target+verification) are errors, while
357
+ // coverage gaps (uncovered criterion, non-support task without a CR) stay
358
+ // warnings. Only the Given/When/Then structure is machine-checkable; semantic
359
+ // test-grade quality remains the documenting agent's judgment.
295
360
  function checkCoverage(c, fm, active, config, warn, err = () => {}) {
296
361
  if (config?.tdd === false) return;
297
362
  if (!active?.includes('specification')) return;
@@ -1,14 +1,17 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseChange } from '../change.mjs';
4
- import { findChangeledgerDir } from '../config.mjs';
5
- import { contractTemplatesDir } from '../paths.mjs';
4
+ import { findChangeledgerDir, loadConfig } from '../config.mjs';
5
+ import { contractTemplatesDir, packageRoot } from '../paths.mjs';
6
6
  import { resolveChange } from '../repo.mjs';
7
7
 
8
+ const VERSION = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')).version;
9
+ const END_DELIMITER =
10
+ '===== CHANGELEDGER CONTEXT END — if this line is missing, the output was truncated: stop and re-run =====';
8
11
  const MODES = ['implement', 'review', 'spec', 'release'];
9
12
  const MODE_CONTEXT = {
10
- implement: ['implement', 'delegation', 'readiness', 'handoff'],
11
- review: ['review', 'delegation', 'handoff'],
13
+ implement: ['implement', 'delegation', 'handoff'],
14
+ review: ['review', 'handoff'],
12
15
  spec: ['spec', 'delegation', 'readiness'],
13
16
  release: ['release'],
14
17
  };
@@ -23,30 +26,102 @@ const STATUS_CONTEXT = {
23
26
  discarded: { mode: 'discarded', fragments: ['discarded'] },
24
27
  };
25
28
  const INCREMENTAL_NOTICE = `This incremental context extends the complete core context already read.
26
- If the complete base output has not been read, stop and run \`changeledger context\` directly before proceeding.`;
29
+ If you have not read the core output through its \`CHANGELEDGER CONTEXT END\` line, stop and run \`changeledger context\` directly before proceeding.`;
27
30
 
28
31
  function fragment(name) {
29
32
  return fs.readFileSync(path.join(contractTemplatesDir, `${name}.md`), 'utf8').trim();
30
33
  }
31
34
 
32
- function compose(mode, fragments, changeText, incremental = true) {
33
- const sections = [`Mode: ${mode}`];
35
+ function beginDelimiter(mode, changeId) {
36
+ const change = changeId ? ` — change: #${changeId}` : '';
37
+ return `===== CHANGELEDGER CONTEXT BEGIN — mode: ${mode}${change} — v${VERSION} =====`;
38
+ }
39
+
40
+ // Resolved defaults so an agent never reads `.changeledger/config.yml` raw to
41
+ // discover the repo's effective policy. Keep these aligned with the shipped
42
+ // template config and the Definition of Ready contract.
43
+ const DEFAULT_LANGUAGE = 'en';
44
+ const DEFAULT_TDD = true;
45
+
46
+ function effectiveLanguage(config) {
47
+ return config?.language ?? DEFAULT_LANGUAGE;
48
+ }
49
+
50
+ function effectiveTdd(config) {
51
+ const value = config?.tdd ?? DEFAULT_TDD;
52
+ return value ? 'on' : 'off';
53
+ }
54
+
55
+ // The transversal policy line every composition anchors on: effective language
56
+ // and tdd with defaults already resolved.
57
+ function transversalPolicy(config) {
58
+ return `Effective policy: language=${effectiveLanguage(config)} — tdd=${effectiveTdd(config)}`;
59
+ }
60
+
61
+ // Type-specific policy for change-id contexts: adds review requirement and the
62
+ // active stages the type actually uses, so the agent does not infer them.
63
+ function changePolicyBlock(config, type) {
64
+ const typeConfig = config?.types?.[type] ?? {};
65
+ const reviewRequired = typeConfig.review_required === true ? 'yes' : 'no';
66
+ const stages = Array.isArray(typeConfig.stages) ? typeConfig.stages.join(', ') : '';
67
+ const lines = [
68
+ `${transversalPolicy(config)} — review_required(${type})=${reviewRequired}`,
69
+ `Active stages(${type})=${stages}`,
70
+ ];
71
+ return lines.join('\n');
72
+ }
73
+
74
+ // One line per local dependency (id, title, status); external `project:id`
75
+ // references stay references, never pretending local resolution.
76
+ function dependencyBlock(config, repoRoot, dependsOn, cwd) {
77
+ if (!Array.isArray(dependsOn) || dependsOn.length === 0) return undefined;
78
+ const lines = dependsOn.map((raw) => {
79
+ const dep = String(raw);
80
+ if (dep.includes(':')) return `- #${dep} — external reference (not resolved locally)`;
81
+ try {
82
+ const resolved = resolveChange(cwd, dep);
83
+ const { frontmatter } = parseChange(fs.readFileSync(resolved.file, 'utf8'));
84
+ return `- #${dep} — ${frontmatter.title} — ${frontmatter.status}`;
85
+ } catch {
86
+ return `- #${dep} — unresolved local dependency`;
87
+ }
88
+ });
89
+ return `## Dependencies\n\n${lines.join('\n')}`;
90
+ }
91
+
92
+ function compose(mode, fragments, options = {}) {
93
+ const {
94
+ changeText,
95
+ incremental = true,
96
+ changeId = undefined,
97
+ policy = undefined,
98
+ dependencies = undefined,
99
+ } = options;
100
+ const sections = [beginDelimiter(mode, changeId)];
34
101
  if (incremental) sections.push(INCREMENTAL_NOTICE);
102
+ if (policy) sections.push(policy);
35
103
  sections.push(...fragments.map(fragment));
104
+ if (dependencies) sections.push(dependencies);
36
105
  if (changeText) sections.push('---\n\n# Selected change\n', changeText.trim());
106
+ sections.push(END_DELIMITER);
37
107
  return `${sections.join('\n\n')}\n`;
38
108
  }
39
109
 
40
110
  function requireRepo(cwd) {
41
- if (!findChangeledgerDir(cwd)) {
42
- throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
43
- }
111
+ const dir = findChangeledgerDir(cwd);
112
+ if (!dir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
113
+ return dir;
44
114
  }
45
115
 
46
116
  export function buildContext(input, cwd = process.cwd()) {
47
- requireRepo(cwd);
48
- if (!input) return compose('core', ['core'], undefined, false);
49
- if (MODES.includes(input)) return compose(input, MODE_CONTEXT[input]);
117
+ const changeledgerDir = requireRepo(cwd);
118
+ const config = loadConfig(changeledgerDir);
119
+ if (!input) {
120
+ return compose('core', ['core'], { incremental: false, policy: transversalPolicy(config) });
121
+ }
122
+ if (MODES.includes(input)) {
123
+ return compose(input, MODE_CONTEXT[input], { policy: transversalPolicy(config) });
124
+ }
50
125
 
51
126
  let resolved;
52
127
  try {
@@ -58,10 +133,15 @@ export function buildContext(input, cwd = process.cwd()) {
58
133
  }
59
134
 
60
135
  const text = fs.readFileSync(resolved.file, 'utf8');
61
- const status = parseChange(text).frontmatter.status;
136
+ const { id, status, type, depends_on: dependsOn } = parseChange(text).frontmatter;
62
137
  const selected = STATUS_CONTEXT[status];
63
138
  if (!selected) throw new Error(`No context mapping for change status "${status}"`);
64
- return compose(selected.mode, selected.fragments, text);
139
+ return compose(selected.mode, selected.fragments, {
140
+ changeText: text,
141
+ changeId: id,
142
+ policy: changePolicyBlock(config, type),
143
+ dependencies: dependencyBlock(config, resolved.repoRoot, dependsOn, cwd),
144
+ });
65
145
  }
66
146
 
67
147
  export function context(input, cwd = process.cwd(), output = console.log) {
@@ -1,6 +1,6 @@
1
- // Graduates a change to a spec: scaffolds the spec file (frontmatter + a body
2
- // seeded from the change's Specification/Proposal) and links it back in the
3
- // change's Log. The final wording stays a manual/agent task.
1
+ // Graduation is intentionally two-phase for a new spec. `scaffoldSpec` creates
2
+ // an editable seed without resolving the change; `graduate --into` links only
3
+ // after the durable wording has been reviewed.
4
4
 
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
@@ -13,55 +13,73 @@ import { slugify } from '../slug.mjs';
13
13
  import { appendLog, setReviewed, setSpecUpdated } from '../writer.mjs';
14
14
  import { serializeScalar } from '../yaml.mjs';
15
15
 
16
- // `into: true` graduates into an EXISTING spec — it refreshes the spec's
17
- // `updated` and links it back, but leaves the body to the agent (who knows what
18
- // to refine). Without `into`, a new spec is scaffolded and an existing one is an
19
- // error. Both routes share the same change-side record (marker + reviewed).
20
- export function graduate(id, slug, cwd = process.cwd(), { into = false } = {}) {
21
- const { config, repoRoot, file: changeFile } = resolveChange(cwd, id);
16
+ const SPEC_SCAFFOLD_MARKER = '<!-- changeledger:spec-scaffold -->';
22
17
 
23
- const specsDir = resolveSpecsDir(repoRoot, config);
18
+ function graduationTarget(id, slug, cwd) {
19
+ const resolved = resolveChange(cwd, id);
20
+ const specsDir = resolveSpecsDir(resolved.repoRoot, resolved.config);
24
21
  const specName = `${slugify(slug)}.md`;
25
- const specFile = path.join(specsDir, specName);
22
+ return { ...resolved, specsDir, specName, specFile: path.join(specsDir, specName) };
23
+ }
26
24
 
27
- // Validate preconditions before acquiring the change file lock — ensures no
28
- // write happens at all when the existence check fails (CR1).
29
- const exists = fs.existsSync(specFile);
30
- if (into && !exists)
31
- throw new Error(`Spec "${specName}" does not exist — drop --into to create it`);
32
- if (!into && exists) throw new Error(`Spec "${specName}" already exists`);
25
+ function requireDone(changeText) {
26
+ const change = parseChange(changeText);
27
+ if (change.frontmatter.status !== 'done') {
28
+ throw new Error('only done changes can be graduated/skipped');
29
+ }
30
+ return change;
31
+ }
33
32
 
34
- mutateFileAtomic(changeFile, (changeText) => {
35
- const change = parseChange(changeText);
36
- if (change.frontmatter.status !== 'done') {
37
- throw new Error('only done changes can be graduated/skipped');
38
- }
33
+ export function scaffoldSpec(id, slug, cwd = process.cwd()) {
34
+ const { file: changeFile, specsDir, specName, specFile } = graduationTarget(id, slug, cwd);
35
+ if (fs.existsSync(specFile)) throw new Error(`Spec "${specName}" already exists`);
39
36
 
40
- if (into) {
41
- // Refresh the spec's updated; the body stays the agent's to edit.
42
- writeFileAtomic(specFile, setSpecUpdated(fs.readFileSync(specFile, 'utf8'), nowUtc()));
43
- } else {
44
- const seedStage =
45
- change.stages.find((s) => s.key === 'specification') ??
46
- change.stages.find((s) => s.key === 'proposal');
47
- const seed = seedStage ? seedStage.body : '';
48
- const title = change.frontmatter.title;
49
-
50
- const content = `---
51
- title: ${serializeScalar(title)}
37
+ const change = requireDone(fs.readFileSync(changeFile, 'utf8'));
38
+ const seedStage =
39
+ change.stages.find((stage) => stage.key === 'specification') ??
40
+ change.stages.find((stage) => stage.key === 'proposal');
41
+ const seed = seedStage ? seedStage.body : '';
42
+ const content = `---
43
+ title: ${serializeScalar(change.frontmatter.title)}
52
44
  updated: ${nowUtc()}
53
45
  tags: [${change.frontmatter.type}]
54
46
  ---
55
47
 
56
- # ${title}
48
+ # ${change.frontmatter.title}
57
49
 
58
- > Graduado del change ${id}.
50
+ ${SPEC_SCAFFOLD_MARKER}
51
+
52
+ > Scaffold from change ${id}; replace this seed with durable current truth before --into.
59
53
 
60
54
  ${seed}
61
55
  `;
62
- fs.mkdirSync(specsDir, { recursive: true });
63
- writeFileAtomic(specFile, content);
56
+
57
+ fs.mkdirSync(specsDir, { recursive: true });
58
+ writeFileAtomic(specFile, content);
59
+ return specFile;
60
+ }
61
+
62
+ // Finalizes graduation into an EXISTING, manually refined spec. The command
63
+ // refreshes `updated` and links it back, but never overwrites the body.
64
+ export function graduate(id, slug, cwd = process.cwd(), { into = false } = {}) {
65
+ if (!into) {
66
+ throw new Error('graduation mode required: use --new, --into, or --skip');
67
+ }
68
+ const { file: changeFile, specName, specFile } = graduationTarget(id, slug, cwd);
69
+
70
+ if (!fs.existsSync(specFile)) {
71
+ throw new Error(`Spec "${specName}" does not exist — use --new to create a scaffold`);
72
+ }
73
+
74
+ mutateFileAtomic(changeFile, (changeText) => {
75
+ requireDone(changeText);
76
+ const specText = fs.readFileSync(specFile, 'utf8');
77
+ if (specText.includes(SPEC_SCAFFOLD_MARKER)) {
78
+ throw new Error(
79
+ `Spec "${specName}" still contains the scaffold marker — refine it and remove the marker before --into`,
80
+ );
64
81
  }
82
+ writeFileAtomic(specFile, setSpecUpdated(specText, nowUtc()));
65
83
 
66
84
  let text = appendLog(changeText, nowUtc(), `graduado a spec \`${specName}\``);
67
85
  text = setReviewed(text, true);
@@ -21,8 +21,28 @@ export {
21
21
  } from '../viewer/domain.mjs';
22
22
  export { createRequestListener, hostnameOf, isAuthorizedWrite, isLocalHost, staticFile };
23
23
 
24
+ // Explicit grammar: `.` selects local-only mode, a bare integer selects the
25
+ // port (default 4040), both may combine, and anything else is rejected
26
+ // instead of being silently ignored.
27
+ function parseViewArgs(args) {
28
+ let localOnly = false;
29
+ let port = 4040;
30
+ const unknown = [];
31
+ for (const a of args) {
32
+ if (a === '.') localOnly = true;
33
+ else if (/^\d+$/.test(a)) port = Number(a);
34
+ else unknown.push(a);
35
+ }
36
+ if (unknown.length) {
37
+ throw new Error(
38
+ `Unknown argument(s) for "changeledger view": ${unknown.join(', ')} — usage: changeledger view [.] [port]`,
39
+ );
40
+ }
41
+ return { localOnly, port };
42
+ }
43
+
24
44
  export async function view(args = [], cwd = process.cwd()) {
25
- const localOnly = args.includes('.');
45
+ const { localOnly, port: requestedPort } = parseViewArgs(args);
26
46
  resolveProjects(cwd, localOnly); // fail fast if local mode outside a repo
27
47
 
28
48
  const token = crypto.randomBytes(16).toString('hex');
@@ -31,10 +51,11 @@ export async function view(args = [], cwd = process.cwd()) {
31
51
  server.headersTimeout = 10_000;
32
52
 
33
53
  const host = '127.0.0.1';
34
- const port = await listen(server, host, Number(args.find((a) => /^\d+$/.test(a))) || 4040);
54
+ const port = await listen(server, host, requestedPort);
35
55
  const url = `http://${host}:${port}`;
36
56
  console.log(`ChangeLedger viewer → ${url} (Ctrl+C to stop)`);
37
57
  openBrowser(url);
58
+ return server;
38
59
  }
39
60
 
40
61
  function listen(server, host, port, attempts = 10) {
package/src/contract.mjs CHANGED
@@ -36,12 +36,15 @@ const LEGACY_CONTRACT_HASHES = new Set([
36
36
 
37
37
  export const REFERENCE = `${MARKER}
38
38
  > [!IMPORTANT]
39
- > This repo uses **ChangeLedger**. Before creating or modifying files, run
40
- > \`changeledger context\` directly, read its complete output, and follow it.
41
- > Do not pipe, filter, summarize, limit, or truncate the output before reading it.
42
- > If the output is truncated/incomplete, stop and restore complete context before
43
- > proceeding. If the command is unavailable, stop and restore/install
44
- > ChangeLedger; do not proceed from memory.
39
+ > This repo uses **ChangeLedger**. Immediately after reading this file — before
40
+ > planning, investigating, or acting — run \`changeledger context\` directly and
41
+ > read its complete output through the \`CHANGELEDGER CONTEXT END\` line. If that
42
+ > line is missing, the output was truncated: stop and re-run the command
43
+ > directly, without pipes or filters. If the command is unavailable, stop and
44
+ > restore/install ChangeLedger; do not proceed from memory.
45
+ >
46
+ > Do not create or modify files without an authorized change; the core context
47
+ > defines the workflow, the task contexts, and the narrow operational exception.
45
48
  `;
46
49
 
47
50
  export const contractLink = (changeledgerDir) => path.join(changeledgerDir, 'AGENTS.md');
package/src/lifecycle.mjs CHANGED
@@ -55,3 +55,19 @@ export function assertTransition(from, to, { type, reviewRequired = false } = {}
55
55
  throw new Error(`${type} changes must be reviewed before validation — move to in-review first`);
56
56
  }
57
57
  }
58
+
59
+ // A lifecycle event recorded in `## Log`. `status:` lines carry an explicit
60
+ // origin; review/validation verdict lines imply it (the writer only emits them
61
+ // from in-review / in-validation). Non-lifecycle entries (owner, graduation,
62
+ // free notes) return null.
63
+ const LOG_EVENT =
64
+ /\*\*([^*]+)\*\*\s*—\s*(?:status:\s*([a-z-]+)\s*→\s*([a-z-]+)|(review)\s*→\s*([a-z-]+)|(validation)\s*→\s*([a-z-]+))/;
65
+
66
+ export function parseLogEvent(line) {
67
+ const m = line.match(LOG_EVENT);
68
+ if (!m) return null;
69
+ const at = m[1].trim();
70
+ if (m[2]) return { at, from: m[2], to: m[3], explicit: true };
71
+ if (m[4]) return { at, from: 'in-review', to: m[5], explicit: false };
72
+ return { at, from: 'in-validation', to: m[7], explicit: false };
73
+ }
package/src/metrics.mjs CHANGED
@@ -3,16 +3,19 @@
3
3
  // Everything is reconstructed from `created` plus the `## Log` status
4
4
  // transitions. The viewer renders the result.
5
5
 
6
+ import { parseLogEvent } from './lifecycle.mjs';
7
+
6
8
  const ACTIVE = ['approved', 'in-progress', 'in-review', 'in-validation', 'blocked'];
7
9
 
8
10
  function logBody(change) {
9
11
  return (change.stages ?? []).find((s) => s.key === 'log')?.body ?? '';
10
12
  }
11
13
 
14
+ // Same event grammar as `changeledger check`'s sequence validation — one shared
15
+ // parser so metrics and validation cannot drift (20260630-225210 CR5).
12
16
  function logTransition(line) {
13
- const m = line.match(/\*\*([^*]+)\*\*\s*—\s*(?:status:.*|review|validation)\s*→\s*([a-z-]+)\b/);
14
- if (!m) return null;
15
- return { at: m[1].trim(), state: m[2].trim() };
17
+ const event = parseLogEvent(line);
18
+ return event ? { at: event.at, state: event.to } : null;
16
19
  }
17
20
 
18
21
  // The moment a change reached `done`: the last lifecycle transition to done, or
@@ -1,6 +1,8 @@
1
1
  # Closing Accepted Work
2
2
 
3
- The human accepted this change. Resolve persistent truth before archiving.
3
+ The human accepted this change. Resolve persistent truth before archiving. Run
4
+ `changeledger context <id>` after acceptance even if the base context was loaded
5
+ earlier; this lifecycle-specific close overlay is not part of the base context.
4
6
  Changes describe a journey; `.changeledger/specs/*.md` describe the current
5
7
  capability, architecture or domain truth that code reflects.
6
8
 
@@ -15,19 +17,25 @@ tags: []
15
17
  ---
16
18
  ```
17
19
 
18
- Choose one graduation outcome:
19
-
20
- - `changeledger graduate <id> <spec-slug>` creates a new spec seeded from the
21
- change's Specification and Proposal; refine its wording manually.
22
- - `changeledger graduate <id> <spec-slug> --into` links an existing spec and
23
- refreshes `updated` without overwriting the spec body (the agent edits the body manually).
20
+ Choose exactly one explicit graduation mode. A positional slug without a mode
21
+ is an error, so words such as `skip` or `skip-*` can never silently become specs.
22
+
23
+ - For a new spec, run `changeledger graduate <id> <spec-slug> --new`. This creates
24
+ a seed from the change's Specification or Proposal but leaves graduation
25
+ pending. Rewrite it as concise durable current truth and remove the explicit
26
+ scaffold marker. Then run `changeledger graduate <id> <spec-slug> --into` to
27
+ finalize it. `--into` refuses an unrefined marked scaffold.
28
+ - For an existing spec, the agent edits its body first, then runs
29
+ `changeledger graduate <id> <spec-slug> --into`. It refreshes `updated`, records
30
+ the link and does not overwrite the body.
24
31
  - `changeledger graduate <id> --skip [reason]` records that no persistent truth
25
32
  changed.
26
33
  - `changeledger graduate --pending` lists accepted changes whose decision is
27
34
  unresolved.
28
35
 
29
- Graduation and skip both set `reviewed: true` on the change: it means the
30
- persistent-truth question was settled, not necessarily that a spec was created.
36
+ Finalization with `--into` and skip both set `reviewed: true` on the change;
37
+ `--new` does not. The boolean means the persistent-truth question was settled,
38
+ not necessarily that a spec was created.
31
39
  The graduation link remains derivable from the Log marker `graduado a spec`,
32
40
  which carries the spec link, rather than from the boolean flag.
33
41
 
@@ -3,14 +3,13 @@
3
3
  Documents under `.changeledger/` are the source of truth. Code is their
4
4
  reflection. Work is planned and documented before code is written.
5
5
 
6
- ## Non-negotiable fast path
6
+ ## Read complete context before acting
7
7
 
8
- Running `changeledger context` is discovery, not compliance by itself. Run it
9
- directly, without piping, filtering, summarizing, limiting or truncating output
10
- before reading it. Read the complete output and follow the current mode. If the
11
- output is truncated or incomplete, including through tools such as `head`,
12
- `tail`, `sed` or `grep`, stop and restore complete context before creating or
13
- modifying files.
8
+ Running `changeledger context` is discovery, not compliance by itself. Read the
9
+ complete output through the `CHANGELEDGER CONTEXT END` line, then follow the
10
+ current mode. If that line is missing, the output was truncated. Stop and re-run
11
+ the command directly, without pipes or filters, before creating or modifying
12
+ files.
14
13
 
15
14
  1. Work starts with conversation. Read-only investigation may clarify a request,
16
15
  but create no change or implementation artifact until there is enough clarity
@@ -26,8 +25,10 @@ modifying files.
26
25
  6. For types that require review, use a fresh clean-context reviewer before
27
26
  human validation.
28
27
  7. Stop at `in-validation`. The agent never accepts on the human's behalf.
29
- 8. After human acceptance, graduate persistent truth or record an explicit skip,
30
- then archive the done change.
28
+ 8. After human acceptance, reload `changeledger context <id>` for the `done`
29
+ change, then graduate persistent truth (a new spec is a two-step `--new`
30
+ then `--into`) or run `changeledger graduate <id> --skip [reason]`; archive
31
+ only after that decision.
31
32
 
32
33
  If no approved or in-progress change applies, do not silently edit repository
33
34
  files. Create or update a change, or ask the human whether a purely operational,
@@ -43,7 +44,8 @@ optional and preferred for error-prone operations such as timestamps, lifecycle
43
44
  transitions and task markers.
44
45
 
45
46
  Delegate only with a clear boundary and benefit. Each delegation prompt states
46
- ownership, expected output and integration criterion; coding agents must know
47
+ at least ownership, expected output and integration criterion; the task context
48
+ carries the full prompt contract. Coding agents must know
47
49
  they share the codebase and must not revert others' work. Do not over-shard or
48
50
  overlap write surfaces without an explicit integration plan. Size the model to
49
51
  the task's difficulty and risk.
@@ -80,8 +82,11 @@ later reconsideration needs a newly authorized change.
80
82
 
81
83
  Valid modes: implement, review, spec, release.
82
84
 
83
- Run these only after reading the complete base output. Each mode and change-id
84
- context extends the core context already read without repeating it.
85
+ Escalate to a mode before acting. Before documenting, run
86
+ `changeledger context spec`. Before executing, run `changeledger context
87
+ implement` or `changeledger context <change-id>`. Run each only after reading
88
+ the complete base output. Every mode and change-id context extends the core
89
+ context already read; it never repeats it.
85
90
 
86
91
  - `changeledger context spec`: author or refine a change.
87
92
  - `changeledger context implement`: execute an approved change.
@@ -97,4 +102,5 @@ Prefer structured CLI queries before scanning files:
97
102
  - `changeledger graduate --pending`: find accepted changes whose graduation decision is unresolved.
98
103
 
99
104
  Run `changeledger help` or `changeledger <command> --help` for exact CLI syntax.
100
- Structure is always English; narrative content follows `.changeledger/config.yml`.
105
+ Structure is always English. Each context delivers the effective policy that
106
+ applies to its task, so you never read `.changeledger/config.yml` raw to operate.
@@ -37,6 +37,9 @@ evidence in the task instead of listing every possible manual phrase in config.
37
37
 
38
38
  `changeledger check` reports missing Given/When/Then, uncovered or unknown CRs,
39
39
  tasks without traceability and CR-bearing tasks without configured target and
40
- verification. Gaps are warnings in `draft` and errors in `approved` or
41
- `in-progress`. Truly operational `(support)` tasks are exempt; observable
40
+ verification. Every diagnostic is a warning in `draft`. In `approved` or
41
+ `in-progress`, readiness defects (missing Given/When/Then, unknown criterion
42
+ references, CR-bearing tasks without target and verification) are errors, while
43
+ coverage gaps (uncovered criteria, non-support tasks without a CR) stay
44
+ warnings. Truly operational `(support)` tasks are exempt; observable
42
45
  implementation is not.