changeledger 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -5
- package/README.md +5 -1
- package/bin/changeledger.mjs +82 -6
- package/package.json +1 -1
- package/src/check.mjs +37 -1
- package/src/commands/agent-context.mjs +64 -0
- package/src/commands/agent-prompt.mjs +22 -0
- package/src/commands/agent.mjs +50 -7
- package/src/commands/context.mjs +8 -9
- package/src/commands/graduate.mjs +20 -8
- package/src/commands/view.mjs +6 -2
- package/src/contract.mjs +11 -5
- package/src/framing.mjs +22 -0
- package/src/lifecycle.mjs +4 -3
- package/src/metrics.mjs +3 -3
- package/src/viewer/domain.mjs +17 -7
- package/src/viewer/public/app-state.js +77 -6
- package/src/viewer/public/app.js +193 -42
- package/src/viewer/public/index.html +2 -2
- package/src/viewer/public/state.js +6 -2
- package/src/viewer/public/styles.css +205 -14
- package/src/viewer/public/view-parts.js +52 -0
- package/templates/contract/agent-contexts/implementation.md +18 -0
- package/templates/contract/agent-contexts/investigation.md +14 -0
- package/templates/contract/agent-contexts/review.md +22 -0
- package/templates/contract/agent-prompts/implementation.md +38 -0
- package/templates/contract/agent-prompts/investigation.md +41 -0
- package/templates/contract/agent-prompts/review.md +36 -0
- package/templates/contract/budgets.yml +16 -0
- package/templates/contract/close.md +25 -14
- package/templates/contract/core.md +43 -35
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +27 -11
- package/templates/contract/review.md +12 -9
- package/templates/contract/validation.md +16 -2
package/AGENTS.md
CHANGED
|
@@ -6,11 +6,17 @@ under `.changeledger/changes/`, persistent truth under `.changeledger/specs/`.
|
|
|
6
6
|
<!-- changeledger -->
|
|
7
7
|
> [!IMPORTANT]
|
|
8
8
|
> This repo uses **ChangeLedger**. Immediately after reading this file — before
|
|
9
|
-
> planning, investigating, or acting — run `changeledger context` directly
|
|
10
|
-
>
|
|
11
|
-
>
|
|
12
|
-
>
|
|
13
|
-
>
|
|
9
|
+
> planning, investigating, or acting — a normal agent must run `changeledger context` directly.
|
|
10
|
+
> Only a delegated leaf whose prompt was emitted by `changeledger agent-prompt <role>`
|
|
11
|
+
> runs `changeledger agent-context <role> [change-id]` instead; the role in the
|
|
12
|
+
> prompt and command must match. No other agent may skip the core context.
|
|
13
|
+
> On the first invocation, retain complete stdout through the `CHANGELEDGER CONTEXT END` line,
|
|
14
|
+
> or the `CHANGELEDGER AGENT CONTEXT END` line for that delegated path:
|
|
15
|
+
> no pipes, filters, summaries, previews or voluntary output limits. If the tool
|
|
16
|
+
> exposes an output budget, reserve enough for the whole response. A missing END
|
|
17
|
+
> after that is exceptional recovery: stop and re-run with a larger capture. If
|
|
18
|
+
> the command is unavailable, stop and restore/install ChangeLedger; do not
|
|
19
|
+
> proceed from memory.
|
|
14
20
|
>
|
|
15
21
|
> Do not create or modify files without an authorized change; the core context
|
|
16
22
|
> defines the workflow, the task contexts, and the narrow operational exception.
|
package/README.md
CHANGED
|
@@ -137,11 +137,15 @@ The contract ships as task-focused fragments and is compiled on demand:
|
|
|
137
137
|
changeledger context # minimal non-negotiable core
|
|
138
138
|
changeledger context <change-id> # lifecycle-aware rules + selected change
|
|
139
139
|
changeledger context review # explicit task mode
|
|
140
|
+
changeledger agent-prompt <role> # portable delegation skeleton
|
|
141
|
+
changeledger agent-context <role> [id] # self-contained context for that delegate
|
|
140
142
|
```
|
|
141
143
|
|
|
142
144
|
`init` places a small fail-closed bootstrap in the project-owned `AGENTS.md`;
|
|
143
145
|
there is no linked or copied contract under `.changeledger/`. Run
|
|
144
|
-
`changeledger register` after upgrading to refresh that bootstrap.
|
|
146
|
+
`changeledger register` after upgrading to refresh that bootstrap. Normal agents
|
|
147
|
+
load `context`; a delegated leaf identified by `agent-prompt` loads only its
|
|
148
|
+
matching `agent-context`, without the orchestrator core.
|
|
145
149
|
|
|
146
150
|
### Upgrading an existing repo's configuration
|
|
147
151
|
|
package/bin/changeledger.mjs
CHANGED
|
@@ -8,11 +8,15 @@ import {
|
|
|
8
8
|
list,
|
|
9
9
|
log,
|
|
10
10
|
owner,
|
|
11
|
+
reopen,
|
|
11
12
|
review,
|
|
12
13
|
show,
|
|
13
14
|
status,
|
|
14
15
|
task,
|
|
16
|
+
validation,
|
|
15
17
|
} from '../src/commands/agent.mjs';
|
|
18
|
+
import { agentContext } from '../src/commands/agent-context.mjs';
|
|
19
|
+
import { agentPrompt } from '../src/commands/agent-prompt.mjs';
|
|
16
20
|
import { check } from '../src/commands/check.mjs';
|
|
17
21
|
import { context } from '../src/commands/context.mjs';
|
|
18
22
|
import {
|
|
@@ -27,16 +31,17 @@ import { registerRepo } from '../src/commands/register.mjs';
|
|
|
27
31
|
import { initReleaseHistory, recordRelease, releasePlan } from '../src/commands/release.mjs';
|
|
28
32
|
import { view } from '../src/commands/view.mjs';
|
|
29
33
|
import { findChangeledgerDir } from '../src/config.mjs';
|
|
30
|
-
import { applyMigration
|
|
34
|
+
import { applyMigration } from '../src/config-migration.mjs';
|
|
31
35
|
import { nowUtc } from '../src/paths.mjs';
|
|
32
36
|
|
|
33
37
|
const { version } = createRequire(import.meta.url)('../package.json');
|
|
34
38
|
|
|
35
39
|
const USAGE = `ChangeLedger (changeledger)
|
|
36
40
|
|
|
37
|
-
Run \`changeledger context\` first in any repo
|
|
41
|
+
Run \`changeledger context\` first in any repo unless a ChangeLedger delegation
|
|
42
|
+
prompt identifies your role and tells you to run \`agent-context\` instead.
|
|
38
43
|
|
|
39
|
-
changeledger init | register | new | view | check | context
|
|
44
|
+
changeledger init | register | new | view | check | context | agent-context
|
|
40
45
|
changeledger status | discard | review | owner | archive | unarchive
|
|
41
46
|
changeledger log | task | list | show | graduate | config | release
|
|
42
47
|
|
|
@@ -62,7 +67,7 @@ program
|
|
|
62
67
|
.helpOption('-h, --help', 'display help for command')
|
|
63
68
|
.addHelpText(
|
|
64
69
|
'after',
|
|
65
|
-
'\nRun `changeledger context` first
|
|
70
|
+
'\nRun `changeledger context` first unless a ChangeLedger delegation prompt tells your role to use `agent-context`.\n' +
|
|
66
71
|
"Run `changeledger <command> --help` for that command's syntax, values and examples.",
|
|
67
72
|
);
|
|
68
73
|
|
|
@@ -179,6 +184,47 @@ program
|
|
|
179
184
|
)
|
|
180
185
|
.action(action((input) => context(input)));
|
|
181
186
|
|
|
187
|
+
program
|
|
188
|
+
.command('agent-prompt')
|
|
189
|
+
.description('print a portable delegation prompt skeleton for a role')
|
|
190
|
+
.argument('<role>', 'investigation | implementation | review')
|
|
191
|
+
.addHelpText(
|
|
192
|
+
'after',
|
|
193
|
+
[
|
|
194
|
+
'',
|
|
195
|
+
'Prints a fill-in-the-blanks delegation prompt for the given role. Works',
|
|
196
|
+
'outside a ChangeLedger repo — the skeletons ship inside the package.',
|
|
197
|
+
'',
|
|
198
|
+
'Examples:',
|
|
199
|
+
' changeledger agent-prompt investigation',
|
|
200
|
+
' changeledger agent-prompt implementation',
|
|
201
|
+
' changeledger agent-prompt review',
|
|
202
|
+
].join('\n'),
|
|
203
|
+
)
|
|
204
|
+
.action(action((role) => agentPrompt(role)));
|
|
205
|
+
|
|
206
|
+
program
|
|
207
|
+
.command('agent-context')
|
|
208
|
+
.description('print a self-contained minimal context for a delegated role')
|
|
209
|
+
.argument('<role>', 'investigation | implementation | review')
|
|
210
|
+
.argument('[change-id]', 'optional for investigation; required for implementation and review')
|
|
211
|
+
.addHelpText(
|
|
212
|
+
'after',
|
|
213
|
+
[
|
|
214
|
+
'',
|
|
215
|
+
'Use only when a delegation prompt emitted by `changeledger agent-prompt`',
|
|
216
|
+
'identifies you as that role. This replaces the normal core bootstrap for',
|
|
217
|
+
'the delegated leaf; normal agents still run `changeledger context` first.',
|
|
218
|
+
'',
|
|
219
|
+
'Examples:',
|
|
220
|
+
' changeledger agent-context investigation',
|
|
221
|
+
' changeledger agent-context investigation <id>',
|
|
222
|
+
' changeledger agent-context implementation <id>',
|
|
223
|
+
' changeledger agent-context review <id>',
|
|
224
|
+
].join('\n'),
|
|
225
|
+
)
|
|
226
|
+
.action(action((role, changeId) => agentContext(role, changeId)));
|
|
227
|
+
|
|
182
228
|
program
|
|
183
229
|
.command('status')
|
|
184
230
|
.description("move a change's lifecycle status (agent-owned, non-terminal moves only)")
|
|
@@ -192,7 +238,7 @@ program
|
|
|
192
238
|
[
|
|
193
239
|
'',
|
|
194
240
|
'Terminal moves are not accepted here: use `changeledger discard <id> "<reason>"`',
|
|
195
|
-
'to discard
|
|
241
|
+
'to discard. Only human validation in the viewer can reach done.',
|
|
196
242
|
'',
|
|
197
243
|
'Examples:',
|
|
198
244
|
' changeledger status <id> in-progress',
|
|
@@ -201,11 +247,41 @@ program
|
|
|
201
247
|
)
|
|
202
248
|
.action(
|
|
203
249
|
action((id, st) => {
|
|
204
|
-
status(id, st);
|
|
250
|
+
status(id, st, process.cwd(), { actor: 'agent' });
|
|
205
251
|
console.log(`#${id} → ${st}`);
|
|
206
252
|
}),
|
|
207
253
|
);
|
|
208
254
|
|
|
255
|
+
program
|
|
256
|
+
.command('validation')
|
|
257
|
+
.description('reject an in-validation change; accepting it remains human-only')
|
|
258
|
+
.argument('<id>')
|
|
259
|
+
.argument('<verdict>', 'fail')
|
|
260
|
+
.argument('<reason...>')
|
|
261
|
+
.action(
|
|
262
|
+
action((id, verdict, reasonParts) => {
|
|
263
|
+
if (verdict !== 'fail')
|
|
264
|
+
throw new Error(
|
|
265
|
+
'validation only accepts fail; human validation in the viewer accepts changes',
|
|
266
|
+
);
|
|
267
|
+
const reason = reasonParts.join(' ').trim();
|
|
268
|
+
validation(id, 'fail', { reason, actor: 'agent' });
|
|
269
|
+
console.log(`#${id} validation fail`);
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
program
|
|
274
|
+
.command('reopen')
|
|
275
|
+
.description('reopen a provisional done change with a reason')
|
|
276
|
+
.argument('<id>')
|
|
277
|
+
.argument('<reason...>')
|
|
278
|
+
.action(
|
|
279
|
+
action((id, reasonParts) => {
|
|
280
|
+
reopen(id, reasonParts.join(' ').trim(), process.cwd(), { actor: 'agent' });
|
|
281
|
+
console.log(`#${id} → in-progress`);
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
|
|
209
285
|
program
|
|
210
286
|
.command('discard')
|
|
211
287
|
.description('discard a change (terminal; keeps the record and reason)')
|
package/package.json
CHANGED
package/src/check.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pure validator: takes a loaded repo ({ config, changes }) and returns
|
|
2
2
|
// { errors, warnings }. No IO — the `changeledger check` command does the IO and printing.
|
|
3
3
|
|
|
4
|
+
import { parseChange } from './change.mjs';
|
|
4
5
|
import { CANONICAL_STATUSES, canTransition, parseLogEvent } from './lifecycle.mjs';
|
|
5
6
|
import { compareVersions, parseVersion, RELEASE_IMPACTS } from './release.mjs';
|
|
6
7
|
|
|
@@ -82,7 +83,7 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
|
|
|
82
83
|
|
|
83
84
|
if (fm.status === 'done' && tasks.some((t) => t.state !== 'done')) {
|
|
84
85
|
const pending = tasks.filter((t) => t.state !== 'done').length;
|
|
85
|
-
|
|
86
|
+
err(c, `status is "done" but ${pending} task(s) are not done`);
|
|
86
87
|
}
|
|
87
88
|
if (fm.status === 'blocked' && tasks.length && !tasks.some((t) => t.state === 'blocked')) {
|
|
88
89
|
warn(c, 'status is "blocked" but no task is marked [!]');
|
|
@@ -131,6 +132,41 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
|
|
|
131
132
|
return { errors, warnings };
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
// Reuses the scoped validator for a selected change, optionally replacing its
|
|
136
|
+
// in-memory text with a candidate that has not been written yet. Callers gate
|
|
137
|
+
// mutations on errors only; warnings remain informational by contract.
|
|
138
|
+
export function checkSelectedChange(repo, id, candidateText) {
|
|
139
|
+
let changes = repo.changes;
|
|
140
|
+
if (candidateText !== undefined) {
|
|
141
|
+
const index = changes.findIndex((c) => String(c.frontmatter?.id) === String(id));
|
|
142
|
+
if (index !== -1) {
|
|
143
|
+
const current = changes[index];
|
|
144
|
+
const candidate = { ...current, text: candidateText, ...parseChange(candidateText) };
|
|
145
|
+
changes = changes.with(index, candidate);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return checkRepo({ ...repo, changes }, { id });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function assertSelectedChangeValid(repo, id, candidateText) {
|
|
152
|
+
const { errors } = checkSelectedChange(repo, id, candidateText);
|
|
153
|
+
if (!errors.length) return;
|
|
154
|
+
throw new Error(
|
|
155
|
+
`change ${id} failed scoped validation:\n${errors.map((error) => `- ${error.message}`).join('\n')}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Validates one already-resolved file without loading or parsing siblings. This
|
|
160
|
+
// is the write-gate path for operations whose scope is exactly one change.
|
|
161
|
+
export function assertChangeTextValid(config, name, text) {
|
|
162
|
+
const parsed = parseChange(text);
|
|
163
|
+
const id = parsed.frontmatter.id;
|
|
164
|
+
assertSelectedChangeValid(
|
|
165
|
+
{ config, changes: [{ name, text, ...parsed }], specs: [], releases: [] },
|
|
166
|
+
id,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
134
170
|
function checkReleases(releases, changesById, err) {
|
|
135
171
|
const seenVersions = new Set();
|
|
136
172
|
const releasedChanges = new Map();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseChange } from '../change.mjs';
|
|
4
|
+
import { findChangeledgerDir, loadConfig } from '../config.mjs';
|
|
5
|
+
import { beginSentinel, endSentinel, VERSION } from '../framing.mjs';
|
|
6
|
+
import { contractTemplatesDir } from '../paths.mjs';
|
|
7
|
+
import { resolveChange } from '../repo.mjs';
|
|
8
|
+
import { transversalPolicy } from './context.mjs';
|
|
9
|
+
|
|
10
|
+
const ROLES = ['investigation', 'implementation', 'review'];
|
|
11
|
+
const ALLOWED_STATUSES = {
|
|
12
|
+
implementation: ['approved', 'in-progress'],
|
|
13
|
+
review: ['in-review'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function requireRepo(cwd) {
|
|
17
|
+
const dir = findChangeledgerDir(cwd);
|
|
18
|
+
if (!dir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function capsule(role) {
|
|
23
|
+
return fs
|
|
24
|
+
.readFileSync(path.join(contractTemplatesDir, 'agent-contexts', `${role}.md`), 'utf8')
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function selectedChange(role, changeId, cwd) {
|
|
29
|
+
if (role !== 'investigation' && !changeId) {
|
|
30
|
+
throw new Error(`role ${role} requires a change id`);
|
|
31
|
+
}
|
|
32
|
+
if (!changeId) return undefined;
|
|
33
|
+
|
|
34
|
+
const resolved = resolveChange(cwd, changeId);
|
|
35
|
+
const text = fs.readFileSync(resolved.file, 'utf8');
|
|
36
|
+
const { id, status } = parseChange(text).frontmatter;
|
|
37
|
+
const allowed = ALLOWED_STATUSES[role];
|
|
38
|
+
if (allowed && !allowed.includes(status)) {
|
|
39
|
+
const expected = allowed.join(' or ');
|
|
40
|
+
throw new Error(`role ${role} requires change status ${expected}; got ${status}`);
|
|
41
|
+
}
|
|
42
|
+
return { id, text };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildAgentContext(role, changeId, cwd = process.cwd()) {
|
|
46
|
+
if (!ROLES.includes(role)) {
|
|
47
|
+
throw new Error(`Unknown role "${role}" — valid roles: ${ROLES.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
const changeledgerDir = requireRepo(cwd);
|
|
50
|
+
const selected = selectedChange(role, changeId, cwd);
|
|
51
|
+
const change = selected ? ` — change: #${selected.id}` : '';
|
|
52
|
+
const sections = [
|
|
53
|
+
beginSentinel('AGENT CONTEXT', `role: ${role}${change} — v${VERSION}`),
|
|
54
|
+
transversalPolicy(loadConfig(changeledgerDir)),
|
|
55
|
+
capsule(role),
|
|
56
|
+
];
|
|
57
|
+
if (selected) sections.push('---\n\n# Selected change', selected.text.trim());
|
|
58
|
+
sections.push(endSentinel('AGENT CONTEXT'));
|
|
59
|
+
return `${sections.join('\n\n')}\n`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function agentContext(role, changeId, cwd = process.cwd(), output = console.log) {
|
|
63
|
+
output(buildAgentContext(role, changeId, cwd).trimEnd());
|
|
64
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { beginSentinel, endSentinel, VERSION } from '../framing.mjs';
|
|
4
|
+
import { contractTemplatesDir } from '../paths.mjs';
|
|
5
|
+
|
|
6
|
+
// Portable role skeletons ship inside the package, so this command works even
|
|
7
|
+
// outside an initialized ChangeLedger repo — it never reads project config.
|
|
8
|
+
const ROLES = ['investigation', 'implementation', 'review'];
|
|
9
|
+
|
|
10
|
+
export function buildAgentPrompt(role) {
|
|
11
|
+
if (!ROLES.includes(role)) {
|
|
12
|
+
throw new Error(`Unknown role "${role}" — valid roles: ${ROLES.join(', ')}`);
|
|
13
|
+
}
|
|
14
|
+
const file = path.join(contractTemplatesDir, 'agent-prompts', `${role}.md`);
|
|
15
|
+
const body = fs.readFileSync(file, 'utf8').trim();
|
|
16
|
+
const begin = beginSentinel('AGENT PROMPT', `role: ${role} — v${VERSION}`);
|
|
17
|
+
return `${begin}\n\n${body}\n\n${endSentinel('AGENT PROMPT')}\n`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function agentPrompt(role, output = console.log) {
|
|
21
|
+
output(buildAgentPrompt(role).trimEnd());
|
|
22
|
+
}
|
package/src/commands/agent.mjs
CHANGED
|
@@ -2,25 +2,28 @@
|
|
|
2
2
|
// (list/show). Files remain the source of truth; these are optional helpers
|
|
3
3
|
// that inject correct timestamps/markers and validate transitions.
|
|
4
4
|
|
|
5
|
+
import fs from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
6
|
-
import { mutateFileAtomic } from '../atomic-write.mjs';
|
|
7
|
+
import { mutateFileAtomic, withFileLock } from '../atomic-write.mjs';
|
|
7
8
|
import { parseChange } from '../change.mjs';
|
|
9
|
+
import { assertChangeTextValid } from '../check.mjs';
|
|
8
10
|
import { ownerHandle as defaultOwnerHandle } from '../git.mjs';
|
|
9
11
|
import { assertTransition } from '../lifecycle.mjs';
|
|
10
12
|
import { nowUtc } from '../paths.mjs';
|
|
13
|
+
import { resolveReleasesDir } from '../release.mjs';
|
|
11
14
|
import { loadRepo, resolveChange } from '../repo.mjs';
|
|
12
15
|
import { appendLog, setArchived, setOwner, setStatus, setTask } from '../writer.mjs';
|
|
13
16
|
|
|
14
17
|
function locate(cwd, id) {
|
|
15
|
-
const { config, file } = resolveChange(cwd, id);
|
|
16
|
-
return { config, file };
|
|
18
|
+
const { config, file, repoRoot } = resolveChange(cwd, id);
|
|
19
|
+
return { config, file, repoRoot };
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export function status(
|
|
20
23
|
id,
|
|
21
24
|
newStatus,
|
|
22
25
|
cwd = process.cwd(),
|
|
23
|
-
{ ownerHandle = defaultOwnerHandle } = {},
|
|
26
|
+
{ ownerHandle = defaultOwnerHandle, actor = 'human' } = {},
|
|
24
27
|
) {
|
|
25
28
|
const { config, file } = locate(cwd, id);
|
|
26
29
|
if (newStatus === 'discarded') {
|
|
@@ -31,12 +34,18 @@ export function status(
|
|
|
31
34
|
if (newStatus === 'done') {
|
|
32
35
|
throw new Error('to complete a change use human validation in the viewer');
|
|
33
36
|
}
|
|
37
|
+
if (newStatus === 'approved' && actor !== 'human') {
|
|
38
|
+
throw new Error('only the human-facing viewer can approve a draft change');
|
|
39
|
+
}
|
|
34
40
|
if (!(config.statuses ?? []).includes(newStatus)) {
|
|
35
41
|
throw new Error(`Invalid status "${newStatus}". Valid: ${(config.statuses ?? []).join(', ')}`);
|
|
36
42
|
}
|
|
37
43
|
const autoOwner = newStatus === 'in-progress' ? ownerHandle(path.dirname(file)) : '';
|
|
38
44
|
mutateFileAtomic(file, (text) => {
|
|
39
45
|
const fm = parseChange(text).frontmatter;
|
|
46
|
+
if (fm.status === 'done' && newStatus === 'in-progress') {
|
|
47
|
+
throw new Error('to reopen a done change use `changeledger reopen <id> "<reason>"`');
|
|
48
|
+
}
|
|
40
49
|
// Validate the move before any in-memory mutation, so an illegal transition
|
|
41
50
|
// leaves the file byte-for-byte unchanged. The review gate reads review_required
|
|
42
51
|
// from the change's type.
|
|
@@ -104,7 +113,7 @@ export function review(id, verdict, { mode, reason } = {}, cwd = process.cwd())
|
|
|
104
113
|
|
|
105
114
|
// Records the human verdict for the complete change. This is intentionally
|
|
106
115
|
// separate from `status`: only the human-facing viewer may close a change.
|
|
107
|
-
export function validation(id, verdict, { reason } = {}, cwd = process.cwd()) {
|
|
116
|
+
export function validation(id, verdict, { reason, actor = 'human' } = {}, cwd = process.cwd()) {
|
|
108
117
|
const { config, file } = locate(cwd, id);
|
|
109
118
|
mutateFileAtomic(file, (text) => {
|
|
110
119
|
const fm = parseChange(text).frontmatter;
|
|
@@ -126,17 +135,51 @@ export function validation(id, verdict, { reason } = {}, cwd = process.cwd()) {
|
|
|
126
135
|
reviewRequired: Boolean(config.types?.[fm.type]?.review_required),
|
|
127
136
|
});
|
|
128
137
|
text = setStatus(text, target);
|
|
129
|
-
|
|
138
|
+
text = appendLog(
|
|
130
139
|
text,
|
|
131
140
|
nowUtc(),
|
|
132
141
|
verdict === 'pass'
|
|
133
142
|
? 'validation → done (human accepted)'
|
|
134
|
-
: `validation → in-progress (
|
|
143
|
+
: `validation → in-progress (${actor} rejected): ${reason}`,
|
|
135
144
|
);
|
|
145
|
+
if (verdict === 'pass') assertChangeTextValid(config, path.basename(file), text);
|
|
146
|
+
return text;
|
|
136
147
|
});
|
|
137
148
|
return file;
|
|
138
149
|
}
|
|
139
150
|
|
|
151
|
+
// Correction path while `done` is still provisional. Graduation,
|
|
152
|
+
// skip, archive and release membership are durable boundaries and fail closed.
|
|
153
|
+
export function reopen(id, reason, cwd = process.cwd(), { actor = 'human' } = {}) {
|
|
154
|
+
if (!String(reason ?? '').trim()) throw new Error('reopen requires a reason');
|
|
155
|
+
const { config, file, repoRoot } = locate(cwd, id);
|
|
156
|
+
const releasesDir = resolveReleasesDir(repoRoot);
|
|
157
|
+
fs.mkdirSync(releasesDir, { recursive: true });
|
|
158
|
+
return withFileLock(path.join(releasesDir, '.history'), () => {
|
|
159
|
+
const released = loadRepo(cwd).releases.some((release) =>
|
|
160
|
+
(release.changes ?? []).some((changeId) => String(changeId) === String(id)),
|
|
161
|
+
);
|
|
162
|
+
mutateFileAtomic(file, (text) => {
|
|
163
|
+
const change = { ...parseChange(text), text };
|
|
164
|
+
const fm = change.frontmatter;
|
|
165
|
+
if (fm.status !== 'done')
|
|
166
|
+
throw new Error(`reopen requires status done (current: ${fm.status})`);
|
|
167
|
+
if (fm.reviewed === true) throw new Error('cannot reopen: graduation is already reviewed');
|
|
168
|
+
if (hasGraduationResolution(change))
|
|
169
|
+
throw new Error('cannot reopen: graduation is already resolved');
|
|
170
|
+
if (fm.archived === true) throw new Error('cannot reopen: change is archived');
|
|
171
|
+
if (released) throw new Error('cannot reopen: change belongs to a recorded release');
|
|
172
|
+
assertTransition('done', 'in-progress', {
|
|
173
|
+
type: fm.type,
|
|
174
|
+
reviewRequired: Boolean(config.types?.[fm.type]?.review_required),
|
|
175
|
+
});
|
|
176
|
+
text = setStatus(text, 'in-progress');
|
|
177
|
+
return appendLog(text, nowUtc(), `status: done → in-progress (${actor} reopened): ${reason}`);
|
|
178
|
+
});
|
|
179
|
+
return file;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
140
183
|
// name '-' clears the owner.
|
|
141
184
|
export function owner(id, name, cwd = process.cwd()) {
|
|
142
185
|
const { file } = locate(cwd, id);
|
package/src/commands/context.mjs
CHANGED
|
@@ -2,12 +2,11 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseChange } from '../change.mjs';
|
|
4
4
|
import { findChangeledgerDir, loadConfig } from '../config.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { beginSentinel, endSentinel, VERSION } from '../framing.mjs';
|
|
6
|
+
import { contractTemplatesDir } from '../paths.mjs';
|
|
6
7
|
import { resolveChange } from '../repo.mjs';
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
-
const END_DELIMITER =
|
|
10
|
-
'===== CHANGELEDGER CONTEXT END — if this line is missing, the output was truncated: stop and re-run =====';
|
|
9
|
+
const END_DELIMITER = endSentinel('CONTEXT');
|
|
11
10
|
const MODES = ['implement', 'review', 'spec', 'release'];
|
|
12
11
|
const MODE_CONTEXT = {
|
|
13
12
|
implement: ['implement', 'delegation', 'handoff'],
|
|
@@ -26,7 +25,7 @@ const STATUS_CONTEXT = {
|
|
|
26
25
|
discarded: { mode: 'discarded', fragments: ['discarded'] },
|
|
27
26
|
};
|
|
28
27
|
const INCREMENTAL_NOTICE = `This incremental context extends the complete core context already read.
|
|
29
|
-
|
|
28
|
+
Its one-pass full-capture rule applies here; a partial view is invalid.`;
|
|
30
29
|
|
|
31
30
|
function fragment(name) {
|
|
32
31
|
return fs.readFileSync(path.join(contractTemplatesDir, `${name}.md`), 'utf8').trim();
|
|
@@ -34,7 +33,7 @@ function fragment(name) {
|
|
|
34
33
|
|
|
35
34
|
function beginDelimiter(mode, changeId) {
|
|
36
35
|
const change = changeId ? ` — change: #${changeId}` : '';
|
|
37
|
-
return
|
|
36
|
+
return beginSentinel('CONTEXT', `mode: ${mode}${change} — v${VERSION}`);
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
// Resolved defaults so an agent never reads `.changeledger/config.yml` raw to
|
|
@@ -54,7 +53,7 @@ function effectiveTdd(config) {
|
|
|
54
53
|
|
|
55
54
|
// The transversal policy line every composition anchors on: effective language
|
|
56
55
|
// and tdd with defaults already resolved.
|
|
57
|
-
function transversalPolicy(config) {
|
|
56
|
+
export function transversalPolicy(config) {
|
|
58
57
|
return `Effective policy: language=${effectiveLanguage(config)} — tdd=${effectiveTdd(config)}`;
|
|
59
58
|
}
|
|
60
59
|
|
|
@@ -73,7 +72,7 @@ function changePolicyBlock(config, type) {
|
|
|
73
72
|
|
|
74
73
|
// One line per local dependency (id, title, status); external `project:id`
|
|
75
74
|
// references stay references, never pretending local resolution.
|
|
76
|
-
function dependencyBlock(
|
|
75
|
+
function dependencyBlock(dependsOn, cwd) {
|
|
77
76
|
if (!Array.isArray(dependsOn) || dependsOn.length === 0) return undefined;
|
|
78
77
|
const lines = dependsOn.map((raw) => {
|
|
79
78
|
const dep = String(raw);
|
|
@@ -140,7 +139,7 @@ export function buildContext(input, cwd = process.cwd()) {
|
|
|
140
139
|
changeText: text,
|
|
141
140
|
changeId: id,
|
|
142
141
|
policy: changePolicyBlock(config, type),
|
|
143
|
-
dependencies: dependencyBlock(
|
|
142
|
+
dependencies: dependencyBlock(dependsOn, cwd),
|
|
144
143
|
});
|
|
145
144
|
}
|
|
146
145
|
|
|
@@ -6,6 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { mutateFileAtomic, writeFileAtomic } from '../atomic-write.mjs';
|
|
8
8
|
import { parseChange } from '../change.mjs';
|
|
9
|
+
import { assertChangeTextValid } from '../check.mjs';
|
|
9
10
|
import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
|
|
10
11
|
import { nowUtc } from '../paths.mjs';
|
|
11
12
|
import { resolveChange } from '../repo.mjs';
|
|
@@ -30,11 +31,23 @@ function requireDone(changeText) {
|
|
|
30
31
|
return change;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
function requireGraduationReady(config, changeFile, changeText) {
|
|
35
|
+
const change = requireDone(changeText);
|
|
36
|
+
assertChangeTextValid(config, path.basename(changeFile), changeText);
|
|
37
|
+
return change;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
export function scaffoldSpec(id, slug, cwd = process.cwd()) {
|
|
34
|
-
const {
|
|
41
|
+
const {
|
|
42
|
+
config,
|
|
43
|
+
file: changeFile,
|
|
44
|
+
specsDir,
|
|
45
|
+
specName,
|
|
46
|
+
specFile,
|
|
47
|
+
} = graduationTarget(id, slug, cwd);
|
|
48
|
+
const change = requireGraduationReady(config, changeFile, fs.readFileSync(changeFile, 'utf8'));
|
|
35
49
|
if (fs.existsSync(specFile)) throw new Error(`Spec "${specName}" already exists`);
|
|
36
50
|
|
|
37
|
-
const change = requireDone(fs.readFileSync(changeFile, 'utf8'));
|
|
38
51
|
const seedStage =
|
|
39
52
|
change.stages.find((stage) => stage.key === 'specification') ??
|
|
40
53
|
change.stages.find((stage) => stage.key === 'proposal');
|
|
@@ -65,14 +78,15 @@ export function graduate(id, slug, cwd = process.cwd(), { into = false } = {}) {
|
|
|
65
78
|
if (!into) {
|
|
66
79
|
throw new Error('graduation mode required: use --new, --into, or --skip');
|
|
67
80
|
}
|
|
68
|
-
const { file: changeFile, specName, specFile } = graduationTarget(id, slug, cwd);
|
|
81
|
+
const { config, file: changeFile, specName, specFile } = graduationTarget(id, slug, cwd);
|
|
82
|
+
requireGraduationReady(config, changeFile, fs.readFileSync(changeFile, 'utf8'));
|
|
69
83
|
|
|
70
84
|
if (!fs.existsSync(specFile)) {
|
|
71
85
|
throw new Error(`Spec "${specName}" does not exist — use --new to create a scaffold`);
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
mutateFileAtomic(changeFile, (changeText) => {
|
|
75
|
-
|
|
89
|
+
requireGraduationReady(config, changeFile, changeText);
|
|
76
90
|
const specText = fs.readFileSync(specFile, 'utf8');
|
|
77
91
|
if (specText.includes(SPEC_SCAFFOLD_MARKER)) {
|
|
78
92
|
throw new Error(
|
|
@@ -91,12 +105,10 @@ export function graduate(id, slug, cwd = process.cwd(), { into = false } = {}) {
|
|
|
91
105
|
// Marks a done change's graduation as reviewed without creating a spec (e.g. a
|
|
92
106
|
// bug/chore with no persistent truth). Records the reason in the Log.
|
|
93
107
|
export function skipGraduation(id, reason, cwd = process.cwd()) {
|
|
94
|
-
const { file: changeFile } = resolveChange(cwd, id);
|
|
108
|
+
const { config, file: changeFile } = resolveChange(cwd, id);
|
|
95
109
|
const message = reason ? `graduation skipped: ${reason}` : 'graduation skipped';
|
|
96
110
|
mutateFileAtomic(changeFile, (text) => {
|
|
97
|
-
|
|
98
|
-
if (change.frontmatter.status !== 'done')
|
|
99
|
-
throw new Error('only done changes can be graduated/skipped');
|
|
111
|
+
requireGraduationReady(config, changeFile, text);
|
|
100
112
|
|
|
101
113
|
text = appendLog(text, nowUtc(), message);
|
|
102
114
|
return setReviewed(text, true);
|
package/src/commands/view.mjs
CHANGED
|
@@ -41,7 +41,11 @@ function parseViewArgs(args) {
|
|
|
41
41
|
return { localOnly, port };
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export async function view(
|
|
44
|
+
export async function view(
|
|
45
|
+
args = [],
|
|
46
|
+
cwd = process.cwd(),
|
|
47
|
+
{ openBrowser: shouldOpen = true } = {},
|
|
48
|
+
) {
|
|
45
49
|
const { localOnly, port: requestedPort } = parseViewArgs(args);
|
|
46
50
|
resolveProjects(cwd, localOnly); // fail fast if local mode outside a repo
|
|
47
51
|
|
|
@@ -54,7 +58,7 @@ export async function view(args = [], cwd = process.cwd()) {
|
|
|
54
58
|
const port = await listen(server, host, requestedPort);
|
|
55
59
|
const url = `http://${host}:${port}`;
|
|
56
60
|
console.log(`ChangeLedger viewer → ${url} (Ctrl+C to stop)`);
|
|
57
|
-
openBrowser(url);
|
|
61
|
+
if (shouldOpen) openBrowser(url);
|
|
58
62
|
return server;
|
|
59
63
|
}
|
|
60
64
|
|
package/src/contract.mjs
CHANGED
|
@@ -37,11 +37,17 @@ const LEGACY_CONTRACT_HASHES = new Set([
|
|
|
37
37
|
export const REFERENCE = `${MARKER}
|
|
38
38
|
> [!IMPORTANT]
|
|
39
39
|
> This repo uses **ChangeLedger**. Immediately after reading this file — before
|
|
40
|
-
> planning, investigating, or acting — run \`changeledger context\` directly
|
|
41
|
-
>
|
|
42
|
-
>
|
|
43
|
-
>
|
|
44
|
-
>
|
|
40
|
+
> planning, investigating, or acting — a normal agent must run \`changeledger context\` directly.
|
|
41
|
+
> Only a delegated leaf whose prompt was emitted by \`changeledger agent-prompt <role>\`
|
|
42
|
+
> runs \`changeledger agent-context <role> [change-id]\` instead; the role in the
|
|
43
|
+
> prompt and command must match. No other agent may skip the core context.
|
|
44
|
+
> On the first invocation, retain complete stdout through the \`CHANGELEDGER CONTEXT END\` line,
|
|
45
|
+
> or the \`CHANGELEDGER AGENT CONTEXT END\` line for that delegated path:
|
|
46
|
+
> no pipes, filters, summaries, previews or voluntary output limits. If the tool
|
|
47
|
+
> exposes an output budget, reserve enough for the whole response. A missing END
|
|
48
|
+
> after that is exceptional recovery: stop and re-run with a larger capture. If
|
|
49
|
+
> the command is unavailable, stop and restore/install ChangeLedger; do not
|
|
50
|
+
> proceed from memory.
|
|
45
51
|
>
|
|
46
52
|
> Do not create or modify files without an authorized change; the core context
|
|
47
53
|
> defines the workflow, the task contexts, and the narrow operational exception.
|
package/src/framing.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { packageRoot } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
// Single source of the installed version and the anti-truncation sentinels, so
|
|
6
|
+
// `context`, `agent-prompt` and `agent-context` share framing and never diverge
|
|
7
|
+
// through independent copies.
|
|
8
|
+
export const VERSION = JSON.parse(
|
|
9
|
+
fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
|
|
10
|
+
).version;
|
|
11
|
+
|
|
12
|
+
const TRUNCATION_SUFFIX = 'if this line is missing, the output was truncated: stop and re-run';
|
|
13
|
+
|
|
14
|
+
// `kind` names the payload (e.g. "CONTEXT", "AGENT PROMPT"); `meta` is the
|
|
15
|
+
// already-formatted descriptor (e.g. "mode: implement — v0.8.0").
|
|
16
|
+
export function beginSentinel(kind, meta) {
|
|
17
|
+
return `===== CHANGELEDGER ${kind} BEGIN — ${meta} =====`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function endSentinel(kind) {
|
|
21
|
+
return `===== CHANGELEDGER ${kind} END — ${TRUNCATION_SUFFIX} =====`;
|
|
22
|
+
}
|