changeledger 0.8.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.
Files changed (48) hide show
  1. package/AGENTS.md +11 -3
  2. package/README.md +10 -1
  3. package/bin/changeledger.mjs +204 -21
  4. package/package.json +1 -1
  5. package/src/check.mjs +12 -0
  6. package/src/commands/agent-context.mjs +65 -0
  7. package/src/commands/agent-prompt.mjs +22 -0
  8. package/src/commands/agent.mjs +13 -10
  9. package/src/commands/check.mjs +55 -0
  10. package/src/commands/commit.mjs +39 -0
  11. package/src/commands/context.mjs +60 -27
  12. package/src/commands/fix.mjs +72 -0
  13. package/src/commands/register.mjs +9 -1
  14. package/src/commands/search.mjs +56 -0
  15. package/src/config-migration.mjs +44 -9
  16. package/src/config.mjs +13 -0
  17. package/src/contract.mjs +73 -15
  18. package/src/fix.mjs +127 -0
  19. package/src/framing.mjs +30 -0
  20. package/src/git.mjs +119 -2
  21. package/src/lifecycle.mjs +2 -2
  22. package/src/metrics.mjs +42 -0
  23. package/src/search.mjs +162 -0
  24. package/src/viewer/domain.mjs +2 -2
  25. package/src/viewer/public/app-state.js +62 -6
  26. package/src/viewer/public/app.js +138 -39
  27. package/src/viewer/public/index.html +2 -2
  28. package/src/viewer/public/state.js +6 -2
  29. package/src/viewer/public/styles.css +76 -5
  30. package/src/viewer/public/view-renderers.js +160 -48
  31. package/src/viewer/server/router.mjs +40 -6
  32. package/templates/config.yml +7 -1
  33. package/templates/contract/agent-contexts/audit.md +22 -0
  34. package/templates/contract/agent-contexts/implementation.md +18 -0
  35. package/templates/contract/agent-contexts/investigation.md +14 -0
  36. package/templates/contract/agent-contexts/review.md +22 -0
  37. package/templates/contract/agent-prompts/audit.md +37 -0
  38. package/templates/contract/agent-prompts/implementation.md +42 -0
  39. package/templates/contract/agent-prompts/investigation.md +41 -0
  40. package/templates/contract/agent-prompts/review.md +36 -0
  41. package/templates/contract/budgets.yml +16 -0
  42. package/templates/contract/close.md +19 -14
  43. package/templates/contract/core.md +41 -30
  44. package/templates/contract/delegation.md +2 -1
  45. package/templates/contract/implement.md +24 -10
  46. package/templates/contract/review.md +7 -9
  47. package/templates/contract/spec.md +14 -1
  48. package/templates/contract/validation.md +1 -1
@@ -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,13 +1,12 @@
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 { contractTemplatesDir, packageRoot } from '../paths.mjs';
4
+ import { findChangeledgerDir, integrationBranch, loadConfig } from '../config.mjs';
5
+ import { beginSentinel, contentRev, endSentinel, VERSION } from '../framing.mjs';
6
+ import { contractTemplatesDir } from '../paths.mjs';
6
7
  import { resolveChange } from '../repo.mjs';
7
8
 
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 =====';
9
+ const END_DELIMITER = endSentinel('CONTEXT');
11
10
  const MODES = ['implement', 'review', 'spec', 'release'];
12
11
  const MODE_CONTEXT = {
13
12
  implement: ['implement', 'delegation', 'handoff'],
@@ -32,9 +31,16 @@ function fragment(name) {
32
31
  return fs.readFileSync(path.join(contractTemplatesDir, `${name}.md`), 'utf8').trim();
33
32
  }
34
33
 
35
- function beginDelimiter(mode, changeId) {
34
+ function beginDelimiter(mode, changeId, rev, extra = '') {
36
35
  const change = changeId ? ` — change: #${changeId}` : '';
37
- return `===== CHANGELEDGER CONTEXT BEGINmode: ${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.`;
38
44
  }
39
45
 
40
46
  // Resolved defaults so an agent never reads `.changeledger/config.yml` raw to
@@ -53,9 +59,12 @@ function effectiveTdd(config) {
53
59
  }
54
60
 
55
61
  // 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)}`;
62
+ // and tdd with defaults already resolved. The integration branch appears only
63
+ // when declared — absence means the repo keeps branch auto-detection.
64
+ export function transversalPolicy(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;
59
68
  }
60
69
 
61
70
  // Type-specific policy for change-id contexts: adds review requirement and the
@@ -89,7 +98,11 @@ function dependencyBlock(dependsOn, cwd) {
89
98
  return `## Dependencies\n\n${lines.join('\n')}`;
90
99
  }
91
100
 
92
- 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 = {}) {
93
106
  const {
94
107
  changeText,
95
108
  incremental = true,
@@ -97,14 +110,15 @@ function compose(mode, fragments, options = {}) {
97
110
  policy = undefined,
98
111
  dependencies = undefined,
99
112
  } = options;
100
- const sections = [beginDelimiter(mode, changeId)];
101
- if (incremental) sections.push(INCREMENTAL_NOTICE);
102
- if (policy) sections.push(policy);
103
- sections.push(...fragments.map(fragment));
104
- if (dependencies) sections.push(dependencies);
105
- if (changeText) sections.push('---\n\n# Selected change\n', changeText.trim());
106
- sections.push(END_DELIMITER);
107
- 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` };
108
122
  }
109
123
 
110
124
  function requireRepo(cwd) {
@@ -113,14 +127,15 @@ function requireRepo(cwd) {
113
127
  return dir;
114
128
  }
115
129
 
116
- export function buildContext(input, cwd = process.cwd()) {
117
- const changeledgerDir = requireRepo(cwd);
118
- const config = loadConfig(changeledgerDir);
130
+ function composeInput(input, cwd, config) {
119
131
  if (!input) {
120
- return compose('core', ['core'], { incremental: false, policy: transversalPolicy(config) });
132
+ return composeResult('core', ['core'], {
133
+ incremental: false,
134
+ policy: transversalPolicy(config),
135
+ });
121
136
  }
122
137
  if (MODES.includes(input)) {
123
- return compose(input, MODE_CONTEXT[input], { policy: transversalPolicy(config) });
138
+ return composeResult(input, MODE_CONTEXT[input], { policy: transversalPolicy(config) });
124
139
  }
125
140
 
126
141
  let resolved;
@@ -136,7 +151,7 @@ export function buildContext(input, cwd = process.cwd()) {
136
151
  const { id, status, type, depends_on: dependsOn } = parseChange(text).frontmatter;
137
152
  const selected = STATUS_CONTEXT[status];
138
153
  if (!selected) throw new Error(`No context mapping for change status "${status}"`);
139
- return compose(selected.mode, selected.fragments, {
154
+ return composeResult(selected.mode, selected.fragments, {
140
155
  changeText: text,
141
156
  changeId: id,
142
157
  policy: changePolicyBlock(config, type),
@@ -144,6 +159,24 @@ export function buildContext(input, cwd = process.cwd()) {
144
159
  });
145
160
  }
146
161
 
147
- export function context(input, cwd = process.cwd(), output = console.log) {
148
- 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());
149
182
  }
@@ -0,0 +1,72 @@
1
+ import { writeFileAtomic } from '../atomic-write.mjs';
2
+ import { computeFixes } from '../fix.mjs';
3
+ import { loadRepo } from '../repo.mjs';
4
+
5
+ // Repairs mechanical, unambiguous format defects (`changeledger fix [id] [--dry-run]`).
6
+ // Ambiguous defects are never touched — they are listed under "requires manual fix".
7
+ export function fix(args = [], cwd = process.cwd(), output = console) {
8
+ const dryRun = args.includes('--dry-run');
9
+ const id = args.find((a) => !a.startsWith('--'));
10
+
11
+ let repo;
12
+ try {
13
+ repo = loadRepo(cwd);
14
+ } catch (e) {
15
+ output.error(` error (repo): ${e.message}`);
16
+ return 1;
17
+ }
18
+
19
+ let targets = repo.changes;
20
+ if (id) {
21
+ targets = repo.changes.filter((c) => String(c.frontmatter?.id) === String(id));
22
+ if (!targets.length) {
23
+ output.error(` error no change with id "${id}"`);
24
+ return 1;
25
+ }
26
+ }
27
+
28
+ let anyChanged = false;
29
+ let anyManual = false;
30
+
31
+ for (const c of targets) {
32
+ const { text: fixedText, applied, manual, changed } = computeFixes(c.text);
33
+
34
+ if (manual.length) {
35
+ anyManual = true;
36
+ output.log(`requires manual fix — ${c.name}:`);
37
+ for (const m of manual) output.log(` - ${m}`);
38
+ }
39
+
40
+ if (!changed) {
41
+ output.log(id ? 'nothing to fix' : `${c.name}: nothing to fix`);
42
+ continue;
43
+ }
44
+
45
+ anyChanged = true;
46
+ if (dryRun) {
47
+ output.log(`--- ${c.name} (dry run)`);
48
+ for (const line of diffLines(c.text, fixedText)) output.log(line);
49
+ continue;
50
+ }
51
+
52
+ writeFileAtomic(c.file, fixedText);
53
+ output.log(`fixed — ${c.name}:`);
54
+ for (const a of applied) output.log(` - ${a}`);
55
+ }
56
+
57
+ if (!anyChanged && !anyManual) output.log('nothing to fix');
58
+ return 0;
59
+ }
60
+
61
+ function diffLines(before, after) {
62
+ const a = before.split('\n');
63
+ const b = after.split('\n');
64
+ const out = [];
65
+ const len = Math.max(a.length, b.length);
66
+ for (let i = 0; i < len; i++) {
67
+ if (a[i] === b[i]) continue;
68
+ if (a[i] !== undefined) out.push(`- ${a[i]}`);
69
+ if (b[i] !== undefined) out.push(`+ ${b[i]}`);
70
+ }
71
+ return out;
72
+ }
@@ -33,7 +33,15 @@ export function registerRepo(cwd = process.cwd(), output = console) {
33
33
 
34
34
  removeLegacyContract(changeledgerDir);
35
35
  removeLegacyGitignore(repoRoot);
36
- if (fs.existsSync(rootContract(repoRoot))) ensureReference(repoRoot);
36
+ if (fs.existsSync(rootContract(repoRoot))) {
37
+ for (const { name, status } of ensureReference(repoRoot)) {
38
+ if (status === 'updated') {
39
+ output.warn(
40
+ `warn ${name}: ChangeLedger bootstrap was outdated; updated to the current version`,
41
+ );
42
+ }
43
+ }
44
+ }
37
45
 
38
46
  register({ id: config.project_id, name, path: repoRoot });
39
47
  return { id: config.project_id, name, path: repoRoot };
@@ -0,0 +1,56 @@
1
+ // `changeledger search` — deterministic lexical discovery over changes
2
+ // (including archived) and specs. See change 20260711-103758.
3
+
4
+ import { loadRepo } from '../repo.mjs';
5
+ import { buildCorpus, searchDocuments } from '../search.mjs';
6
+
7
+ // Runs the search against the repo at `cwd` and returns ranked hits. Pure
8
+ // query, no mutation.
9
+ export function search(query, { limit, type, status } = {}, cwd = process.cwd()) {
10
+ const { changes, specs } = loadRepo(cwd);
11
+ const corpus = buildCorpus({ changes, specs });
12
+ return searchDocuments(corpus, query, { limit, type, status });
13
+ }
14
+
15
+ function formatLabel(hit) {
16
+ return hit.kind === 'spec' ? hit.ref : `${hit.ref} ${hit.status} ${hit.type}`;
17
+ }
18
+
19
+ // A non-numeric or <1 `--limit` used to degrade silently to "no matches"
20
+ // (see change 20260711-160443); fail fast with a clear error instead.
21
+ function parseLimit(limitStr) {
22
+ if (limitStr === undefined) return undefined;
23
+ const n = Number(limitStr);
24
+ if (!Number.isInteger(n) || n < 1) {
25
+ throw new Error(`--limit must be a whole number >= 1, got "${limitStr}"`);
26
+ }
27
+ return n;
28
+ }
29
+
30
+ // CLI entry point: prints text or `--json`, and `no matches` when nothing scores.
31
+ export function runSearch(queryParts, options = {}, cwd = process.cwd()) {
32
+ const query = queryParts.join(' ').trim();
33
+ const limit = parseLimit(options.limit);
34
+ const hits = search(query, { limit, type: options.type, status: options.status }, cwd);
35
+
36
+ if (options.json) {
37
+ console.log(
38
+ JSON.stringify(
39
+ hits.map(({ ref, title, score, snippet }) => ({ ref, title, score, snippet })),
40
+ null,
41
+ 2,
42
+ ),
43
+ );
44
+ return;
45
+ }
46
+
47
+ if (!hits.length) {
48
+ console.log('no matches');
49
+ return;
50
+ }
51
+
52
+ for (const hit of hits) {
53
+ console.log(`${formatLabel(hit)} — ${hit.title}`);
54
+ console.log(` ${hit.snippet}`);
55
+ }
56
+ }
@@ -4,7 +4,7 @@ import { parseDocument } from 'yaml';
4
4
  import { writeFileAtomic } from './atomic-write.mjs';
5
5
  import { templatesDir } from './paths.mjs';
6
6
 
7
- export const SUPPORTED_SCHEMA_VERSION = 1;
7
+ export const SUPPORTED_SCHEMA_VERSION = 2;
8
8
 
9
9
  const CANONICAL_STATUSES = [
10
10
  'draft',
@@ -59,13 +59,33 @@ export function buildMigration(originalText) {
59
59
 
60
60
  const changes = [];
61
61
 
62
- // schema_version: 1 remove any existing value, then prepend to appear first
63
- if (Object.hasOwn(config, 'schema_version')) {
64
- doc.delete('schema_version');
62
+ // schema_version — update in place when the key exists at version >= 1 (keeps
63
+ // its comment and position); otherwise remove any explicit 0 and prepend so
64
+ // the key appears first.
65
+ if (Object.hasOwn(config, 'schema_version') && current >= 1) {
66
+ doc.set('schema_version', SUPPORTED_SCHEMA_VERSION);
67
+ changes.push(`updated schema_version: ${current} → ${SUPPORTED_SCHEMA_VERSION}`);
68
+ } else {
69
+ if (Object.hasOwn(config, 'schema_version')) {
70
+ doc.delete('schema_version');
71
+ }
72
+ doc.contents.items.unshift(doc.createPair('schema_version', SUPPORTED_SCHEMA_VERSION));
73
+ changes.push(`added schema_version: ${SUPPORTED_SCHEMA_VERSION}`);
74
+ }
75
+
76
+ if (current < 1) {
77
+ migrateToV1(doc, config, changes);
65
78
  }
66
- doc.contents.items.unshift(doc.createPair('schema_version', 1));
67
- changes.push('added schema_version: 1');
79
+ migrateToV2(doc, config, changes);
80
+
81
+ // No line wrapping and no flow padding: keeps untouched flow sequences
82
+ // (statuses, stages) byte-identical to their common written form.
83
+ const yaml = doc.toString({ lineWidth: 0, flowCollectionPadding: false });
84
+ return { yaml, changes, fromVersion: current };
85
+ }
68
86
 
87
+ // 0 → 1: structural additions and managed-comment refresh.
88
+ function migrateToV1(doc, config, changes) {
69
89
  // tdd: true if absent
70
90
  if (!Object.hasOwn(config, 'tdd')) {
71
91
  doc.set('tdd', true);
@@ -125,8 +145,23 @@ export function buildMigration(originalText) {
125
145
  const templateComments = loadTemplateComments();
126
146
  const commentChanges = refreshManagedComments(doc, templateComments);
127
147
  changes.push(...commentChanges);
148
+ }
128
149
 
129
- return { yaml: doc.toString(), changes };
150
+ // 1 → 2: additive quick type. Existing custom `quick` definitions and impacts
151
+ // are never touched.
152
+ function migrateToV2(doc, config, changes) {
153
+ const configTypes = config.types ?? {};
154
+ if (!Object.hasOwn(configTypes, 'quick')) {
155
+ const stagesNode = doc.createNode(['request', 'log']);
156
+ stagesNode.flow = true;
157
+ doc.setIn(['types', 'quick', 'stages'], stagesNode);
158
+ changes.push('added types.quick with stages: [request, log]');
159
+ }
160
+ const currentImpacts = config.release?.impacts ?? {};
161
+ if (!Object.hasOwn(currentImpacts, 'quick')) {
162
+ doc.setIn(['release', 'impacts', 'quick'], 'patch');
163
+ changes.push('added release.impacts.quick: patch');
164
+ }
130
165
  }
131
166
 
132
167
  // Apply migration to a file (or dry-run). Returns summary string.
@@ -145,8 +180,8 @@ export function applyMigration(configFile, { dryRun = false } = {}) {
145
180
  }
146
181
 
147
182
  const header = dryRun
148
- ? `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION} (dry run)`
149
- : `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION}`;
183
+ ? `Config migration ${result.fromVersion} → ${SUPPORTED_SCHEMA_VERSION} (dry run)`
184
+ : `Config migration ${result.fromVersion} → ${SUPPORTED_SCHEMA_VERSION}`;
150
185
 
151
186
  const summary = [header, ...result.changes.map((c) => ` - ${c}`)].join('\n');
152
187
 
package/src/config.mjs CHANGED
@@ -69,6 +69,19 @@ function isInside(root, target) {
69
69
  return target === root || target.startsWith(root + path.sep);
70
70
  }
71
71
 
72
+ // Optional declared integration branch: change branches start from it and
73
+ // merge back into it. Absent means the caller keeps its current auto-detection
74
+ // (`defaultBaseBranch`); a present but malformed value fails fast instead of
75
+ // silently falling back.
76
+ export function integrationBranch(config) {
77
+ const value = config?.git?.integration_branch;
78
+ if (value === undefined || value === null) return undefined;
79
+ if (typeof value !== 'string' || value.trim() === '') {
80
+ throw new Error('config "git.integration_branch" must be a non-empty string');
81
+ }
82
+ return value.trim();
83
+ }
84
+
72
85
  // Single source of the specs directory: the configured `specs_dir` or the
73
86
  // default, always resolved through the containment guard. Shared by `loadRepo`
74
87
  // and `graduate` so a graduated spec lands where the repo will later read it.
package/src/contract.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { writeFileAtomic } from './atomic-write.mjs';
5
5
 
6
6
  const CONTRACT_FILES = ['AGENTS.md', 'CLAUDE.md'];
7
- const MARKER = '<!-- changeledger -->';
7
+ const LEGACY_MARKER = '<!-- changeledger -->';
8
8
  const LEGACY_ENTRY = '.changeledger/AGENTS.md';
9
9
  // Exact SHA-256 digests of every historical templates/AGENTS.md payload. A
10
10
  // regular file is removed only when it is byte-identical to a contract version
@@ -34,11 +34,23 @@ const LEGACY_CONTRACT_HASHES = new Set([
34
34
  '99a42380f3835e1f706b986b86e58e33f12d68de4a69d94eb50845e790e05f71',
35
35
  ]);
36
36
 
37
- export const REFERENCE = `${MARKER}
38
- > [!IMPORTANT]
37
+ // Bootstrap block format version. Independent of the package version: bump
38
+ // only when the delimited block's shape or required content changes in a way
39
+ // that must be detected and re-registered in consuming repos.
40
+ export const BOOTSTRAP_VERSION = 2;
41
+
42
+ const BEGIN_RE = /<!-- CHANGELEDGER BOOTSTRAP BEGIN v(\d+) -->/;
43
+ const END_MARKER = '<!-- CHANGELEDGER BOOTSTRAP END -->';
44
+ const beginMarker = (version) => `<!-- CHANGELEDGER BOOTSTRAP BEGIN v${version} -->`;
45
+
46
+ export const REFERENCE = `> [!IMPORTANT]
39
47
  > This repo uses **ChangeLedger**. Immediately after reading this file — before
40
- > planning, investigating, or acting — run \`changeledger context\` directly.
41
- > On the first invocation, retain complete stdout through the \`CHANGELEDGER CONTEXT END\` line:
48
+ > planning, investigating, or acting — a normal agent must run \`changeledger context\` directly.
49
+ > Only a delegated leaf whose prompt was emitted by \`changeledger agent-prompt <role>\`
50
+ > runs \`changeledger agent-context <role> [change-id]\` instead; the role in the
51
+ > prompt and command must match. No other agent may skip the core context.
52
+ > On the first invocation, retain complete stdout through the \`CHANGELEDGER CONTEXT END\` line,
53
+ > or the \`CHANGELEDGER AGENT CONTEXT END\` line for that delegated path:
42
54
  > no pipes, filters, summaries, previews or voluntary output limits. If the tool
43
55
  > exposes an output budget, reserve enough for the whole response. A missing END
44
56
  > after that is exceptional recovery: stop and re-run with a larger capture. If
@@ -47,8 +59,15 @@ export const REFERENCE = `${MARKER}
47
59
  >
48
60
  > Do not create or modify files without an authorized change; the core context
49
61
  > defines the workflow, the task contexts, and the narrow operational exception.
62
+ > After a compaction, verify a retained capture with \`changeledger context
63
+ > [mode] --have <rev>\` (the BEGIN line's \`rev:\`) instead of recapturing in
64
+ > full; a mismatch still returns the complete output.
50
65
  `;
51
66
 
67
+ function bootstrapBlock(version = BOOTSTRAP_VERSION) {
68
+ return `${beginMarker(version)}\n${REFERENCE}${END_MARKER}\n`;
69
+ }
70
+
52
71
  export const contractLink = (changeledgerDir) => path.join(changeledgerDir, 'AGENTS.md');
53
72
  export const rootContract = (repoRoot) => path.join(repoRoot, 'AGENTS.md');
54
73
 
@@ -60,28 +79,66 @@ function isPlainFile(file) {
60
79
  }
61
80
  }
62
81
 
63
- function replaceReference(text) {
64
- const start = text.indexOf(MARKER);
65
- if (start === -1) return `${text}${text.endsWith('\n') ? '' : '\n'}\n${REFERENCE}`;
82
+ function insertBlock(text) {
83
+ const sep = text.length === 0 || text.endsWith('\n') ? '' : '\n';
84
+ const gap = text.length === 0 ? '' : '\n';
85
+ return `${text}${sep}${gap}${bootstrapBlock()}`;
86
+ }
87
+
88
+ // Migrate the legacy `<!-- changeledger -->` marker and its contiguous
89
+ // blockquote to the delimited format.
90
+ function migrateLegacy(text, start) {
66
91
  const tail = text.slice(start).split('\n');
67
92
  let consumed = 1;
68
93
  while (consumed < tail.length && tail[consumed].startsWith('>')) consumed += 1;
69
94
  const before = text.slice(0, start);
70
95
  const after = tail.slice(consumed).join('\n').replace(/^\n+/, '');
71
- return `${before}${REFERENCE}${after ? `\n${after}` : ''}`;
96
+ return `${before}${bootstrapBlock()}${after ? `\n${after}` : ''}`;
97
+ }
98
+
99
+ // Replace only the interior of an existing BEGIN/END block, preserving
100
+ // everything outside the delimiters byte-for-byte.
101
+ function replaceDelimited(text, beginIndex, version) {
102
+ const endIndex = text.indexOf(END_MARKER, beginIndex);
103
+ if (endIndex === -1) {
104
+ throw new Error(
105
+ 'Malformed ChangeLedger bootstrap: found a BEGIN marker without a matching END marker',
106
+ );
107
+ }
108
+ const before = text.slice(0, beginIndex);
109
+ const after = text.slice(endIndex + END_MARKER.length).replace(/^\n/, '');
110
+ const newText = `${before}${bootstrapBlock()}${after}`;
111
+ const status =
112
+ version < BOOTSTRAP_VERSION ? 'updated' : newText === text ? 'unchanged' : 'replaced';
113
+ return { text: newText, status, fromVersion: version };
114
+ }
115
+
116
+ // Compute the delimited bootstrap block for `text`, inserting it, replacing
117
+ // it, or migrating the legacy marker as needed.
118
+ export function applyBootstrap(text) {
119
+ const beginMatch = BEGIN_RE.exec(text);
120
+ if (beginMatch) {
121
+ return replaceDelimited(text, beginMatch.index, Number(beginMatch[1]));
122
+ }
123
+ const legacyIndex = text.indexOf(LEGACY_MARKER);
124
+ if (legacyIndex !== -1) {
125
+ return { text: migrateLegacy(text, legacyIndex), status: 'migrated' };
126
+ }
127
+ return { text: insertBlock(text), status: 'inserted' };
72
128
  }
73
129
 
74
- // Add or replace the managed bootstrap block in project-owned agent files.
130
+ // Add, replace, or migrate the managed bootstrap block in project-owned agent
131
+ // files. Returns the files touched with the transition applied to each.
75
132
  export function ensureReference(repoRoot) {
76
133
  const touched = [];
77
134
  for (const name of CONTRACT_FILES) {
78
135
  const file = path.join(repoRoot, name);
79
136
  if (!isPlainFile(file)) continue;
80
137
  const text = fs.readFileSync(file, 'utf8');
81
- const updated = replaceReference(text);
82
- if (updated === text) continue;
138
+ const { text: updated, status, fromVersion } = applyBootstrap(text);
139
+ if (status === 'unchanged') continue;
83
140
  writeFileAtomic(file, updated);
84
- touched.push(name);
141
+ touched.push({ name, status, fromVersion });
85
142
  }
86
143
  return touched;
87
144
  }
@@ -134,9 +191,10 @@ export function checkContract(repoRoot) {
134
191
  const file = path.join(repoRoot, name);
135
192
  if (!isPlainFile(file)) continue;
136
193
  const text = fs.readFileSync(file, 'utf8');
137
- if (!text.includes(MARKER)) {
194
+ const { status } = applyBootstrap(text);
195
+ if (status === 'inserted') {
138
196
  errors.push(`${name} has no ChangeLedger reference — run \`changeledger register\``);
139
- } else if (!text.includes(REFERENCE.trim())) {
197
+ } else if (status !== 'unchanged') {
140
198
  errors.push(`${name} has an outdated ChangeLedger reference — run \`changeledger register\``);
141
199
  }
142
200
  }