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.
- package/AGENTS.md +11 -3
- package/README.md +10 -1
- package/bin/changeledger.mjs +204 -21
- package/package.json +1 -1
- package/src/check.mjs +12 -0
- package/src/commands/agent-context.mjs +65 -0
- package/src/commands/agent-prompt.mjs +22 -0
- package/src/commands/agent.mjs +13 -10
- package/src/commands/check.mjs +55 -0
- package/src/commands/commit.mjs +39 -0
- package/src/commands/context.mjs +60 -27
- package/src/commands/fix.mjs +72 -0
- package/src/commands/register.mjs +9 -1
- package/src/commands/search.mjs +56 -0
- package/src/config-migration.mjs +44 -9
- package/src/config.mjs +13 -0
- package/src/contract.mjs +73 -15
- package/src/fix.mjs +127 -0
- package/src/framing.mjs +30 -0
- package/src/git.mjs +119 -2
- package/src/lifecycle.mjs +2 -2
- package/src/metrics.mjs +42 -0
- package/src/search.mjs +162 -0
- package/src/viewer/domain.mjs +2 -2
- package/src/viewer/public/app-state.js +62 -6
- package/src/viewer/public/app.js +138 -39
- package/src/viewer/public/index.html +2 -2
- package/src/viewer/public/state.js +6 -2
- package/src/viewer/public/styles.css +76 -5
- package/src/viewer/public/view-renderers.js +160 -48
- package/src/viewer/server/router.mjs +40 -6
- package/templates/config.yml +7 -1
- package/templates/contract/agent-contexts/audit.md +22 -0
- package/templates/contract/agent-contexts/implementation.md +18 -0
- package/templates/contract/agent-contexts/investigation.md +14 -0
- package/templates/contract/agent-contexts/review.md +22 -0
- package/templates/contract/agent-prompts/audit.md +37 -0
- package/templates/contract/agent-prompts/implementation.md +42 -0
- package/templates/contract/agent-prompts/investigation.md +41 -0
- package/templates/contract/agent-prompts/review.md +36 -0
- package/templates/contract/budgets.yml +16 -0
- package/templates/contract/close.md +19 -14
- package/templates/contract/core.md +41 -30
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +24 -10
- package/templates/contract/review.md +7 -9
- package/templates/contract/spec.md +14 -1
- 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
|
+
}
|
package/src/commands/context.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
if (incremental)
|
|
102
|
-
if (policy)
|
|
103
|
-
|
|
104
|
-
if (dependencies)
|
|
105
|
-
if (changeText)
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
117
|
-
const changeledgerDir = requireRepo(cwd);
|
|
118
|
-
const config = loadConfig(changeledgerDir);
|
|
130
|
+
function composeInput(input, cwd, config) {
|
|
119
131
|
if (!input) {
|
|
120
|
-
return
|
|
132
|
+
return composeResult('core', ['core'], {
|
|
133
|
+
incremental: false,
|
|
134
|
+
policy: transversalPolicy(config),
|
|
135
|
+
});
|
|
121
136
|
}
|
|
122
137
|
if (MODES.includes(input)) {
|
|
123
|
-
return
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
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)))
|
|
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
|
+
}
|
package/src/config-migration.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
149
|
-
: `Config migration
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
>
|
|
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
|
|
64
|
-
const
|
|
65
|
-
|
|
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}${
|
|
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
|
|
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 =
|
|
82
|
-
if (
|
|
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
|
-
|
|
194
|
+
const { status } = applyBootstrap(text);
|
|
195
|
+
if (status === 'inserted') {
|
|
138
196
|
errors.push(`${name} has no ChangeLedger reference — run \`changeledger register\``);
|
|
139
|
-
} else if (
|
|
197
|
+
} else if (status !== 'unchanged') {
|
|
140
198
|
errors.push(`${name} has an outdated ChangeLedger reference — run \`changeledger register\``);
|
|
141
199
|
}
|
|
142
200
|
}
|