changeledger 0.3.0 → 0.5.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 +13 -7
- package/README.md +22 -13
- package/bin/changeledger.mjs +10 -2
- package/package.json +1 -1
- package/src/check.mjs +35 -9
- package/src/commands/agent.mjs +1 -1
- package/src/commands/check.mjs +1 -1
- package/src/commands/context.mjs +65 -0
- package/src/commands/init.mjs +3 -6
- package/src/commands/new.mjs +1 -1
- package/src/commands/register.mjs +10 -6
- package/src/commands/view.mjs +10 -1
- package/src/config.mjs +6 -3
- package/src/contract.mjs +86 -50
- package/src/paths.mjs +1 -1
- package/src/registry.mjs +24 -1
- package/src/repo.mjs +7 -0
- package/src/viewer/domain.mjs +136 -3
- package/src/viewer/public/api.js +26 -0
- package/src/viewer/public/app-state.js +144 -5
- package/src/viewer/public/app.js +299 -11
- package/src/viewer/public/index.html +2 -0
- package/src/viewer/public/styles.css +202 -0
- package/src/viewer/server/router.mjs +50 -11
- package/templates/config.yml +4 -3
- package/templates/contract/blocked.md +6 -0
- package/templates/contract/close.md +18 -0
- package/templates/contract/core.md +64 -0
- package/templates/contract/discarded.md +5 -0
- package/templates/contract/implement.md +52 -0
- package/templates/contract/readiness.md +19 -0
- package/templates/contract/release.md +15 -0
- package/templates/contract/review.md +19 -0
- package/templates/contract/spec.md +94 -0
- package/templates/contract/validation.md +9 -0
- package/templates/AGENTS.md +0 -540
package/AGENTS.md
CHANGED
|
@@ -5,15 +5,21 @@ under `.changeledger/changes/`, persistent truth under `.changeledger/specs/`.
|
|
|
5
5
|
|
|
6
6
|
<!-- changeledger -->
|
|
7
7
|
> [!IMPORTANT]
|
|
8
|
-
> This repo uses **ChangeLedger**.
|
|
9
|
-
>
|
|
8
|
+
> This repo uses **ChangeLedger**. Before creating or modifying files, run
|
|
9
|
+
> `changeledger context` (or `changeledger context <change-id>`) and follow its output.
|
|
10
|
+
> If the command is unavailable, stop and restore/install ChangeLedger; do not proceed from memory.
|
|
10
11
|
|
|
11
|
-
The
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
The canonical ChangeLedger contract is split into task-focused fragments under
|
|
13
|
+
[`templates/contract/`](templates/contract/). The deterministic
|
|
14
|
+
`changeledger context` command composes them; consuming repos keep only the
|
|
15
|
+
bootstrap above, not a linked or copied contract.
|
|
14
16
|
|
|
15
17
|
## Project-specific notes
|
|
16
18
|
|
|
19
|
+
- Product evolution follows the complexity budget and no-goals in
|
|
20
|
+
[`INTENT.md`](INTENT.md): prefer observed problems and a small deterministic,
|
|
21
|
+
local-first core; AI orchestration, memory and cloud services belong only in
|
|
22
|
+
optional integrations.
|
|
17
23
|
- Managed with **pnpm**; lint/format via **Biome**. Runtime dependencies are
|
|
18
24
|
allowed only when they are mature and justified: the CLI uses `yaml` for
|
|
19
25
|
config/frontmatter parsing, and the viewer uses `lit-html`, `marked`,
|
|
@@ -21,5 +27,5 @@ into each repo as `.changeledger/AGENTS.md`. Edit the convention there, not here
|
|
|
21
27
|
- `pnpm verify` (lint + test + `changeledger check`) is the full quality gate. The
|
|
22
28
|
versioned `hooks/pre-commit` runs `lint-staged`, `pnpm test` and `changeledger check`
|
|
23
29
|
so staged formatting stays compatible with partial commits.
|
|
24
|
-
-
|
|
25
|
-
|
|
30
|
+
- Edit the convention in `templates/contract/`; keep each rule in one fragment
|
|
31
|
+
so task contexts compose without duplicated truth.
|
package/README.md
CHANGED
|
@@ -27,9 +27,10 @@ changeledger init
|
|
|
27
27
|
changeledger view
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
`changeledger init` creates `.changeledger/`, gives the project a stable identity
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
`changeledger init` creates `.changeledger/`, gives the project a stable identity
|
|
31
|
+
and installs a small context bootstrap in the project-owned `AGENTS.md`. The
|
|
32
|
+
repository keeps only its configuration and documents; the CLI, viewer and
|
|
33
|
+
canonical contract fragments remain in the global package.
|
|
33
34
|
|
|
34
35
|
## The workflow
|
|
35
36
|
|
|
@@ -62,8 +63,8 @@ other tools discover it through the repository's `AGENTS.md` reference.
|
|
|
62
63
|
|
|
63
64
|
With the default Definition of Ready policy, `changeledger check` verifies that acceptance
|
|
64
65
|
criteria are test-grade and mapped to actionable tasks. Repositories doing
|
|
65
|
-
exploratory work can set `tdd: false`;
|
|
66
|
-
|
|
66
|
+
exploratory work can set `tdd: false`; run `changeledger context spec` or
|
|
67
|
+
`changeledger context implement` for the complete task-specific rules.
|
|
67
68
|
|
|
68
69
|
## Essential commands
|
|
69
70
|
|
|
@@ -71,7 +72,7 @@ exploratory work can set `tdd: false`; the complete rules live in
|
|
|
71
72
|
|
|
72
73
|
```sh
|
|
73
74
|
changeledger init # initialize and register the current repository
|
|
74
|
-
changeledger register #
|
|
75
|
+
changeledger register # refresh registration and the context bootstrap
|
|
75
76
|
changeledger view # view every registered project
|
|
76
77
|
changeledger view . # view only the current project
|
|
77
78
|
changeledger check [id] # validate the repository or one change
|
|
@@ -128,17 +129,25 @@ CI:
|
|
|
128
129
|
changeledger check || exit 1
|
|
129
130
|
```
|
|
130
131
|
|
|
131
|
-
The contract
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
The contract ships as task-focused fragments and is compiled on demand:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
changeledger context # minimal non-negotiable core
|
|
136
|
+
changeledger context <change-id> # lifecycle-aware rules + selected change
|
|
137
|
+
changeledger context review # explicit task mode
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`init` places a small fail-closed bootstrap in the project-owned `AGENTS.md`;
|
|
141
|
+
there is no linked or copied contract under `.changeledger/`. Run
|
|
142
|
+
`changeledger register` after upgrading to refresh that bootstrap and migrate
|
|
143
|
+
legacy repositories.
|
|
135
144
|
|
|
136
145
|
## Compatibility and security
|
|
137
146
|
|
|
138
147
|
- Node.js **24+**.
|
|
139
148
|
- Tested on Linux, macOS and Windows.
|
|
140
|
-
-
|
|
141
|
-
|
|
149
|
+
- No symlink privileges are required on Windows; context fragments remain in the
|
|
150
|
+
installed package and the CLI prints them on demand.
|
|
142
151
|
- The viewer binds to loopback and treats repository content as untrusted input.
|
|
143
152
|
See [`SECURITY.md`](SECURITY.md) for the threat model and private reporting
|
|
144
153
|
instructions.
|
|
@@ -151,7 +160,7 @@ latest `0.x` release to receive fixes.
|
|
|
151
160
|
|
|
152
161
|
Contributions are welcome. Development setup, quality gates and repository
|
|
153
162
|
conventions live in [`CONTRIBUTING.md`](CONTRIBUTING.md). The canonical agent
|
|
154
|
-
contract is [`
|
|
163
|
+
contract is composed from [`templates/contract/`](templates/contract/).
|
|
155
164
|
|
|
156
165
|
## Language policy
|
|
157
166
|
|
package/bin/changeledger.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
task,
|
|
14
14
|
} from '../src/commands/agent.mjs';
|
|
15
15
|
import { check } from '../src/commands/check.mjs';
|
|
16
|
+
import { context } from '../src/commands/context.mjs';
|
|
16
17
|
import { graduate, pendingGraduation, skipGraduation } from '../src/commands/graduate.mjs';
|
|
17
18
|
import { init } from '../src/commands/init.mjs';
|
|
18
19
|
import { newChange } from '../src/commands/new.mjs';
|
|
@@ -24,10 +25,11 @@ import { nowUtc } from '../src/paths.mjs';
|
|
|
24
25
|
const USAGE = `ChangeLedger (changeledger)
|
|
25
26
|
|
|
26
27
|
changeledger init set up .changeledger/ in the current repo (+ register it)
|
|
27
|
-
changeledger register
|
|
28
|
+
changeledger register refresh registration and context bootstrap
|
|
28
29
|
changeledger new <type> <slug> <title> scaffold a new change (slug is the English filename)
|
|
29
30
|
changeledger view [port] launch the local viewer (default port 4040)
|
|
30
31
|
changeledger check [id] [--json] validate the repo or one change
|
|
32
|
+
changeledger context [mode|change-id] print deterministic task context
|
|
31
33
|
changeledger status <id> <status> move a change's lifecycle status
|
|
32
34
|
changeledger discard <id> "<reason>" discard a change (terminal; keeps the record)
|
|
33
35
|
changeledger review <id> pass independent review passed → in-validation
|
|
@@ -78,7 +80,7 @@ program
|
|
|
78
80
|
|
|
79
81
|
program
|
|
80
82
|
.command('register')
|
|
81
|
-
.description(
|
|
83
|
+
.description('refresh registration and context bootstrap')
|
|
82
84
|
.action(
|
|
83
85
|
action(() => {
|
|
84
86
|
const { id, path: p } = registerRepo();
|
|
@@ -122,6 +124,12 @@ program
|
|
|
122
124
|
}
|
|
123
125
|
});
|
|
124
126
|
|
|
127
|
+
program
|
|
128
|
+
.command('context')
|
|
129
|
+
.description('print deterministic task context')
|
|
130
|
+
.argument('[mode-or-change-id]')
|
|
131
|
+
.action(action((input) => context(input)));
|
|
132
|
+
|
|
125
133
|
program
|
|
126
134
|
.command('status')
|
|
127
135
|
.description("move a change's lifecycle status")
|
package/package.json
CHANGED
package/src/check.mjs
CHANGED
|
@@ -15,9 +15,9 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
|
|
|
15
15
|
|
|
16
16
|
checkConfig(config, (c, message) => err(c ?? { name: '.changeledger/config.yml' }, message));
|
|
17
17
|
|
|
18
|
-
const statuses = config.statuses
|
|
19
|
-
const types = config.types
|
|
20
|
-
const canonical = config.stages
|
|
18
|
+
const statuses = Array.isArray(config.statuses) ? config.statuses : [];
|
|
19
|
+
const types = isMapping(config.types) ? config.types : {};
|
|
20
|
+
const canonical = Array.isArray(config.stages) ? config.stages : [];
|
|
21
21
|
|
|
22
22
|
// Scope: a single change (fast, post-write check) or the whole repo.
|
|
23
23
|
let targets = changes;
|
|
@@ -64,7 +64,8 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
|
|
|
64
64
|
const ordered = [...known].sort((a, b) => canonical.indexOf(a) - canonical.indexOf(b));
|
|
65
65
|
if (known.join(',') !== ordered.join(',')) err(c, 'stages are out of canonical order');
|
|
66
66
|
|
|
67
|
-
const
|
|
67
|
+
const typeDefinition = isMapping(types[fm.type]) ? types[fm.type] : null;
|
|
68
|
+
const active = Array.isArray(typeDefinition?.stages) ? typeDefinition.stages : null;
|
|
68
69
|
if (active) {
|
|
69
70
|
for (const k of active)
|
|
70
71
|
if (!present.includes(k)) err(c, `missing active stage "## ${k}" for type ${fm.type}`);
|
|
@@ -365,9 +366,20 @@ function misplacedVerificationSuffix(task, config) {
|
|
|
365
366
|
}
|
|
366
367
|
|
|
367
368
|
function readinessConfig(config) {
|
|
369
|
+
const readiness = isMapping(config?.readiness) ? config.readiness : null;
|
|
368
370
|
return {
|
|
369
|
-
target_patterns:
|
|
370
|
-
|
|
371
|
+
target_patterns:
|
|
372
|
+
readiness && 'target_patterns' in readiness
|
|
373
|
+
? Array.isArray(readiness.target_patterns)
|
|
374
|
+
? readiness.target_patterns
|
|
375
|
+
: []
|
|
376
|
+
: ['src/**'],
|
|
377
|
+
verification_patterns:
|
|
378
|
+
readiness && 'verification_patterns' in readiness
|
|
379
|
+
? Array.isArray(readiness.verification_patterns)
|
|
380
|
+
? readiness.verification_patterns
|
|
381
|
+
: []
|
|
382
|
+
: ['test/**'],
|
|
371
383
|
};
|
|
372
384
|
}
|
|
373
385
|
|
|
@@ -445,11 +457,21 @@ function checkConfig(config, err) {
|
|
|
445
457
|
}
|
|
446
458
|
if ('statuses' in c && !Array.isArray(c.statuses)) err(null, 'config "statuses" must be a list');
|
|
447
459
|
if ('stages' in c && !Array.isArray(c.stages)) err(null, 'config "stages" must be a list');
|
|
460
|
+
if ('types' in c && !isMapping(c.types)) err(null, 'config "types" must be a mapping');
|
|
448
461
|
if ('readiness' in c) checkReadinessConfig(c.readiness, err);
|
|
449
|
-
|
|
462
|
+
const configuredTypes = isMapping(c.types) ? c.types : {};
|
|
463
|
+
if ('release' in c) checkReleaseConfig(c.release, configuredTypes, err);
|
|
450
464
|
const canonical = Array.isArray(c.stages) ? c.stages : [];
|
|
451
|
-
for (const [type, def] of Object.entries(
|
|
452
|
-
|
|
465
|
+
for (const [type, def] of Object.entries(configuredTypes)) {
|
|
466
|
+
if (!isMapping(def)) {
|
|
467
|
+
err(null, `config type "${type}" must be a mapping`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (!Array.isArray(def.stages)) {
|
|
471
|
+
err(null, `config type "${type}": stages must be a list`);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
for (const s of def.stages) {
|
|
453
475
|
if (!canonical.includes(s))
|
|
454
476
|
err(null, `config type "${type}" references unknown stage "${s}"`);
|
|
455
477
|
}
|
|
@@ -458,6 +480,10 @@ function checkConfig(config, err) {
|
|
|
458
480
|
}
|
|
459
481
|
}
|
|
460
482
|
|
|
483
|
+
function isMapping(value) {
|
|
484
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
485
|
+
}
|
|
486
|
+
|
|
461
487
|
function checkReleaseConfig(release, types, err) {
|
|
462
488
|
if (!release || typeof release !== 'object' || Array.isArray(release)) {
|
|
463
489
|
err(null, 'config "release" must be a mapping');
|
package/src/commands/agent.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export function status(
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// Records the verdict of the independent review (run by a delegated subagent
|
|
62
|
-
// with clean context — see
|
|
62
|
+
// with clean context — see `changeledger context review`). `pass` advances to human validation;
|
|
63
63
|
// `fail` routes it back: `retry` for a defect inside the contract (the
|
|
64
64
|
// implementer fixes), `block` for one that escalates to a human. Requires the
|
|
65
65
|
// change to be in-review.
|
package/src/commands/check.mjs
CHANGED
|
@@ -22,7 +22,7 @@ export function check(args = [], cwd = process.cwd(), output = console) {
|
|
|
22
22
|
|
|
23
23
|
const { errors, warnings } = checkRepo(repo, { id });
|
|
24
24
|
|
|
25
|
-
// Discovery validation needs the filesystem (root contract
|
|
25
|
+
// Discovery validation needs the filesystem (root contract bootstrap), so it
|
|
26
26
|
// lives here, not in the pure validator. Repo-wide only.
|
|
27
27
|
if (!id) {
|
|
28
28
|
for (const message of checkContract(repo.repoRoot, repo.changeledgerDir)) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseChange } from '../change.mjs';
|
|
4
|
+
import { findChangeledgerDir } from '../config.mjs';
|
|
5
|
+
import { contractTemplatesDir } from '../paths.mjs';
|
|
6
|
+
import { resolveChange } from '../repo.mjs';
|
|
7
|
+
|
|
8
|
+
const MODES = ['implement', 'review', 'spec', 'release'];
|
|
9
|
+
const MODE_CONTEXT = {
|
|
10
|
+
implement: ['core', 'implement', 'readiness'],
|
|
11
|
+
review: ['core', 'review'],
|
|
12
|
+
spec: ['core', 'spec', 'readiness'],
|
|
13
|
+
release: ['core', 'release'],
|
|
14
|
+
};
|
|
15
|
+
const STATUS_CONTEXT = {
|
|
16
|
+
draft: { mode: 'spec', fragments: MODE_CONTEXT.spec },
|
|
17
|
+
approved: { mode: 'implement', fragments: MODE_CONTEXT.implement },
|
|
18
|
+
'in-progress': { mode: 'implement', fragments: MODE_CONTEXT.implement },
|
|
19
|
+
'in-review': { mode: 'review', fragments: ['core', 'review'] },
|
|
20
|
+
blocked: { mode: 'blocked', fragments: ['core', 'blocked'] },
|
|
21
|
+
'in-validation': { mode: 'validation', fragments: ['core', 'validation'] },
|
|
22
|
+
done: { mode: 'close', fragments: ['core', 'close'] },
|
|
23
|
+
discarded: { mode: 'discarded', fragments: ['core', 'discarded'] },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function fragment(name) {
|
|
27
|
+
return fs.readFileSync(path.join(contractTemplatesDir, `${name}.md`), 'utf8').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function compose(mode, fragments, changeText) {
|
|
31
|
+
const sections = [`Mode: ${mode}`, ...fragments.map(fragment)];
|
|
32
|
+
if (changeText) sections.push('---\n\n# Selected change\n', changeText.trim());
|
|
33
|
+
return `${sections.join('\n\n')}\n`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireRepo(cwd) {
|
|
37
|
+
if (!findChangeledgerDir(cwd)) {
|
|
38
|
+
throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildContext(input, cwd = process.cwd()) {
|
|
43
|
+
requireRepo(cwd);
|
|
44
|
+
if (!input) return compose('core', ['core']);
|
|
45
|
+
if (MODES.includes(input)) return compose(input, MODE_CONTEXT[input]);
|
|
46
|
+
|
|
47
|
+
let resolved;
|
|
48
|
+
try {
|
|
49
|
+
resolved = resolveChange(cwd, input);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Unknown context "${input}" — valid modes: ${MODES.join(', ')} (or pass a change id)`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const text = fs.readFileSync(resolved.file, 'utf8');
|
|
57
|
+
const status = parseChange(text).frontmatter.status;
|
|
58
|
+
const selected = STATUS_CONTEXT[status];
|
|
59
|
+
if (!selected) throw new Error(`No context mapping for change status "${status}"`);
|
|
60
|
+
return compose(selected.mode, selected.fragments, text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function context(input, cwd = process.cwd(), output = console.log) {
|
|
64
|
+
output(buildContext(input, cwd).trimEnd());
|
|
65
|
+
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -2,20 +2,19 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { writeFileAtomic } from '../atomic-write.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { ensureReference, rootContract } from '../contract.mjs';
|
|
6
6
|
import { templatesDir } from '../paths.mjs';
|
|
7
7
|
import { register } from '../registry.mjs';
|
|
8
8
|
import { serializeScalar } from '../yaml.mjs';
|
|
9
9
|
|
|
10
|
-
// Sets up `.changeledger
|
|
11
|
-
// AGENTS.md contract into `.changeledger/`, references it from the project's root
|
|
10
|
+
// Sets up `.changeledger/`, installs the context bootstrap in the project-owned
|
|
12
11
|
// AGENTS.md, and registers the repo in the global registry.
|
|
13
12
|
export function init(cwd = process.cwd()) {
|
|
14
13
|
const repoRoot = path.resolve(cwd);
|
|
15
14
|
const changeledgerDir = path.join(repoRoot, '.changeledger');
|
|
16
15
|
if (fs.existsSync(changeledgerDir)) {
|
|
17
16
|
throw new Error(
|
|
18
|
-
'.changeledger/ already exists. Use `changeledger register` to
|
|
17
|
+
'.changeledger/ already exists. Use `changeledger register` to refresh this repo.',
|
|
19
18
|
);
|
|
20
19
|
}
|
|
21
20
|
// The root AGENTS.md is the project's own contract; we reference the tool's
|
|
@@ -34,9 +33,7 @@ export function init(cwd = process.cwd()) {
|
|
|
34
33
|
`${fs.readFileSync(path.join(templatesDir, 'config.yml'), 'utf8')}\n# Project identity (stable; the global registry maps it to a path)\nproject_id: "${id}"\nproject_name: ${serializeScalar(name)}\n`,
|
|
35
34
|
);
|
|
36
35
|
|
|
37
|
-
linkContract(changeledgerDir);
|
|
38
36
|
ensureReference(repoRoot);
|
|
39
|
-
ensureGitignore(repoRoot);
|
|
40
37
|
|
|
41
38
|
register({ id, name, path: repoRoot });
|
|
42
39
|
return changeledgerDir;
|
package/src/commands/new.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const LOCK_MTIME_STALE_MS = 30_000;
|
|
|
11
11
|
|
|
12
12
|
// Scaffolds a new change file with the active stages for its type.
|
|
13
13
|
// `slug` is the English filename slug (structure); `title` is the content title
|
|
14
|
-
// (repo language). See
|
|
14
|
+
// (repo language). See `changeledger context spec`.
|
|
15
15
|
export function newChange({ type, slug, title, owner, now }, cwd = process.cwd()) {
|
|
16
16
|
const changeledgerDir = findChangeledgerDir(cwd);
|
|
17
17
|
if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { findChangeledgerDir, loadConfig } from '../config.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ensureReference,
|
|
6
|
+
removeLegacyContract,
|
|
7
|
+
removeLegacyGitignore,
|
|
8
|
+
rootContract,
|
|
9
|
+
} from '../contract.mjs';
|
|
5
10
|
import { register } from '../registry.mjs';
|
|
6
11
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// after moving the repo or cloning it on another machine.
|
|
12
|
+
// Refreshes the repo bootstrap and registry path. Also migrates the per-machine
|
|
13
|
+
// contract artifact left by legacy versions.
|
|
10
14
|
export function registerRepo(cwd = process.cwd()) {
|
|
11
15
|
const changeledgerDir = findChangeledgerDir(cwd);
|
|
12
16
|
if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
@@ -19,8 +23,8 @@ export function registerRepo(cwd = process.cwd()) {
|
|
|
19
23
|
const repoRoot = path.dirname(changeledgerDir);
|
|
20
24
|
const name = config.project_name || path.basename(repoRoot);
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
removeLegacyContract(changeledgerDir);
|
|
27
|
+
removeLegacyGitignore(repoRoot);
|
|
24
28
|
if (fs.existsSync(rootContract(repoRoot))) ensureReference(repoRoot);
|
|
25
29
|
|
|
26
30
|
register({ id: config.project_id, name, path: repoRoot });
|
package/src/commands/view.mjs
CHANGED
|
@@ -5,7 +5,16 @@ import { resolveProjects } from '../viewer/domain.mjs';
|
|
|
5
5
|
import { createRequestListener, staticFile } from '../viewer/server/router.mjs';
|
|
6
6
|
import { hostnameOf, isAuthorizedWrite, isLocalHost } from '../viewer/server/security.mjs';
|
|
7
7
|
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
changeStatus,
|
|
10
|
+
readProjectConfig,
|
|
11
|
+
repairProjectPath,
|
|
12
|
+
resolveProjects,
|
|
13
|
+
saveProjectConfig,
|
|
14
|
+
searchProjects,
|
|
15
|
+
serialize,
|
|
16
|
+
unregisterProject,
|
|
17
|
+
} from '../viewer/domain.mjs';
|
|
9
18
|
export { createRequestListener, hostnameOf, isAuthorizedWrite, isLocalHost, staticFile };
|
|
10
19
|
|
|
11
20
|
export async function view(args = [], cwd = process.cwd()) {
|
package/src/config.mjs
CHANGED
|
@@ -2,13 +2,16 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseYaml } from './yaml.mjs';
|
|
4
4
|
|
|
5
|
-
// Walk up from `start` looking for a `.changeledger
|
|
6
|
-
//
|
|
5
|
+
// Walk up from `start` looking for a project `.changeledger/config.yml`. The
|
|
6
|
+
// config file is the marker: `~/.changeledger/` may exist only as global state
|
|
7
|
+
// and must not be mistaken for a repository (notably when Windows temp dirs
|
|
8
|
+
// live below the user's home). Returns the project data directory or null.
|
|
7
9
|
export function findChangeledgerDir(start = process.cwd()) {
|
|
8
10
|
let dir = path.resolve(start);
|
|
9
11
|
for (;;) {
|
|
10
12
|
const candidate = path.join(dir, '.changeledger');
|
|
11
|
-
|
|
13
|
+
const config = path.join(candidate, 'config.yml');
|
|
14
|
+
if (fs.existsSync(config) && fs.statSync(config).isFile()) return candidate;
|
|
12
15
|
const parent = path.dirname(dir);
|
|
13
16
|
if (parent === dir) return null;
|
|
14
17
|
dir = parent;
|
package/src/contract.mjs
CHANGED
|
@@ -1,29 +1,49 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { writeFileAtomic } from './atomic-write.mjs';
|
|
4
|
-
import { agentsTemplate } from './paths.mjs';
|
|
5
5
|
|
|
6
|
-
// The tool's contract (templates/AGENTS.md) is a tool artifact, not a project
|
|
7
|
-
// artifact. Each repo links to the installed copy via `.changeledger/AGENTS.md` (a
|
|
8
|
-
// per-machine, gitignored symlink) and points its own contract files at that
|
|
9
|
-
// link. We never copy the contract (it would drift) nor commit the symlink (it
|
|
10
|
-
// would dangle on another machine). See change #20260614-151759.
|
|
11
|
-
|
|
12
|
-
// Project-owned contract files that should reference the linked contract. The
|
|
13
|
-
// root AGENTS.md is required by `init`; CLAUDE.md is referenced when present.
|
|
14
6
|
const CONTRACT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
15
|
-
|
|
16
7
|
const MARKER = '<!-- changeledger -->';
|
|
17
|
-
const
|
|
8
|
+
const LEGACY_ENTRY = '.changeledger/AGENTS.md';
|
|
9
|
+
// Exact SHA-256 digests of every historical templates/AGENTS.md payload. A
|
|
10
|
+
// regular file is removed only when it is byte-identical to a contract version
|
|
11
|
+
// ChangeLedger actually shipped; headings or other heuristics are not enough.
|
|
12
|
+
const LEGACY_CONTRACT_HASHES = new Set([
|
|
13
|
+
'92a6d22c9985dce2d15724d20e2d5c0f1178d82dc56d28e339b72c7a27574c6f',
|
|
14
|
+
'4701b260d0cc39098f3b942ed2b1969f00a84e7b77a4e107070fedef0fda7d0d',
|
|
15
|
+
'30b3ce1768655ebee2be2b88328ba860293d8bbbe55e12a5aacc8177d7d02c9b',
|
|
16
|
+
'd36d17f99505f2b47a7ceea8f078618f652807463d5d60a83ca95f940d5a9c35',
|
|
17
|
+
'1175cc4bcc884d792cdde2338b3c72b336bcb66b47555d6e5938d756acdea635',
|
|
18
|
+
'f605adad9192286bc2cda45aebb5a12dab5b3c199924789398684b79b55811',
|
|
19
|
+
'05e06c8e9dc45ac41ff38b47075b22492c30f3f1a7019371293be10351aada77',
|
|
20
|
+
'9816e4d14f70dad268f9b89ad49094665997bf62f0b773a05fe8eed17eebc9de',
|
|
21
|
+
'1b5439055e52a1bb72f72b99cbd2842b2118776fb80e262175d584a335e07e5f',
|
|
22
|
+
'6fe7a03f699ffa875c1ef4bc2d59b451058d6ab4c3c8824cc220ae031d068480',
|
|
23
|
+
'd56ec9ef5c1bd71464d9d9bc2a772c42cce103f336ebba61106855c8011bca6f',
|
|
24
|
+
'071ed83e79589ec0362e4e99ee80cb4f7a839f2a810cf59dbf056c46e342a09c',
|
|
25
|
+
'1148a1b69ce035a695c64f0df1aaaedcbeeed669b5b774c52f7b136e9f5fe4f9',
|
|
26
|
+
'25d4508b0b60aa939e81d1e83383f90e7a41feacb6cb2f12fab6548cebb77ca1',
|
|
27
|
+
'37e1ffe059c0d26b07566597607c41d42df3e9548830d42413dd8d2afd62aa86',
|
|
28
|
+
'60519091c785977299725e4f2f3207b83828bfc56d94a989adf13bd0fc9bb174',
|
|
29
|
+
'6e201bb60337c9b0869c6c769272c82abc466330f82c662bb023a74ad0b8fa92',
|
|
30
|
+
'80e2560e01cfae87455177b54f77845b657764338d3bf3507119e3e1ff289bcd',
|
|
31
|
+
'7a126595a0b5de0b58eec9db38ee6d1654caf0b42df29d181692c382abe213c6',
|
|
32
|
+
'2e839be3ad9458feebcfd8ca979f9173b1f63b514939903ea8f9f03517528f88',
|
|
33
|
+
'f10407e809fc679d11e442d57c92a4b82d0647f06946bb42c7daccedf76873ec',
|
|
34
|
+
'99a42380f3835e1f706b986b86e58e33f12d68de4a69d94eb50845e790e05f71',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export const REFERENCE = `${MARKER}
|
|
18
38
|
> [!IMPORTANT]
|
|
19
|
-
> This repo uses **ChangeLedger**.
|
|
20
|
-
>
|
|
39
|
+
> This repo uses **ChangeLedger**. Before creating or modifying files, run
|
|
40
|
+
> \`changeledger context\` (or \`changeledger context <change-id>\`) and follow its output.
|
|
41
|
+
> If the command is unavailable, stop and restore/install ChangeLedger; do not proceed from memory.
|
|
21
42
|
`;
|
|
22
43
|
|
|
23
44
|
export const contractLink = (changeledgerDir) => path.join(changeledgerDir, 'AGENTS.md');
|
|
24
45
|
export const rootContract = (repoRoot) => path.join(repoRoot, 'AGENTS.md');
|
|
25
46
|
|
|
26
|
-
// A real, non-symlink file we may safely append to.
|
|
27
47
|
function isPlainFile(file) {
|
|
28
48
|
try {
|
|
29
49
|
return fs.lstatSync(file).isFile();
|
|
@@ -32,69 +52,85 @@ function isPlainFile(file) {
|
|
|
32
52
|
}
|
|
33
53
|
}
|
|
34
54
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
try {
|
|
46
|
-
fs.symlinkSync(agentsTemplate, link);
|
|
47
|
-
} catch {
|
|
48
|
-
// Windows without Developer Mode/admin cannot create symlinks. Fall back to
|
|
49
|
-
// a copy so the contract is still present; `changeledger register` refreshes it if the
|
|
50
|
-
// installed contract changes.
|
|
51
|
-
writeFileAtomic(link, fs.readFileSync(agentsTemplate, 'utf8'));
|
|
52
|
-
}
|
|
53
|
-
return link;
|
|
55
|
+
function replaceReference(text) {
|
|
56
|
+
const start = text.indexOf(MARKER);
|
|
57
|
+
if (start === -1) return `${text}${text.endsWith('\n') ? '' : '\n'}\n${REFERENCE}`;
|
|
58
|
+
const tail = text.slice(start).split('\n');
|
|
59
|
+
let consumed = 1;
|
|
60
|
+
while (consumed < tail.length && tail[consumed].startsWith('>')) consumed += 1;
|
|
61
|
+
const before = text.slice(0, start);
|
|
62
|
+
const after = tail.slice(consumed).join('\n').replace(/^\n+/, '');
|
|
63
|
+
return `${before}${REFERENCE}${after ? `\n${after}` : ''}`;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
//
|
|
57
|
-
// already there. Symlinks are skipped (appending would write into their target).
|
|
66
|
+
// Add or replace the managed bootstrap block in project-owned agent files.
|
|
58
67
|
export function ensureReference(repoRoot) {
|
|
59
68
|
const touched = [];
|
|
60
69
|
for (const name of CONTRACT_FILES) {
|
|
61
70
|
const file = path.join(repoRoot, name);
|
|
62
71
|
if (!isPlainFile(file)) continue;
|
|
63
72
|
const text = fs.readFileSync(file, 'utf8');
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
const updated = replaceReference(text);
|
|
74
|
+
if (updated === text) continue;
|
|
75
|
+
writeFileAtomic(file, updated);
|
|
66
76
|
touched.push(name);
|
|
67
77
|
}
|
|
68
78
|
return touched;
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
//
|
|
72
|
-
|
|
81
|
+
// Remove only artifacts known to be managed by legacy ChangeLedger versions.
|
|
82
|
+
// Unknown regular files fail closed instead of being deleted.
|
|
83
|
+
export function removeLegacyContract(changeledgerDir, knownHashes = LEGACY_CONTRACT_HASHES) {
|
|
84
|
+
const file = contractLink(changeledgerDir);
|
|
85
|
+
let stat;
|
|
86
|
+
try {
|
|
87
|
+
stat = fs.lstatSync(file);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code === 'ENOENT') return false;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
if (stat.isSymbolicLink()) {
|
|
93
|
+
fs.unlinkSync(file);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (stat.isFile()) {
|
|
97
|
+
const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
98
|
+
if (knownHashes.has(digest)) {
|
|
99
|
+
fs.unlinkSync(file);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw new Error(
|
|
104
|
+
'`.changeledger/AGENTS.md` is not a recognized legacy ChangeLedger contract; move or remove it manually, then run `changeledger register` again',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function removeLegacyGitignore(repoRoot) {
|
|
73
109
|
const file = path.join(repoRoot, '.gitignore');
|
|
74
|
-
|
|
75
|
-
const text = fs.
|
|
76
|
-
|
|
77
|
-
|
|
110
|
+
if (!fs.existsSync(file)) return false;
|
|
111
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const kept = lines.filter((line) => line !== LEGACY_ENTRY);
|
|
114
|
+
if (kept.length === lines.length) return false;
|
|
115
|
+
writeFileAtomic(file, kept.join('\n'));
|
|
78
116
|
return true;
|
|
79
117
|
}
|
|
80
118
|
|
|
81
|
-
|
|
82
|
-
export function checkContract(repoRoot, changeledgerDir) {
|
|
119
|
+
export function checkContract(repoRoot) {
|
|
83
120
|
const errors = [];
|
|
84
121
|
const root = rootContract(repoRoot);
|
|
85
122
|
if (!fs.existsSync(root)) {
|
|
86
123
|
errors.push('missing AGENTS.md at the repo root (ChangeLedger contract reference lives here)');
|
|
87
124
|
}
|
|
88
|
-
// Every present, non-symlink contract file must carry the reference.
|
|
89
125
|
for (const name of CONTRACT_FILES) {
|
|
90
126
|
const file = path.join(repoRoot, name);
|
|
91
127
|
if (!isPlainFile(file)) continue;
|
|
92
|
-
|
|
128
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
129
|
+
if (!text.includes(MARKER)) {
|
|
93
130
|
errors.push(`${name} has no ChangeLedger reference — run \`changeledger register\``);
|
|
131
|
+
} else if (!text.includes(REFERENCE.trim())) {
|
|
132
|
+
errors.push(`${name} has an outdated ChangeLedger reference — run \`changeledger register\``);
|
|
94
133
|
}
|
|
95
134
|
}
|
|
96
|
-
if (!fs.existsSync(contractLink(changeledgerDir))) {
|
|
97
|
-
errors.push('`.changeledger/AGENTS.md` is missing or dangling — run `changeledger register`');
|
|
98
|
-
}
|
|
99
135
|
return errors;
|
|
100
136
|
}
|
package/src/paths.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
|
|
4
4
|
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
5
5
|
export const templatesDir = path.join(packageRoot, 'templates');
|
|
6
|
-
export const
|
|
6
|
+
export const contractTemplatesDir = path.join(templatesDir, 'contract');
|
|
7
7
|
export const publicDir = path.join(packageRoot, 'src', 'viewer', 'public');
|
|
8
8
|
|
|
9
9
|
// ISO 8601 UTC at second precision, matching the change `created`/task convention.
|