changeledger 0.3.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 +25 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/bin/changeledger.mjs +375 -0
- package/package.json +72 -0
- package/src/atomic-write.mjs +134 -0
- package/src/change.mjs +102 -0
- package/src/check.mjs +536 -0
- package/src/commands/agent.mjs +256 -0
- package/src/commands/check.mjs +48 -0
- package/src/commands/graduate.mjs +105 -0
- package/src/commands/init.mjs +43 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/register.mjs +28 -0
- package/src/commands/release.mjs +138 -0
- package/src/commands/view.mjs +52 -0
- package/src/config.mjs +76 -0
- package/src/contract.mjs +100 -0
- package/src/git.mjs +73 -0
- package/src/lifecycle.mjs +57 -0
- package/src/metrics.mjs +143 -0
- package/src/paths.mjs +12 -0
- package/src/registry.mjs +55 -0
- package/src/release.mjs +71 -0
- package/src/repo.mjs +122 -0
- package/src/slug.mjs +12 -0
- package/src/spec.mjs +12 -0
- package/src/viewer/domain.mjs +133 -0
- package/src/viewer/public/api.js +25 -0
- package/src/viewer/public/app-state.js +87 -0
- package/src/viewer/public/app.js +717 -0
- package/src/viewer/public/index.html +64 -0
- package/src/viewer/public/security.js +65 -0
- package/src/viewer/public/state.js +31 -0
- package/src/viewer/public/styles.css +1062 -0
- package/src/viewer/public/templates.js +9 -0
- package/src/viewer/public/view-parts.js +162 -0
- package/src/viewer/public/view-renderers.js +191 -0
- package/src/viewer/server/router.mjs +200 -0
- package/src/viewer/server/security.mjs +27 -0
- package/src/writer.mjs +151 -0
- package/src/yaml.mjs +75 -0
- package/templates/AGENTS.md +540 -0
- package/templates/config.yml +55 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Agent-facing commands: safe mutations (status/log/task) and queries
|
|
2
|
+
// (list/show). Files remain the source of truth; these are optional helpers
|
|
3
|
+
// that inject correct timestamps/markers and validate transitions.
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { mutateFileAtomic } from '../atomic-write.mjs';
|
|
7
|
+
import { parseChange } from '../change.mjs';
|
|
8
|
+
import { ownerHandle as defaultOwnerHandle } from '../git.mjs';
|
|
9
|
+
import { assertTransition } from '../lifecycle.mjs';
|
|
10
|
+
import { nowUtc } from '../paths.mjs';
|
|
11
|
+
import { loadRepo, resolveChange } from '../repo.mjs';
|
|
12
|
+
import { appendLog, setArchived, setOwner, setStatus, setTask } from '../writer.mjs';
|
|
13
|
+
|
|
14
|
+
function locate(cwd, id) {
|
|
15
|
+
const { config, file } = resolveChange(cwd, id);
|
|
16
|
+
return { config, file };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function status(
|
|
20
|
+
id,
|
|
21
|
+
newStatus,
|
|
22
|
+
cwd = process.cwd(),
|
|
23
|
+
{ ownerHandle = defaultOwnerHandle } = {},
|
|
24
|
+
) {
|
|
25
|
+
const { config, file } = locate(cwd, id);
|
|
26
|
+
if (newStatus === 'discarded') {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'to discard a change use `changeledger discard <id> "<reason>"` (a reason is required)',
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (newStatus === 'done') {
|
|
32
|
+
throw new Error('to complete a change use human validation in the viewer');
|
|
33
|
+
}
|
|
34
|
+
if (!(config.statuses ?? []).includes(newStatus)) {
|
|
35
|
+
throw new Error(`Invalid status "${newStatus}". Valid: ${(config.statuses ?? []).join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
const autoOwner = newStatus === 'in-progress' ? ownerHandle(path.dirname(file)) : '';
|
|
38
|
+
mutateFileAtomic(file, (text) => {
|
|
39
|
+
const fm = parseChange(text).frontmatter;
|
|
40
|
+
// Validate the move before any in-memory mutation, so an illegal transition
|
|
41
|
+
// leaves the file byte-for-byte unchanged. The review gate reads review_required
|
|
42
|
+
// from the change's type.
|
|
43
|
+
assertTransition(fm.status, newStatus, {
|
|
44
|
+
type: fm.type,
|
|
45
|
+
reviewRequired: Boolean(config.types?.[fm.type]?.review_required),
|
|
46
|
+
});
|
|
47
|
+
text = setStatus(text, newStatus);
|
|
48
|
+
text = appendLog(text, nowUtc(), `status: ${fm.status} → ${newStatus}`);
|
|
49
|
+
|
|
50
|
+
// Work begins here: assign the owner from the local git identity unless one was
|
|
51
|
+
// set explicitly (see change 20260614-124047).
|
|
52
|
+
if (newStatus === 'in-progress' && !fm.owner && autoOwner) {
|
|
53
|
+
text = setOwner(text, autoOwner);
|
|
54
|
+
text = appendLog(text, nowUtc(), `owner → ${autoOwner} (auto)`);
|
|
55
|
+
}
|
|
56
|
+
return text;
|
|
57
|
+
});
|
|
58
|
+
return file;
|
|
59
|
+
}
|
|
60
|
+
|
|
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;
|
|
63
|
+
// `fail` routes it back: `retry` for a defect inside the contract (the
|
|
64
|
+
// implementer fixes), `block` for one that escalates to a human. Requires the
|
|
65
|
+
// change to be in-review.
|
|
66
|
+
export function review(id, verdict, { mode, reason } = {}, cwd = process.cwd()) {
|
|
67
|
+
const { file } = locate(cwd, id);
|
|
68
|
+
mutateFileAtomic(file, (text) => {
|
|
69
|
+
const { status: current } = parseChange(text).frontmatter;
|
|
70
|
+
if (current !== 'in-review') {
|
|
71
|
+
throw new Error(`review requires status in-review (current: ${current})`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (verdict === 'pass') {
|
|
75
|
+
text = setStatus(text, 'in-validation');
|
|
76
|
+
text = appendLog(
|
|
77
|
+
text,
|
|
78
|
+
nowUtc(),
|
|
79
|
+
'review → in-validation (delegated subagent, clean context)',
|
|
80
|
+
);
|
|
81
|
+
} else if (verdict === 'fail') {
|
|
82
|
+
if (!reason) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
'fail requires a reason — changeledger review <id> fail --retry|--block "<reason>"',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (mode === 'retry') {
|
|
88
|
+
text = setStatus(text, 'in-progress');
|
|
89
|
+
text = appendLog(text, nowUtc(), `review → in-progress (retry): ${reason}`);
|
|
90
|
+
} else if (mode === 'block') {
|
|
91
|
+
text = setStatus(text, 'blocked');
|
|
92
|
+
text = appendLog(text, nowUtc(), `review → blocked: ${reason}`);
|
|
93
|
+
} else {
|
|
94
|
+
throw new Error('fail requires --retry or --block');
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error(`Unknown review verdict "${verdict}" (use pass|fail)`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return text;
|
|
101
|
+
});
|
|
102
|
+
return file;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Records the human verdict for the complete change. This is intentionally
|
|
106
|
+
// separate from `status`: only the human-facing viewer may close a change.
|
|
107
|
+
export function validation(id, verdict, { reason } = {}, cwd = process.cwd()) {
|
|
108
|
+
const { config, file } = locate(cwd, id);
|
|
109
|
+
mutateFileAtomic(file, (text) => {
|
|
110
|
+
const fm = parseChange(text).frontmatter;
|
|
111
|
+
const current = fm.status;
|
|
112
|
+
let target;
|
|
113
|
+
if (verdict === 'pass') {
|
|
114
|
+
target = 'done';
|
|
115
|
+
} else if (verdict === 'fail') {
|
|
116
|
+
if (!reason) throw new Error('validation fail requires a reason');
|
|
117
|
+
target = 'in-progress';
|
|
118
|
+
} else {
|
|
119
|
+
throw new Error(`Unknown validation verdict "${verdict}" (use pass|fail)`);
|
|
120
|
+
}
|
|
121
|
+
if (current !== 'in-validation') {
|
|
122
|
+
throw new Error(`validation requires status in-validation (current: ${current})`);
|
|
123
|
+
}
|
|
124
|
+
assertTransition(current, target, {
|
|
125
|
+
type: fm.type,
|
|
126
|
+
reviewRequired: Boolean(config.types?.[fm.type]?.review_required),
|
|
127
|
+
});
|
|
128
|
+
text = setStatus(text, target);
|
|
129
|
+
return appendLog(
|
|
130
|
+
text,
|
|
131
|
+
nowUtc(),
|
|
132
|
+
verdict === 'pass'
|
|
133
|
+
? 'validation → done (human accepted)'
|
|
134
|
+
: `validation → in-progress (human rejected): ${reason}`,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
return file;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// name '-' clears the owner.
|
|
141
|
+
export function owner(id, name, cwd = process.cwd()) {
|
|
142
|
+
const { file } = locate(cwd, id);
|
|
143
|
+
const next = name === '-' ? null : name;
|
|
144
|
+
mutateFileAtomic(file, (text) => {
|
|
145
|
+
text = setOwner(text, next);
|
|
146
|
+
return appendLog(text, nowUtc(), next ? `owner → ${next}` : 'owner cleared');
|
|
147
|
+
});
|
|
148
|
+
return file;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Discards a change: a terminal lifecycle move that keeps the file and its
|
|
152
|
+
// reasoning instead of deleting it. The reason is mandatory and recorded in the
|
|
153
|
+
// Log; the transition graph rejects discarding a done or in-review change.
|
|
154
|
+
export function discard(id, reason, cwd = process.cwd()) {
|
|
155
|
+
if (!reason) {
|
|
156
|
+
throw new Error('discard requires a reason — changeledger discard <id> "<reason>"');
|
|
157
|
+
}
|
|
158
|
+
const { config, file } = locate(cwd, id);
|
|
159
|
+
mutateFileAtomic(file, (text) => {
|
|
160
|
+
const fm = parseChange(text).frontmatter;
|
|
161
|
+
// Validate before any mutation so an illegal discard leaves the file untouched.
|
|
162
|
+
assertTransition(fm.status, 'discarded', {
|
|
163
|
+
type: fm.type,
|
|
164
|
+
reviewRequired: Boolean(config.types?.[fm.type]?.review_required),
|
|
165
|
+
});
|
|
166
|
+
text = setStatus(text, 'discarded');
|
|
167
|
+
return appendLog(text, nowUtc(), `status: ${fm.status} → discarded: ${reason}`);
|
|
168
|
+
});
|
|
169
|
+
return file;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function archive(id, on, cwd = process.cwd()) {
|
|
173
|
+
const { file } = locate(cwd, id);
|
|
174
|
+
mutateFileAtomic(file, (text) => {
|
|
175
|
+
text = setArchived(text, on);
|
|
176
|
+
return appendLog(text, nowUtc(), on ? 'archived' : 'unarchived');
|
|
177
|
+
});
|
|
178
|
+
return file;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function archiveGraduated({ dryRun = false } = {}, cwd = process.cwd()) {
|
|
182
|
+
const { changes } = loadRepo(cwd);
|
|
183
|
+
const selected = changes.filter((c) => isArchivableGraduated(c));
|
|
184
|
+
if (!dryRun) {
|
|
185
|
+
for (const c of selected) {
|
|
186
|
+
mutateFileAtomic(c.file, (text) => {
|
|
187
|
+
const current = { ...parseChange(text), text };
|
|
188
|
+
if (!isArchivableGraduated(current)) return undefined;
|
|
189
|
+
text = setArchived(text, true);
|
|
190
|
+
return appendLog(text, nowUtc(), 'archived');
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return selected.map((c) => ({
|
|
195
|
+
id: c.frontmatter.id,
|
|
196
|
+
title: c.frontmatter.title,
|
|
197
|
+
file: c.file,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isArchivableGraduated(c) {
|
|
202
|
+
return (
|
|
203
|
+
c.frontmatter.status === 'done' &&
|
|
204
|
+
c.frontmatter.reviewed === true &&
|
|
205
|
+
c.frontmatter.archived !== true &&
|
|
206
|
+
hasGraduationResolution(c)
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function hasGraduationResolution(c) {
|
|
211
|
+
const logBody = c.stages.find((s) => s.key === 'log')?.body ?? '';
|
|
212
|
+
return logBody
|
|
213
|
+
.split('\n')
|
|
214
|
+
.some((line) => /—\s*(graduado a spec `[^`]+`|graduation skipped\b)/i.test(line));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function log(id, message, cwd = process.cwd()) {
|
|
218
|
+
const { file } = locate(cwd, id);
|
|
219
|
+
mutateFileAtomic(file, (text) => appendLog(text, nowUtc(), message));
|
|
220
|
+
return file;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function task(id, action, n, reason, cwd = process.cwd()) {
|
|
224
|
+
const { file } = locate(cwd, id);
|
|
225
|
+
mutateFileAtomic(file, (text) => {
|
|
226
|
+
if (action === 'done') return setTask(text, n, 'done', { iso: nowUtc() });
|
|
227
|
+
if (action === 'block') return setTask(text, n, 'blocked', { reason });
|
|
228
|
+
throw new Error(`Unknown task action "${action}" (use done|block)`);
|
|
229
|
+
});
|
|
230
|
+
return file;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function list({ status: byStatus, type: byType } = {}, cwd = process.cwd()) {
|
|
234
|
+
return loadRepo(cwd)
|
|
235
|
+
.changes.map((c) => ({
|
|
236
|
+
id: c.frontmatter.id,
|
|
237
|
+
title: c.frontmatter.title,
|
|
238
|
+
type: c.frontmatter.type,
|
|
239
|
+
status: c.frontmatter.status,
|
|
240
|
+
owner: c.frontmatter.owner ?? null,
|
|
241
|
+
progress: c.progress,
|
|
242
|
+
}))
|
|
243
|
+
.filter((c) => (!byStatus || c.status === byStatus) && (!byType || c.type === byType));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function show(id, cwd = process.cwd()) {
|
|
247
|
+
const c = loadRepo(cwd).changes.find((x) => String(x.frontmatter.id) === String(id));
|
|
248
|
+
if (!c) throw new Error(`No change with id "${id}"`);
|
|
249
|
+
return {
|
|
250
|
+
id: c.frontmatter.id,
|
|
251
|
+
frontmatter: c.frontmatter,
|
|
252
|
+
stages: c.stages,
|
|
253
|
+
tasks: c.tasks,
|
|
254
|
+
progress: c.progress,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { checkRepo } from '../check.mjs';
|
|
2
|
+
import { checkContract } from '../contract.mjs';
|
|
3
|
+
import { loadRepo } from '../repo.mjs';
|
|
4
|
+
|
|
5
|
+
// Validates the repo (or a single change with `changeledger check <id>`). Prints findings
|
|
6
|
+
// and returns an exit code (1 if errors).
|
|
7
|
+
export function check(args = [], cwd = process.cwd(), output = console) {
|
|
8
|
+
const json = args.includes('--json');
|
|
9
|
+
const id = args.find((a) => !a.startsWith('--'));
|
|
10
|
+
|
|
11
|
+
let repo;
|
|
12
|
+
try {
|
|
13
|
+
repo = loadRepo(cwd);
|
|
14
|
+
} catch (e) {
|
|
15
|
+
if (json)
|
|
16
|
+
output.log(
|
|
17
|
+
JSON.stringify({ errors: [{ file: '(repo)', message: e.message }], warnings: [] }, null, 2),
|
|
18
|
+
);
|
|
19
|
+
else output.error(` error (repo): ${e.message}`);
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { errors, warnings } = checkRepo(repo, { id });
|
|
24
|
+
|
|
25
|
+
// Discovery validation needs the filesystem (root contract, symlink), so it
|
|
26
|
+
// lives here, not in the pure validator. Repo-wide only.
|
|
27
|
+
if (!id) {
|
|
28
|
+
for (const message of checkContract(repo.repoRoot, repo.changeledgerDir)) {
|
|
29
|
+
errors.push({ file: 'AGENTS.md', message });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (json) {
|
|
34
|
+
output.log(JSON.stringify({ errors, warnings }, null, 2));
|
|
35
|
+
return errors.length ? 1 : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const w of warnings) output.warn(` warn ${w.file}: ${w.message}`);
|
|
39
|
+
for (const e of errors) output.error(` error ${e.file}: ${e.message}`);
|
|
40
|
+
|
|
41
|
+
const scope = id ? `change ${id}` : `${repo.changes.length} change(s)`;
|
|
42
|
+
if (!errors.length && !warnings.length) {
|
|
43
|
+
output.log(`✓ ${scope} valid`);
|
|
44
|
+
} else {
|
|
45
|
+
output.log(`\n${errors.length} error(s), ${warnings.length} warning(s) — ${scope}`);
|
|
46
|
+
}
|
|
47
|
+
return errors.length ? 1 : 0;
|
|
48
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Graduates a change to a spec: scaffolds the spec file (frontmatter + a body
|
|
2
|
+
// seeded from the change's Specification/Proposal) and links it back in the
|
|
3
|
+
// change's Log. The final wording stays a manual/agent task.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { mutateFileAtomic, writeFileAtomic } from '../atomic-write.mjs';
|
|
8
|
+
import { parseChange } from '../change.mjs';
|
|
9
|
+
import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
|
|
10
|
+
import { nowUtc } from '../paths.mjs';
|
|
11
|
+
import { resolveChange } from '../repo.mjs';
|
|
12
|
+
import { slugify } from '../slug.mjs';
|
|
13
|
+
import { appendLog, setReviewed, setSpecUpdated } from '../writer.mjs';
|
|
14
|
+
import { serializeScalar } from '../yaml.mjs';
|
|
15
|
+
|
|
16
|
+
// `into: true` graduates into an EXISTING spec — it refreshes the spec's
|
|
17
|
+
// `updated` and links it back, but leaves the body to the agent (who knows what
|
|
18
|
+
// to refine). Without `into`, a new spec is scaffolded and an existing one is an
|
|
19
|
+
// error. Both routes share the same change-side record (marker + reviewed).
|
|
20
|
+
export function graduate(id, slug, cwd = process.cwd(), { into = false } = {}) {
|
|
21
|
+
const { config, repoRoot, file: changeFile } = resolveChange(cwd, id);
|
|
22
|
+
|
|
23
|
+
const specsDir = resolveSpecsDir(repoRoot, config);
|
|
24
|
+
const specName = `${slugify(slug)}.md`;
|
|
25
|
+
const specFile = path.join(specsDir, specName);
|
|
26
|
+
|
|
27
|
+
// Validate preconditions before acquiring the change file lock — ensures no
|
|
28
|
+
// write happens at all when the existence check fails (CR1).
|
|
29
|
+
const exists = fs.existsSync(specFile);
|
|
30
|
+
if (into && !exists)
|
|
31
|
+
throw new Error(`Spec "${specName}" does not exist — drop --into to create it`);
|
|
32
|
+
if (!into && exists) throw new Error(`Spec "${specName}" already exists`);
|
|
33
|
+
|
|
34
|
+
mutateFileAtomic(changeFile, (changeText) => {
|
|
35
|
+
const change = parseChange(changeText);
|
|
36
|
+
if (change.frontmatter.status !== 'done') {
|
|
37
|
+
throw new Error('only done changes can be graduated/skipped');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (into) {
|
|
41
|
+
// Refresh the spec's updated; the body stays the agent's to edit.
|
|
42
|
+
writeFileAtomic(specFile, setSpecUpdated(fs.readFileSync(specFile, 'utf8'), nowUtc()));
|
|
43
|
+
} else {
|
|
44
|
+
const seedStage =
|
|
45
|
+
change.stages.find((s) => s.key === 'specification') ??
|
|
46
|
+
change.stages.find((s) => s.key === 'proposal');
|
|
47
|
+
const seed = seedStage ? seedStage.body : '';
|
|
48
|
+
const title = change.frontmatter.title;
|
|
49
|
+
|
|
50
|
+
const content = `---
|
|
51
|
+
title: ${serializeScalar(title)}
|
|
52
|
+
updated: ${nowUtc()}
|
|
53
|
+
tags: [${change.frontmatter.type}]
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
# ${title}
|
|
57
|
+
|
|
58
|
+
> Graduado del change ${id}.
|
|
59
|
+
|
|
60
|
+
${seed}
|
|
61
|
+
`;
|
|
62
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
63
|
+
writeFileAtomic(specFile, content);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let text = appendLog(changeText, nowUtc(), `graduado a spec \`${specName}\``);
|
|
67
|
+
text = setReviewed(text, true);
|
|
68
|
+
return text;
|
|
69
|
+
});
|
|
70
|
+
return specFile;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Marks a done change's graduation as reviewed without creating a spec (e.g. a
|
|
74
|
+
// bug/chore with no persistent truth). Records the reason in the Log.
|
|
75
|
+
export function skipGraduation(id, reason, cwd = process.cwd()) {
|
|
76
|
+
const { file: changeFile } = resolveChange(cwd, id);
|
|
77
|
+
const message = reason ? `graduation skipped: ${reason}` : 'graduation skipped';
|
|
78
|
+
mutateFileAtomic(changeFile, (text) => {
|
|
79
|
+
const change = parseChange(text);
|
|
80
|
+
if (change.frontmatter.status !== 'done')
|
|
81
|
+
throw new Error('only done changes can be graduated/skipped');
|
|
82
|
+
|
|
83
|
+
text = appendLog(text, nowUtc(), message);
|
|
84
|
+
return setReviewed(text, true);
|
|
85
|
+
});
|
|
86
|
+
return changeFile;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Lists done changes whose graduation has not been reviewed yet.
|
|
90
|
+
export function pendingGraduation(cwd = process.cwd()) {
|
|
91
|
+
const changeledgerDir = findChangeledgerDir(cwd);
|
|
92
|
+
if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
93
|
+
const config = loadConfig(changeledgerDir);
|
|
94
|
+
const repoRoot = path.dirname(changeledgerDir);
|
|
95
|
+
const changesDir = resolveRepoPath(repoRoot, config.changes_dir, 'changes_dir');
|
|
96
|
+
if (!fs.existsSync(changesDir)) return [];
|
|
97
|
+
|
|
98
|
+
return fs
|
|
99
|
+
.readdirSync(changesDir)
|
|
100
|
+
.filter((n) => n.endsWith('.md'))
|
|
101
|
+
.sort()
|
|
102
|
+
.map((n) => ({ name: n, ...parseChange(fs.readFileSync(path.join(changesDir, n), 'utf8')) }))
|
|
103
|
+
.filter((c) => c.frontmatter.status === 'done' && c.frontmatter.reviewed !== true)
|
|
104
|
+
.map((c) => ({ id: c.frontmatter.id, title: c.frontmatter.title, type: c.frontmatter.type }));
|
|
105
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { writeFileAtomic } from '../atomic-write.mjs';
|
|
5
|
+
import { ensureGitignore, ensureReference, linkContract, rootContract } from '../contract.mjs';
|
|
6
|
+
import { templatesDir } from '../paths.mjs';
|
|
7
|
+
import { register } from '../registry.mjs';
|
|
8
|
+
import { serializeScalar } from '../yaml.mjs';
|
|
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
|
|
12
|
+
// AGENTS.md, and registers the repo in the global registry.
|
|
13
|
+
export function init(cwd = process.cwd()) {
|
|
14
|
+
const repoRoot = path.resolve(cwd);
|
|
15
|
+
const changeledgerDir = path.join(repoRoot, '.changeledger');
|
|
16
|
+
if (fs.existsSync(changeledgerDir)) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'.changeledger/ already exists. Use `changeledger register` to (re)link this repo.',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
// The root AGENTS.md is the project's own contract; we reference the tool's
|
|
22
|
+
// contract from it but never create it ourselves. Fail before touching .changeledger/.
|
|
23
|
+
if (!fs.existsSync(rootContract(repoRoot))) {
|
|
24
|
+
throw new Error('Create AGENTS.md at the repo root first, then re-run `changeledger init`.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fs.mkdirSync(path.join(changeledgerDir, 'changes'), { recursive: true });
|
|
28
|
+
const configFile = path.join(changeledgerDir, 'config.yml');
|
|
29
|
+
|
|
30
|
+
const id = crypto.randomBytes(5).toString('hex');
|
|
31
|
+
const name = path.basename(repoRoot);
|
|
32
|
+
writeFileAtomic(
|
|
33
|
+
configFile,
|
|
34
|
+
`${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
|
+
);
|
|
36
|
+
|
|
37
|
+
linkContract(changeledgerDir);
|
|
38
|
+
ensureReference(repoRoot);
|
|
39
|
+
ensureGitignore(repoRoot);
|
|
40
|
+
|
|
41
|
+
register({ id, name, path: repoRoot });
|
|
42
|
+
return changeledgerDir;
|
|
43
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { findChangeledgerDir, loadConfig, resolveRepoPath } from '../config.mjs';
|
|
4
|
+
import { slugify } from '../slug.mjs';
|
|
5
|
+
import { serializeScalar } from '../yaml.mjs';
|
|
6
|
+
|
|
7
|
+
// Applied only when JSON parse fails — governs mtime-based staleness fallback.
|
|
8
|
+
// Not the primary timeout: the main strategy is PID liveness (process.kill 0),
|
|
9
|
+
// which is more robust for id-collision prevention than a wall-clock timeout.
|
|
10
|
+
const LOCK_MTIME_STALE_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
// Scaffolds a new change file with the active stages for its type.
|
|
13
|
+
// `slug` is the English filename slug (structure); `title` is the content title
|
|
14
|
+
// (repo language). See AGENTS.md §7-§8.
|
|
15
|
+
export function newChange({ type, slug, title, owner, now }, cwd = process.cwd()) {
|
|
16
|
+
const changeledgerDir = findChangeledgerDir(cwd);
|
|
17
|
+
if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
18
|
+
|
|
19
|
+
const config = loadConfig(changeledgerDir);
|
|
20
|
+
const typeDef = config.types?.[type];
|
|
21
|
+
if (!typeDef) {
|
|
22
|
+
throw new Error(`Unknown type "${type}". Valid: ${Object.keys(config.types ?? {}).join(', ')}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const repoRoot = path.dirname(changeledgerDir);
|
|
26
|
+
const changesDir = resolveRepoPath(repoRoot, config.changes_dir, 'changes_dir');
|
|
27
|
+
fs.mkdirSync(changesDir, { recursive: true });
|
|
28
|
+
const normalizedSlug = slugify(slug);
|
|
29
|
+
|
|
30
|
+
// Guarantee a unique id even for changes created within the same second
|
|
31
|
+
// (an agent creating several in a loop). Bump by 1s until free; keep created
|
|
32
|
+
// coherent with the id. The final reservation is atomic (`wx`), so two
|
|
33
|
+
// separate `changeledger new` processes racing in the same second cannot both win the
|
|
34
|
+
// same id.
|
|
35
|
+
let created = now;
|
|
36
|
+
let id = idFromTimestamp(created);
|
|
37
|
+
for (;;) {
|
|
38
|
+
if (idTaken(changesDir, id)) {
|
|
39
|
+
created = bumpSecond(created);
|
|
40
|
+
id = idFromTimestamp(created);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const lock = acquireIdLock(changesDir, id);
|
|
44
|
+
if (!lock) {
|
|
45
|
+
created = bumpSecond(created);
|
|
46
|
+
id = idFromTimestamp(created);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Re-check after acquiring the lock: another process may have written a
|
|
51
|
+
// file with this id between our idTaken() check and acquireIdLock().
|
|
52
|
+
if (idTaken(changesDir, id)) {
|
|
53
|
+
releaseIdLock(lock);
|
|
54
|
+
created = bumpSecond(created);
|
|
55
|
+
id = idFromTimestamp(created);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const file = path.join(changesDir, `${id}-${normalizedSlug}.md`);
|
|
60
|
+
try {
|
|
61
|
+
fs.writeFileSync(
|
|
62
|
+
file,
|
|
63
|
+
render({ id, title, type, owner, stages: typeDef.stages, now: created }),
|
|
64
|
+
{
|
|
65
|
+
flag: 'wx',
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
return file;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (e.code !== 'EEXIST') throw e;
|
|
71
|
+
created = bumpSecond(created);
|
|
72
|
+
id = idFromTimestamp(created);
|
|
73
|
+
} finally {
|
|
74
|
+
releaseIdLock(lock);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function acquireIdLock(changesDir, id) {
|
|
80
|
+
const lock = path.join(changesDir, `.${id}.lock`);
|
|
81
|
+
let attempts = 0;
|
|
82
|
+
for (;;) {
|
|
83
|
+
try {
|
|
84
|
+
const fd = fs.openSync(lock, 'wx');
|
|
85
|
+
fs.writeFileSync(
|
|
86
|
+
fd,
|
|
87
|
+
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }),
|
|
88
|
+
);
|
|
89
|
+
return { fd, path: lock };
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (e.code !== 'EEXIST') throw e;
|
|
92
|
+
if (!isStaleLock(lock)) return null;
|
|
93
|
+
if (++attempts > 5) return null;
|
|
94
|
+
fs.rmSync(lock, { force: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function releaseIdLock(lock) {
|
|
100
|
+
fs.closeSync(lock.fd);
|
|
101
|
+
fs.rmSync(lock.path, { force: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isStaleLock(lock) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = fs.readFileSync(lock, 'utf8');
|
|
107
|
+
const data = JSON.parse(raw);
|
|
108
|
+
return !Number.isInteger(data.pid) || !processIsAlive(data.pid);
|
|
109
|
+
} catch {
|
|
110
|
+
try {
|
|
111
|
+
return Date.now() - fs.statSync(lock).mtimeMs > LOCK_MTIME_STALE_MS;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (e.code === 'ENOENT') return true;
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function processIsAlive(pid) {
|
|
120
|
+
try {
|
|
121
|
+
process.kill(pid, 0);
|
|
122
|
+
return true;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return e.code === 'EPERM';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function idTaken(changesDir, id) {
|
|
129
|
+
return fs.readdirSync(changesDir).some((name) => name.startsWith(`${id}-`));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function bumpSecond(iso) {
|
|
133
|
+
const t = new Date(iso).getTime() + 1000;
|
|
134
|
+
return new Date(t).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Derives the canonical id from an ISO 8601 UTC timestamp: YYYYMMDD-HHMMSS.
|
|
138
|
+
export function idFromTimestamp(iso) {
|
|
139
|
+
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
|
|
140
|
+
if (!m) throw new Error(`Invalid ISO timestamp: ${iso}`);
|
|
141
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
142
|
+
return `${y}${mo}${d}-${h}${mi}${s}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function heading(stageKey) {
|
|
146
|
+
return stageKey.charAt(0).toUpperCase() + stageKey.slice(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function render({ id, title, type, owner, stages, now }) {
|
|
150
|
+
const fm = [
|
|
151
|
+
'---',
|
|
152
|
+
`id: "${id}"`,
|
|
153
|
+
`title: ${serializeScalar(title)}`,
|
|
154
|
+
`type: ${type}`,
|
|
155
|
+
'status: draft',
|
|
156
|
+
`created: ${now}`,
|
|
157
|
+
'depends_on: []',
|
|
158
|
+
...(owner ? [`owner: ${serializeScalar(owner)}`] : []),
|
|
159
|
+
'---',
|
|
160
|
+
'',
|
|
161
|
+
].join('\n');
|
|
162
|
+
const body = stages.map((s) => `## ${heading(s)}\n`).join('\n');
|
|
163
|
+
return `${fm}\n${body}`;
|
|
164
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { findChangeledgerDir, loadConfig } from '../config.mjs';
|
|
4
|
+
import { ensureGitignore, ensureReference, linkContract, rootContract } from '../contract.mjs';
|
|
5
|
+
import { register } from '../registry.mjs';
|
|
6
|
+
|
|
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.
|
|
10
|
+
export function registerRepo(cwd = process.cwd()) {
|
|
11
|
+
const changeledgerDir = findChangeledgerDir(cwd);
|
|
12
|
+
if (!changeledgerDir) throw new Error('Not a ChangeLedger repo. Run `changeledger init` first.');
|
|
13
|
+
|
|
14
|
+
const config = loadConfig(changeledgerDir);
|
|
15
|
+
if (!config.project_id) {
|
|
16
|
+
throw new Error('config.yml has no project_id. Run `changeledger init` to create one.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const repoRoot = path.dirname(changeledgerDir);
|
|
20
|
+
const name = config.project_name || path.basename(repoRoot);
|
|
21
|
+
|
|
22
|
+
linkContract(changeledgerDir);
|
|
23
|
+
ensureGitignore(repoRoot);
|
|
24
|
+
if (fs.existsSync(rootContract(repoRoot))) ensureReference(repoRoot);
|
|
25
|
+
|
|
26
|
+
register({ id: config.project_id, name, path: repoRoot });
|
|
27
|
+
return { id: config.project_id, name, path: repoRoot };
|
|
28
|
+
}
|