changeledger 0.9.0 → 0.10.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
@@ -3,7 +3,7 @@
3
3
  This is the ChangeLedger repo itself. It dogfoods its own format: changes live
4
4
  under `.changeledger/changes/`, persistent truth under `.changeledger/specs/`.
5
5
 
6
- <!-- changeledger -->
6
+ <!-- CHANGELEDGER BOOTSTRAP BEGIN v2 -->
7
7
  > [!IMPORTANT]
8
8
  > This repo uses **ChangeLedger**. Immediately after reading this file — before
9
9
  > planning, investigating, or acting — a normal agent must run `changeledger context` directly.
@@ -20,6 +20,10 @@ under `.changeledger/changes/`, persistent truth under `.changeledger/specs/`.
20
20
  >
21
21
  > Do not create or modify files without an authorized change; the core context
22
22
  > defines the workflow, the task contexts, and the narrow operational exception.
23
+ > After a compaction, verify a retained capture with `changeledger context
24
+ > [mode] --have <rev>` (the BEGIN line's `rev:`) instead of recapturing in
25
+ > full; a mismatch still returns the complete output.
26
+ <!-- CHANGELEDGER BOOTSTRAP END -->
23
27
 
24
28
  The canonical ChangeLedger contract is split into task-focused fragments under
25
29
  [`templates/contract/`](templates/contract/). The deterministic
package/README.md CHANGED
@@ -147,6 +147,11 @@ there is no linked or copied contract under `.changeledger/`. Run
147
147
  load `context`; a delegated leaf identified by `agent-prompt` loads only its
148
148
  matching `agent-context`, without the orchestrator core.
149
149
 
150
+ Roles are `investigation`, `implementation`, `review` and `audit`. `audit` is a
151
+ read-only inspection of a change already in `in-validation` — after review has
152
+ already passed — for a human or orchestrator to consult before accepting or
153
+ rejecting it; it never moves the change or records a verdict.
154
+
150
155
  ### Upgrading an existing repo's configuration
151
156
 
152
157
  Repos created before ChangeLedger 0.6 may have an older configuration schema.
@@ -18,7 +18,9 @@ import {
18
18
  import { agentContext } from '../src/commands/agent-context.mjs';
19
19
  import { agentPrompt } from '../src/commands/agent-prompt.mjs';
20
20
  import { check } from '../src/commands/check.mjs';
21
+ import { commit } from '../src/commands/commit.mjs';
21
22
  import { context } from '../src/commands/context.mjs';
23
+ import { fix } from '../src/commands/fix.mjs';
22
24
  import {
23
25
  graduate,
24
26
  pendingGraduation,
@@ -29,6 +31,7 @@ import { init } from '../src/commands/init.mjs';
29
31
  import { newChange } from '../src/commands/new.mjs';
30
32
  import { registerRepo } from '../src/commands/register.mjs';
31
33
  import { initReleaseHistory, recordRelease, releasePlan } from '../src/commands/release.mjs';
34
+ import { runSearch } from '../src/commands/search.mjs';
32
35
  import { view } from '../src/commands/view.mjs';
33
36
  import { findChangeledgerDir } from '../src/config.mjs';
34
37
  import { applyMigration } from '../src/config-migration.mjs';
@@ -41,9 +44,9 @@ const USAGE = `ChangeLedger (changeledger)
41
44
  Run \`changeledger context\` first in any repo unless a ChangeLedger delegation
42
45
  prompt identifies your role and tells you to run \`agent-context\` instead.
43
46
 
44
- changeledger init | register | new | view | check | context | agent-context
45
- changeledger status | discard | review | owner | archive | unarchive
46
- changeledger log | task | list | show | graduate | config | release
47
+ changeledger init | register | new | view | check | fix | context | agent-context
48
+ changeledger commit | status | discard | review | owner | archive
49
+ changeledger log | task | list | show | search | graduate | config | release
47
50
 
48
51
  Run \`changeledger <command> --help\` for that command's syntax, values and examples.`;
49
52
 
@@ -60,6 +63,11 @@ function action(fn) {
60
63
  };
61
64
  }
62
65
 
66
+ // Collects a repeatable option (e.g. `--id`) into an array across invocations.
67
+ function collect(value, previous) {
68
+ return previous.concat([value]);
69
+ }
70
+
63
71
  program
64
72
  .name('changeledger')
65
73
  .description('ChangeLedger (changeledger)')
@@ -136,9 +144,25 @@ program
136
144
  .description('validate the repo or one change')
137
145
  .argument('[id]')
138
146
  .option('--json', 'print JSON')
147
+ .option(
148
+ '--commits [base]',
149
+ 'lint commit subjects on <base>..HEAD for the [#id] marker (base auto-detected if omitted)',
150
+ )
151
+ .addHelpText(
152
+ 'after',
153
+ ['', 'Examples:', ' changeledger check --commits', ' changeledger check --commits main'].join(
154
+ '\n',
155
+ ),
156
+ )
139
157
  .action((id, options) => {
140
158
  try {
141
- const args = [...(id ? [id] : []), ...(options.json ? ['--json'] : [])];
159
+ const args = [
160
+ ...(id ? [id] : []),
161
+ ...(options.json ? ['--json'] : []),
162
+ ...(options.commits !== undefined
163
+ ? ['--commits', ...(typeof options.commits === 'string' ? [options.commits] : [])]
164
+ : []),
165
+ ];
142
166
  process.exit(check(args));
143
167
  } catch (e) {
144
168
  console.error(`Error: ${e.message}`);
@@ -146,6 +170,52 @@ program
146
170
  }
147
171
  });
148
172
 
173
+ program
174
+ .command('fix')
175
+ .description('repair mechanical, unambiguous format defects (or one change)')
176
+ .argument('[id]')
177
+ .option('--dry-run', 'print the proposed diff without writing')
178
+ .action((id, options) => {
179
+ try {
180
+ const args = [...(id ? [id] : []), ...(options.dryRun ? ['--dry-run'] : [])];
181
+ process.exit(fix(args));
182
+ } catch (e) {
183
+ console.error(`Error: ${e.message}`);
184
+ process.exit(1);
185
+ }
186
+ });
187
+
188
+ program
189
+ .command('commit')
190
+ .description('compose the canonical [#id] marker and create a git commit')
191
+ .requiredOption('-m, --message <subject>', 'conventional subject: type(scope): description')
192
+ .option(
193
+ '--id <change-id>',
194
+ 'change id to reference (repeatable); auto-resolved from the single in-progress change if omitted',
195
+ collect,
196
+ [],
197
+ )
198
+ .addHelpText(
199
+ 'after',
200
+ [
201
+ '',
202
+ 'When --id is omitted, the single in-progress change is used automatically;',
203
+ 'zero or multiple in-progress changes require --id explicitly. Repeat --id',
204
+ 'for a multi-id subject: each id gets its own bracket ([#A] [#B]).',
205
+ '',
206
+ 'Examples:',
207
+ ' changeledger commit -m "feat(cli): add helper"',
208
+ ' changeledger commit -m "feat(cli): add helper" --id 20260711-000001',
209
+ ' changeledger commit -m "feat(cli): add helper" --id 20260711-000001 --id 20260711-000002',
210
+ ].join('\n'),
211
+ )
212
+ .action(
213
+ action((options) => {
214
+ const subject = commit({ message: options.message, ids: options.id });
215
+ console.log(`Committed: ${subject}`);
216
+ }),
217
+ );
218
+
149
219
  program
150
220
  .command('context')
151
221
  .description('print deterministic task context')
@@ -153,6 +223,10 @@ program
153
223
  '[mode-or-change-id]',
154
224
  'spec|implement|review|release, or a change id (pack inferred from its status)',
155
225
  )
226
+ .option(
227
+ '--have <rev>',
228
+ 'skip the full reload when this matches the current rev (short `unchanged` confirmation instead)',
229
+ )
156
230
  .addHelpText(
157
231
  'after',
158
232
  [
@@ -173,6 +247,10 @@ program
173
247
  'are inferred the same way from the change id; they are not modes you pass',
174
248
  'explicitly.',
175
249
  '',
250
+ 'Each BEGIN line carries `rev:<hash>`. After a compaction, pass the rev your',
251
+ 'retained capture carried as `--have <rev>`: a match prints a short confirmation',
252
+ 'instead of reloading the full body; a mismatch prints the complete output.',
253
+ '',
176
254
  'Examples:',
177
255
  ' changeledger context',
178
256
  ' changeledger context spec',
@@ -180,14 +258,15 @@ program
180
258
  ' changeledger context review',
181
259
  ' changeledger context release',
182
260
  ' changeledger context 20260630-225212',
261
+ ' changeledger context --have 0123456789ab',
183
262
  ].join('\n'),
184
263
  )
185
- .action(action((input) => context(input)));
264
+ .action(action((input, options) => context(input, { have: options.have })));
186
265
 
187
266
  program
188
267
  .command('agent-prompt')
189
268
  .description('print a portable delegation prompt skeleton for a role')
190
- .argument('<role>', 'investigation | implementation | review')
269
+ .argument('<role>', 'investigation | implementation | review | audit')
191
270
  .addHelpText(
192
271
  'after',
193
272
  [
@@ -195,10 +274,14 @@ program
195
274
  'Prints a fill-in-the-blanks delegation prompt for the given role. Works',
196
275
  'outside a ChangeLedger repo — the skeletons ship inside the package.',
197
276
  '',
277
+ '`audit` is a read-only inspection of a change already in `in-validation`,',
278
+ 'after review already passed; it never issues a verdict or moves the change.',
279
+ '',
198
280
  'Examples:',
199
281
  ' changeledger agent-prompt investigation',
200
282
  ' changeledger agent-prompt implementation',
201
283
  ' changeledger agent-prompt review',
284
+ ' changeledger agent-prompt audit',
202
285
  ].join('\n'),
203
286
  )
204
287
  .action(action((role) => agentPrompt(role)));
@@ -206,8 +289,11 @@ program
206
289
  program
207
290
  .command('agent-context')
208
291
  .description('print a self-contained minimal context for a delegated role')
209
- .argument('<role>', 'investigation | implementation | review')
210
- .argument('[change-id]', 'optional for investigation; required for implementation and review')
292
+ .argument('<role>', 'investigation | implementation | review | audit')
293
+ .argument(
294
+ '[change-id]',
295
+ 'optional for investigation; required for implementation, review and audit',
296
+ )
211
297
  .addHelpText(
212
298
  'after',
213
299
  [
@@ -216,11 +302,15 @@ program
216
302
  'identifies you as that role. This replaces the normal core bootstrap for',
217
303
  'the delegated leaf; normal agents still run `changeledger context` first.',
218
304
  '',
305
+ '`audit` requires a change in `in-validation`; it is read-only inspection',
306
+ 'after review already passed, and never issues a verdict or moves the change.',
307
+ '',
219
308
  'Examples:',
220
309
  ' changeledger agent-context investigation',
221
310
  ' changeledger agent-context investigation <id>',
222
311
  ' changeledger agent-context implementation <id>',
223
312
  ' changeledger agent-context review <id>',
313
+ ' changeledger agent-context audit <id>',
224
314
  ].join('\n'),
225
315
  )
226
316
  .action(action((role, changeId) => agentContext(role, changeId)));
@@ -356,6 +446,8 @@ program
356
446
  ' changeledger archive <id>',
357
447
  ' changeledger archive --graduated',
358
448
  ' changeledger archive --graduated --dry-run',
449
+ '',
450
+ 'To reverse an archive, edit `archived: false` in the change frontmatter directly.',
359
451
  ].join('\n'),
360
452
  )
361
453
  .action(
@@ -371,22 +463,11 @@ program
371
463
  }
372
464
  if (options.dryRun) throw new Error('--dry-run requires --graduated');
373
465
  if (!id) throw new Error('archive requires <id> or --graduated');
374
- archive(id, true);
466
+ archive(id);
375
467
  console.log(`#${id} archived`);
376
468
  }),
377
469
  );
378
470
 
379
- program
380
- .command('unarchive')
381
- .description('show a change in the viewer')
382
- .argument('<id>')
383
- .action(
384
- action((id) => {
385
- archive(id, false);
386
- console.log(`#${id} unarchived`);
387
- }),
388
- );
389
-
390
471
  program
391
472
  .command('log')
392
473
  .description('append a timestamped Log entry')
@@ -465,6 +546,32 @@ program
465
546
  }),
466
547
  );
467
548
 
549
+ program
550
+ .command('search')
551
+ .description('deterministic lexical search over changes (incl. archived) and specs')
552
+ .argument('<query...>', 'search terms')
553
+ .option('--limit <n>', 'max results (default 10)')
554
+ .option(
555
+ '--type <type>',
556
+ 'filter by a change type configured in .changeledger/config.yml (types:); excludes specs',
557
+ )
558
+ .option(
559
+ '--status <status>',
560
+ 'filter by a change status configured in .changeledger/config.yml (statuses:); excludes specs',
561
+ )
562
+ .option('--json', 'print JSON')
563
+ .addHelpText(
564
+ 'after',
565
+ [
566
+ '',
567
+ 'Examples:',
568
+ ' changeledger search wallet',
569
+ ' changeledger search wallet --type bug --status done',
570
+ ' changeledger search "app check" --json',
571
+ ].join('\n'),
572
+ )
573
+ .action(action((queryParts, options) => runSearch(queryParts, options)));
574
+
468
575
  program
469
576
  .command('graduate')
470
577
  .description('graduate a done change to persistent truth')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changeledger",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Turn conversations into buildable changes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/check.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // { errors, warnings }. No IO — the `changeledger check` command does the IO and printing.
3
3
 
4
4
  import { parseChange } from './change.mjs';
5
+ import { hasFixableDefects } from './fix.mjs';
5
6
  import { CANONICAL_STATUSES, canTransition, parseLogEvent } from './lifecycle.mjs';
6
7
  import { compareVersions, parseVersion, RELEASE_IMPACTS } from './release.mjs';
7
8
 
@@ -32,6 +33,7 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
32
33
  const fm = c.frontmatter ?? {};
33
34
 
34
35
  checkConflictMarkers(c, err);
36
+ checkAutoFixable(c, fm, warn);
35
37
 
36
38
  for (const k of REQUIRED) if (!(k in fm)) err(c, `missing frontmatter "${k}"`);
37
39
  if (fm.created && !ISO_UTC.test(fm.created)) err(c, `created not ISO 8601 UTC: ${fm.created}`);
@@ -317,6 +319,16 @@ function* graduationMarkers(change) {
317
319
  // underline, but ChangeLedger uses ATX headings, so this is safe in practice.
318
320
  const CONFLICT = /^(<{7}|={7}|>{7})(\s|$)/;
319
321
 
322
+ // Hints at `changeledger fix` rather than duplicating its repair rules: reuses
323
+ // the same pure detector so the two commands can never disagree about what
324
+ // counts as auto-fixable.
325
+ function checkAutoFixable(c, fm, warn) {
326
+ if (typeof c.text !== 'string') return;
327
+ if (hasFixableDefects(c.text)) {
328
+ warn(c, `auto-fixable format defect(s) found; run: changeledger fix ${fm.id ?? ''}`.trimEnd());
329
+ }
330
+ }
331
+
320
332
  function checkConflictMarkers(c, err) {
321
333
  if (typeof c.text !== 'string') return;
322
334
  const lines = c.text.split('\n');
@@ -7,10 +7,11 @@ import { contractTemplatesDir } from '../paths.mjs';
7
7
  import { resolveChange } from '../repo.mjs';
8
8
  import { transversalPolicy } from './context.mjs';
9
9
 
10
- const ROLES = ['investigation', 'implementation', 'review'];
10
+ const ROLES = ['investigation', 'implementation', 'review', 'audit'];
11
11
  const ALLOWED_STATUSES = {
12
12
  implementation: ['approved', 'in-progress'],
13
13
  review: ['in-review'],
14
+ audit: ['in-validation'],
14
15
  };
15
16
 
16
17
  function requireRepo(cwd) {
@@ -5,7 +5,7 @@ import { contractTemplatesDir } from '../paths.mjs';
5
5
 
6
6
  // Portable role skeletons ship inside the package, so this command works even
7
7
  // outside an initialized ChangeLedger repo — it never reads project config.
8
- const ROLES = ['investigation', 'implementation', 'review'];
8
+ const ROLES = ['investigation', 'implementation', 'review', 'audit'];
9
9
 
10
10
  export function buildAgentPrompt(role) {
11
11
  if (!ROLES.includes(role)) {
@@ -212,11 +212,11 @@ export function discard(id, reason, cwd = process.cwd()) {
212
212
  return file;
213
213
  }
214
214
 
215
- export function archive(id, on, cwd = process.cwd()) {
215
+ export function archive(id, cwd = process.cwd()) {
216
216
  const { file } = locate(cwd, id);
217
217
  mutateFileAtomic(file, (text) => {
218
- text = setArchived(text, on);
219
- return appendLog(text, nowUtc(), on ? 'archived' : 'unarchived');
218
+ text = setArchived(text, true);
219
+ return appendLog(text, nowUtc(), 'archived');
220
220
  });
221
221
  return file;
222
222
  }
@@ -1,12 +1,67 @@
1
1
  import { checkRepo } from '../check.mjs';
2
+ import { findChangeledgerDir, integrationBranch, loadConfig } from '../config.mjs';
2
3
  import { getSchemaVersion, SUPPORTED_SCHEMA_VERSION } from '../config-migration.mjs';
3
4
  import { checkContract } from '../contract.mjs';
5
+ import { defaultBaseBranch, lintCommitRange } from '../git.mjs';
4
6
  import { loadRepo } from '../repo.mjs';
5
7
 
8
+ // Declared integration branch, when a ChangeLedger repo (and the key) exists.
9
+ // Outside a repo the lint still works on plain git, so absence is undefined,
10
+ // not an error; a malformed declared value still fails fast in
11
+ // `integrationBranch`.
12
+ function configuredIntegrationBranch(cwd) {
13
+ const changeledgerDir = findChangeledgerDir(cwd);
14
+ if (!changeledgerDir) return undefined;
15
+ return integrationBranch(loadConfig(changeledgerDir));
16
+ }
17
+
18
+ // Lints `base..HEAD` for the canonical `[#id]` commit marker (merges and
19
+ // `chore(release)` prep are exempt) — no ChangeLedger repo required, just git.
20
+ function checkCommits(args, commitsIdx, cwd, output, json) {
21
+ const provided = args[commitsIdx + 1];
22
+ const base = provided && !provided.startsWith('--') ? provided : undefined;
23
+
24
+ let resolvedBase;
25
+ let violations;
26
+ try {
27
+ resolvedBase = base ?? configuredIntegrationBranch(cwd) ?? defaultBaseBranch(cwd);
28
+ violations = lintCommitRange(cwd, `${resolvedBase}..HEAD`);
29
+ } catch (e) {
30
+ if (json)
31
+ output.log(
32
+ JSON.stringify(
33
+ { errors: [{ file: '(commits)', message: e.message }], warnings: [] },
34
+ null,
35
+ 2,
36
+ ),
37
+ );
38
+ else output.error(` error (commits): ${e.message}`);
39
+ return 1;
40
+ }
41
+
42
+ const errors = violations.map((v) => ({
43
+ file: '(commits)',
44
+ message: `${v.sha} missing [#id] marker: "${v.subject}"`,
45
+ }));
46
+
47
+ if (json) {
48
+ output.log(JSON.stringify({ errors, warnings: [] }, null, 2));
49
+ return errors.length ? 1 : 0;
50
+ }
51
+
52
+ for (const e of errors) output.error(` error ${e.file}: ${e.message}`);
53
+ const scope = `commits ${resolvedBase}..HEAD`;
54
+ if (!errors.length) output.log(`✓ ${scope} valid`);
55
+ else output.log(`\n${errors.length} error(s) — ${scope}`);
56
+ return errors.length ? 1 : 0;
57
+ }
58
+
6
59
  // Validates the repo (or a single change with `changeledger check <id>`). Prints findings
7
60
  // and returns an exit code (1 if errors).
8
61
  export function check(args = [], cwd = process.cwd(), output = console) {
9
62
  const json = args.includes('--json');
63
+ const commitsIdx = args.indexOf('--commits');
64
+ if (commitsIdx !== -1) return checkCommits(args, commitsIdx, cwd, output, json);
10
65
  const id = args.find((a) => !a.startsWith('--'));
11
66
 
12
67
  let repo;
@@ -0,0 +1,39 @@
1
+ // Executable commit contract: composes the canonical `[#id]` marker and
2
+ // delegates to `git commit`, instead of relying on agents to remember the
3
+ // convention documented in prose (templates/contract/implement.md).
4
+
5
+ import { mutatingRun } from '../git.mjs';
6
+ import { loadRepo } from '../repo.mjs';
7
+
8
+ const SUBJECT_RE = /^[a-zA-Z]+\([^()]+\):\s+\S.*/;
9
+
10
+ // Validates the subject, resolves the change id(s) to append, and creates the
11
+ // commit. Never invokes git unless the subject and id resolution both succeed
12
+ // — no partial/incorrect commit is ever created. Returns the final subject.
13
+ export function commit({ message, ids = [] } = {}, cwd = process.cwd(), run = mutatingRun) {
14
+ if (!message || !SUBJECT_RE.test(message)) {
15
+ throw new Error(
16
+ `Subject must follow the conventional form "type(scope): description", got: "${message ?? ''}"`,
17
+ );
18
+ }
19
+
20
+ const repo = loadRepo(cwd);
21
+ let resolvedIds = ids;
22
+ if (!resolvedIds.length) {
23
+ const active = repo.changes.filter((c) => c.frontmatter.status === 'in-progress');
24
+ if (active.length !== 1) {
25
+ if (active.length === 0) {
26
+ throw new Error('No change is in-progress; pass --id <change-id> explicitly.');
27
+ }
28
+ const candidates = active.map((c) => c.frontmatter.id).join(', ');
29
+ throw new Error(
30
+ `Ambiguous: ${active.length} changes are in-progress (${candidates}); pass --id <change-id> explicitly.`,
31
+ );
32
+ }
33
+ resolvedIds = [active[0].frontmatter.id];
34
+ }
35
+
36
+ const subject = `${message} ${resolvedIds.map((id) => `[#${id}]`).join(' ')}`;
37
+ run(['commit', '-m', subject], repo.repoRoot);
38
+ return subject;
39
+ }
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseChange } from '../change.mjs';
4
- import { findChangeledgerDir, loadConfig } from '../config.mjs';
5
- import { beginSentinel, endSentinel, VERSION } from '../framing.mjs';
4
+ import { findChangeledgerDir, integrationBranch, loadConfig } from '../config.mjs';
5
+ import { beginSentinel, contentRev, endSentinel, VERSION } from '../framing.mjs';
6
6
  import { contractTemplatesDir } from '../paths.mjs';
7
7
  import { resolveChange } from '../repo.mjs';
8
8
 
@@ -31,9 +31,16 @@ function fragment(name) {
31
31
  return fs.readFileSync(path.join(contractTemplatesDir, `${name}.md`), 'utf8').trim();
32
32
  }
33
33
 
34
- function beginDelimiter(mode, changeId) {
34
+ function beginDelimiter(mode, changeId, rev, extra = '') {
35
35
  const change = changeId ? ` — change: #${changeId}` : '';
36
- return beginSentinel('CONTEXT', `mode: ${mode}${change} v${VERSION}`);
36
+ const revPart = rev ? ` — rev:${rev}` : '';
37
+ return beginSentinel('CONTEXT', `mode: ${mode}${change} — v${VERSION}${revPart}${extra}`);
38
+ }
39
+
40
+ // Short confirmation body returned by `--have` when the caller's retained
41
+ // revision still matches: no contract text, just the framed confirmation.
42
+ function unchangedBody(rev) {
43
+ return `Context unchanged since rev:${rev}. Skip reload; continue with the retained capture.`;
37
44
  }
38
45
 
39
46
  // Resolved defaults so an agent never reads `.changeledger/config.yml` raw to
@@ -52,9 +59,12 @@ function effectiveTdd(config) {
52
59
  }
53
60
 
54
61
  // The transversal policy line every composition anchors on: effective language
55
- // and tdd with defaults already resolved.
62
+ // and tdd with defaults already resolved. The integration branch appears only
63
+ // when declared — absence means the repo keeps branch auto-detection.
56
64
  export function transversalPolicy(config) {
57
- return `Effective policy: language=${effectiveLanguage(config)} — tdd=${effectiveTdd(config)}`;
65
+ const base = `Effective policy: language=${effectiveLanguage(config)} — tdd=${effectiveTdd(config)}`;
66
+ const branch = integrationBranch(config);
67
+ return branch ? `${base} — integration_branch=${branch}` : base;
58
68
  }
59
69
 
60
70
  // Type-specific policy for change-id contexts: adds review requirement and the
@@ -88,7 +98,11 @@ function dependencyBlock(dependsOn, cwd) {
88
98
  return `## Dependencies\n\n${lines.join('\n')}`;
89
99
  }
90
100
 
91
- function compose(mode, fragments, options = {}) {
101
+ // Composes the body (everything between the BEGIN and END lines), derives its
102
+ // `rev` from that body alone — never from the framing lines that quote it —
103
+ // then returns both the rev and the full rendered text so callers can decide
104
+ // whether a `--have` match makes the full body unnecessary.
105
+ function composeResult(mode, fragments, options = {}) {
92
106
  const {
93
107
  changeText,
94
108
  incremental = true,
@@ -96,14 +110,15 @@ function compose(mode, fragments, options = {}) {
96
110
  policy = undefined,
97
111
  dependencies = undefined,
98
112
  } = options;
99
- const sections = [beginDelimiter(mode, changeId)];
100
- if (incremental) sections.push(INCREMENTAL_NOTICE);
101
- if (policy) sections.push(policy);
102
- sections.push(...fragments.map(fragment));
103
- if (dependencies) sections.push(dependencies);
104
- if (changeText) sections.push('---\n\n# Selected change\n', changeText.trim());
105
- sections.push(END_DELIMITER);
106
- return `${sections.join('\n\n')}\n`;
113
+ const body = [];
114
+ if (incremental) body.push(INCREMENTAL_NOTICE);
115
+ if (policy) body.push(policy);
116
+ body.push(...fragments.map(fragment));
117
+ if (dependencies) body.push(dependencies);
118
+ if (changeText) body.push('---\n\n# Selected change\n', changeText.trim());
119
+ const rev = contentRev(body.join('\n\n'));
120
+ const sections = [beginDelimiter(mode, changeId, rev), ...body, END_DELIMITER];
121
+ return { mode, changeId, rev, text: `${sections.join('\n\n')}\n` };
107
122
  }
108
123
 
109
124
  function requireRepo(cwd) {
@@ -112,14 +127,15 @@ function requireRepo(cwd) {
112
127
  return dir;
113
128
  }
114
129
 
115
- export function buildContext(input, cwd = process.cwd()) {
116
- const changeledgerDir = requireRepo(cwd);
117
- const config = loadConfig(changeledgerDir);
130
+ function composeInput(input, cwd, config) {
118
131
  if (!input) {
119
- return compose('core', ['core'], { incremental: false, policy: transversalPolicy(config) });
132
+ return composeResult('core', ['core'], {
133
+ incremental: false,
134
+ policy: transversalPolicy(config),
135
+ });
120
136
  }
121
137
  if (MODES.includes(input)) {
122
- return compose(input, MODE_CONTEXT[input], { policy: transversalPolicy(config) });
138
+ return composeResult(input, MODE_CONTEXT[input], { policy: transversalPolicy(config) });
123
139
  }
124
140
 
125
141
  let resolved;
@@ -135,7 +151,7 @@ export function buildContext(input, cwd = process.cwd()) {
135
151
  const { id, status, type, depends_on: dependsOn } = parseChange(text).frontmatter;
136
152
  const selected = STATUS_CONTEXT[status];
137
153
  if (!selected) throw new Error(`No context mapping for change status "${status}"`);
138
- return compose(selected.mode, selected.fragments, {
154
+ return composeResult(selected.mode, selected.fragments, {
139
155
  changeText: text,
140
156
  changeId: id,
141
157
  policy: changePolicyBlock(config, type),
@@ -143,6 +159,24 @@ export function buildContext(input, cwd = process.cwd()) {
143
159
  });
144
160
  }
145
161
 
146
- export function context(input, cwd = process.cwd(), output = console.log) {
147
- output(buildContext(input, cwd).trimEnd());
162
+ // `options.have` names a previously captured `rev`. A match returns a short
163
+ // framed `unchanged` confirmation instead of the full contract body; any
164
+ // mismatch (stale or invented) falls back to the complete normal output.
165
+ export function buildContext(input, cwd = process.cwd(), options = {}) {
166
+ const changeledgerDir = requireRepo(cwd);
167
+ const config = loadConfig(changeledgerDir);
168
+ const result = composeInput(input, cwd, config);
169
+ if (options.have && options.have === result.rev) {
170
+ const sections = [
171
+ beginDelimiter(result.mode, result.changeId, result.rev, ' — unchanged'),
172
+ unchangedBody(result.rev),
173
+ END_DELIMITER,
174
+ ];
175
+ return `${sections.join('\n\n')}\n`;
176
+ }
177
+ return result.text;
178
+ }
179
+
180
+ export function context(input, options = {}, cwd = process.cwd(), output = console.log) {
181
+ output(buildContext(input, cwd, options).trimEnd());
148
182
  }