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 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**. Read and follow [`.changeledger/AGENTS.md`](.changeledger/AGENTS.md)
9
- > (the change contract). If it is missing, run `changeledger register`.
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 **canonical ChangeLedger contract** (the convention every consuming repo
12
- follows) is shipped as [`templates/AGENTS.md`](templates/AGENTS.md) and linked
13
- into each repo as `.changeledger/AGENTS.md`. Edit the convention there, not here.
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
- - The contract you edit for the convention is `templates/AGENTS.md` it is the
25
- artifact that `changeledger init`/`register` symlink into other repos.
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 and links the
31
- installed agent contract. The repository keeps only its configuration and
32
- documents; the CLI and viewer remain in the global package.
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`; the complete rules live in
66
- [`AGENTS.md`](AGENTS.md).
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 # relink a moved or freshly cloned repository
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 itself ships with the CLI and is linked as `.changeledger/AGENTS.md`. It is
132
- per-machine and gitignored, so upgrades do not leave committed copies behind.
133
- Run `changeledger register` after cloning, moving a repository or updating the global
134
- installation.
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
- - On Windows, contract linking uses a symlink when permitted and falls back to a
141
- copy when Developer Mode or administrator privileges are unavailable.
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 [`AGENTS.md`](AGENTS.md).
163
+ contract is composed from [`templates/contract/`](templates/contract/).
155
164
 
156
165
  ## Language policy
157
166
 
@@ -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 (re)link this repo's path in the global registry
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("(re)link this repo's path in the global registry")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changeledger",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Turn conversations into buildable changes.",
5
5
  "type": "module",
6
6
  "bin": {
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 active = types[fm.type]?.stages;
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: config?.readiness?.target_patterns ?? ['src/**'],
370
- verification_patterns: config?.readiness?.verification_patterns ?? ['test/**'],
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
- if ('release' in c) checkReleaseConfig(c.release, c.types ?? {}, err);
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(c.types ?? {})) {
452
- for (const s of def?.stages ?? []) {
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');
@@ -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 AGENTS.md §6). `pass` advances to human validation;
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.
@@ -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, symlink), so it
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
+ }
@@ -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 { ensureGitignore, ensureReference, linkContract, rootContract } from '../contract.mjs';
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/` in the repo, gives it a stable identity, links the installed
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 (re)link this repo.',
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;
@@ -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 AGENTS.md §7-§8.
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 { ensureGitignore, ensureReference, linkContract, rootContract } from '../contract.mjs';
4
+ import {
5
+ ensureReference,
6
+ removeLegacyContract,
7
+ removeLegacyGitignore,
8
+ rootContract,
9
+ } from '../contract.mjs';
5
10
  import { register } from '../registry.mjs';
6
11
 
7
- // (Re)links the current repo's path to its config project_id in the global
8
- // registry, and regenerates the per-machine `.changeledger/AGENTS.md` contract link. Use
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
- linkContract(changeledgerDir);
23
- ensureGitignore(repoRoot);
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 });
@@ -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 { changeStatus, resolveProjects, searchProjects, serialize } from '../viewer/domain.mjs';
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/` directory. Returns its absolute path
6
- // or null if none is found.
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
- if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
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 REFERENCE = `${MARKER}
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**. Read and follow \`.changeledger/AGENTS.md\` (the change
20
- > contract). If it is missing, run \`changeledger register\`.
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
- // Create (or refresh) the `.changeledger/AGENTS.md` symlink to the installed contract.
36
- // Idempotent and tolerant of a pre-existing/dangling link.
37
- export function linkContract(changeledgerDir) {
38
- const link = contractLink(changeledgerDir);
39
- try {
40
- fs.lstatSync(link);
41
- fs.unlinkSync(link);
42
- } catch {
43
- // no existing link nothing to remove
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
- // Append the reference block to each present, non-symlink contract file unless
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
- if (text.includes(MARKER)) continue;
65
- writeFileAtomic(file, `${text}${text.endsWith('\n') ? '' : '\n'}\n${REFERENCE}`);
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
- // Ensure `.changeledger/AGENTS.md` is gitignored (it is a per-machine artifact).
72
- export function ensureGitignore(repoRoot) {
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
- const entry = '.changeledger/AGENTS.md';
75
- const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
76
- if (text.split('\n').some((l) => l.trim() === entry)) return false;
77
- writeFileAtomic(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${entry}\n`);
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
- // IO-level discovery validation (repo-wide). Returns a list of error messages.
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
- if (!fs.readFileSync(file, 'utf8').includes(MARKER)) {
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 agentsTemplate = path.join(templatesDir, 'AGENTS.md');
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.