changeledger 0.9.0 → 0.11.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 +5 -1
- package/README.md +5 -0
- package/bin/changeledger.mjs +127 -20
- package/package.json +1 -1
- package/src/check.mjs +12 -0
- package/src/commands/agent-context.mjs +2 -1
- package/src/commands/agent-prompt.mjs +1 -1
- package/src/commands/agent.mjs +3 -3
- package/src/commands/check.mjs +55 -0
- package/src/commands/commit.mjs +44 -0
- package/src/commands/context.mjs +57 -23
- 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 +67 -9
- package/src/config.mjs +13 -0
- package/src/contract.mjs +67 -13
- package/src/fix.mjs +127 -0
- package/src/framing.mjs +8 -0
- package/src/git.mjs +137 -2
- package/src/metrics.mjs +42 -0
- package/src/search.mjs +162 -0
- package/src/viewer/domain.mjs +13 -1
- package/src/viewer/public/app.js +76 -11
- 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 +11 -1
- package/templates/contract/agent-contexts/audit.md +22 -0
- package/templates/contract/agent-prompts/audit.md +37 -0
- package/templates/contract/agent-prompts/implementation.md +4 -0
- package/templates/contract/close.md +1 -1
- package/templates/contract/core.md +14 -4
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +16 -2
- package/templates/contract/spec.md +14 -1
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
|
-
<!--
|
|
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.
|
package/bin/changeledger.mjs
CHANGED
|
@@ -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
|
|
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 = [
|
|
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 commit: the clean subject gets a ChangeLedger: [#A] [#B] body.',
|
|
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(
|
|
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
|
|
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
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)) {
|
package/src/commands/agent.mjs
CHANGED
|
@@ -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,
|
|
215
|
+
export function archive(id, cwd = process.cwd()) {
|
|
216
216
|
const { file } = locate(cwd, id);
|
|
217
217
|
mutateFileAtomic(file, (text) => {
|
|
218
|
-
text = setArchived(text,
|
|
219
|
-
return appendLog(text, nowUtc(),
|
|
218
|
+
text = setArchived(text, true);
|
|
219
|
+
return appendLog(text, nowUtc(), 'archived');
|
|
220
220
|
});
|
|
221
221
|
return file;
|
|
222
222
|
}
|
package/src/commands/check.mjs
CHANGED
|
@@ -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} ${v.reason}: "${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,44 @@
|
|
|
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 markers = resolvedIds.map((id) => `[#${id}]`).join(' ');
|
|
37
|
+
const multiple = resolvedIds.length > 1;
|
|
38
|
+
const subject = multiple ? message : `${message} ${markers}`;
|
|
39
|
+
const args = multiple
|
|
40
|
+
? ['commit', '-m', subject, '-m', `ChangeLedger: ${markers}`]
|
|
41
|
+
: ['commit', '-m', subject];
|
|
42
|
+
run(args, repo.repoRoot);
|
|
43
|
+
return subject;
|
|
44
|
+
}
|
package/src/commands/context.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
if (incremental)
|
|
101
|
-
if (policy)
|
|
102
|
-
|
|
103
|
-
if (dependencies)
|
|
104
|
-
if (changeText)
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
116
|
-
const changeledgerDir = requireRepo(cwd);
|
|
117
|
-
const config = loadConfig(changeledgerDir);
|
|
130
|
+
function composeInput(input, cwd, config) {
|
|
118
131
|
if (!input) {
|
|
119
|
-
return
|
|
132
|
+
return composeResult('core', ['core'], {
|
|
133
|
+
incremental: false,
|
|
134
|
+
policy: transversalPolicy(config),
|
|
135
|
+
});
|
|
120
136
|
}
|
|
121
137
|
if (MODES.includes(input)) {
|
|
122
|
-
return
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
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
|
}
|