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.
Files changed (44) hide show
  1. package/AGENTS.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +160 -0
  4. package/bin/changeledger.mjs +375 -0
  5. package/package.json +72 -0
  6. package/src/atomic-write.mjs +134 -0
  7. package/src/change.mjs +102 -0
  8. package/src/check.mjs +536 -0
  9. package/src/commands/agent.mjs +256 -0
  10. package/src/commands/check.mjs +48 -0
  11. package/src/commands/graduate.mjs +105 -0
  12. package/src/commands/init.mjs +43 -0
  13. package/src/commands/new.mjs +164 -0
  14. package/src/commands/register.mjs +28 -0
  15. package/src/commands/release.mjs +138 -0
  16. package/src/commands/view.mjs +52 -0
  17. package/src/config.mjs +76 -0
  18. package/src/contract.mjs +100 -0
  19. package/src/git.mjs +73 -0
  20. package/src/lifecycle.mjs +57 -0
  21. package/src/metrics.mjs +143 -0
  22. package/src/paths.mjs +12 -0
  23. package/src/registry.mjs +55 -0
  24. package/src/release.mjs +71 -0
  25. package/src/repo.mjs +122 -0
  26. package/src/slug.mjs +12 -0
  27. package/src/spec.mjs +12 -0
  28. package/src/viewer/domain.mjs +133 -0
  29. package/src/viewer/public/api.js +25 -0
  30. package/src/viewer/public/app-state.js +87 -0
  31. package/src/viewer/public/app.js +717 -0
  32. package/src/viewer/public/index.html +64 -0
  33. package/src/viewer/public/security.js +65 -0
  34. package/src/viewer/public/state.js +31 -0
  35. package/src/viewer/public/styles.css +1062 -0
  36. package/src/viewer/public/templates.js +9 -0
  37. package/src/viewer/public/view-parts.js +162 -0
  38. package/src/viewer/public/view-renderers.js +191 -0
  39. package/src/viewer/server/router.mjs +200 -0
  40. package/src/viewer/server/security.mjs +27 -0
  41. package/src/writer.mjs +151 -0
  42. package/src/yaml.mjs +75 -0
  43. package/templates/AGENTS.md +540 -0
  44. package/templates/config.yml +55 -0
@@ -0,0 +1,134 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ let counter = 0;
5
+ // Timeout-based staleness: a lock is stale if it's held for longer than
6
+ // DEFAULT_LOCK_WAIT_MS. Simpler and portable, appropriate for file-mutation
7
+ // exclusion where contention is short-lived. Contrast with acquireIdLock in
8
+ // new.mjs, which uses PID liveness — better for id-collision prevention across
9
+ // processes where a lock might outlive a slow but still-running agent.
10
+ const DEFAULT_LOCK_WAIT_MS = 5_000;
11
+ const DEFAULT_LOCK_RETRY_MS = 10;
12
+
13
+ export function writeFileAtomic(file, data, options = {}) {
14
+ const { encoding = 'utf8', fsImpl = fs } =
15
+ typeof options === 'string' ? { encoding: options } : options;
16
+ const dir = path.dirname(file);
17
+ const tmp = path.join(
18
+ dir,
19
+ `.${path.basename(file)}.${process.pid}.${Date.now()}.${counter++}.tmp`,
20
+ );
21
+ let fd = null;
22
+
23
+ try {
24
+ fd = fsImpl.openSync(tmp, 'wx');
25
+ fsImpl.writeFileSync(fd, data, { encoding });
26
+ fsImpl.fsyncSync(fd);
27
+ fsImpl.closeSync(fd);
28
+ fd = null;
29
+ fsImpl.renameSync(tmp, file);
30
+ fsyncDir(dir, fsImpl);
31
+ } catch (e) {
32
+ if (fd !== null) {
33
+ try {
34
+ fsImpl.closeSync(fd);
35
+ } catch {
36
+ // best effort cleanup below
37
+ }
38
+ }
39
+ try {
40
+ fsImpl.rmSync(tmp, { force: true });
41
+ } catch {
42
+ // preserve the original error
43
+ }
44
+ throw e;
45
+ }
46
+ }
47
+
48
+ export function mutateFileAtomic(file, mutate, options = {}) {
49
+ const { encoding = 'utf8', fsImpl = fs } = options;
50
+ return withFileLock(
51
+ file,
52
+ () => {
53
+ const before = fsImpl.readFileSync(file, encoding);
54
+ const after = mutate(before);
55
+ if (after === undefined) return undefined;
56
+ writeFileAtomic(file, after, { encoding, fsImpl });
57
+ return after;
58
+ },
59
+ options,
60
+ );
61
+ }
62
+
63
+ export function withFileLock(file, fn, options = {}) {
64
+ const { fsImpl = fs, waitMs = DEFAULT_LOCK_WAIT_MS, retryMs = DEFAULT_LOCK_RETRY_MS } = options;
65
+ const lock = lockPath(file);
66
+ const start = Date.now();
67
+ let fd = null;
68
+
69
+ while (fd === null) {
70
+ try {
71
+ fd = fsImpl.openSync(lock, 'wx');
72
+ try {
73
+ fsImpl.writeFileSync(
74
+ fd,
75
+ JSON.stringify({ pid: process.pid, created: new Date().toISOString() }),
76
+ );
77
+ } catch (e) {
78
+ cleanupLock(lock, fd, fsImpl);
79
+ fd = null;
80
+ throw e;
81
+ }
82
+ } catch (e) {
83
+ if (e.code !== 'EEXIST') throw e;
84
+ if (Date.now() - start > waitMs) {
85
+ throw new Error(`timed out waiting for lock ${lock}`);
86
+ }
87
+ sleepSync(retryMs);
88
+ }
89
+ }
90
+
91
+ try {
92
+ return fn();
93
+ } finally {
94
+ cleanupLock(lock, fd, fsImpl);
95
+ }
96
+ }
97
+
98
+ function lockPath(file) {
99
+ return path.join(path.dirname(file), `.${path.basename(file)}.lock`);
100
+ }
101
+
102
+ function cleanupLock(lock, fd, fsImpl) {
103
+ try {
104
+ fsImpl.closeSync(fd);
105
+ } finally {
106
+ try {
107
+ fsImpl.rmSync(lock, { force: true });
108
+ } catch {
109
+ // preserve the original result/error
110
+ }
111
+ }
112
+ }
113
+
114
+ function sleepSync(ms) {
115
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
116
+ }
117
+
118
+ function fsyncDir(dir, fsImpl) {
119
+ let fd = null;
120
+ try {
121
+ fd = fsImpl.openSync(dir, 'r');
122
+ fsImpl.fsyncSync(fd);
123
+ } catch {
124
+ // Some platforms/filesystems do not support directory fsync.
125
+ } finally {
126
+ if (fd !== null) {
127
+ try {
128
+ fsImpl.closeSync(fd);
129
+ } catch {
130
+ // best effort
131
+ }
132
+ }
133
+ }
134
+ }
package/src/change.mjs ADDED
@@ -0,0 +1,102 @@
1
+ // Parses a ChangeLedger change file: frontmatter + stages + tasks.
2
+ // Stage bodies are kept raw (markdown) — the viewer renders them.
3
+
4
+ import { parseYaml } from './yaml.mjs';
5
+
6
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?/;
7
+ const TASK = /^- \[( |x|!)\]\s+(.*)$/;
8
+ const STATE_BY_MARK = { ' ': 'todo', x: 'done', '!': 'blocked' };
9
+
10
+ export function parseChange(text) {
11
+ const fm = text.match(FRONTMATTER);
12
+ if (!fm) throw new Error('Change is missing its frontmatter block');
13
+ const frontmatter = parseYaml(fm[1]);
14
+ const body = text.slice(fm[0].length);
15
+
16
+ const stages = splitStages(body);
17
+ const plan = stages.find((s) => s.key === 'plan');
18
+ const tasks = plan ? parseTasks(plan.body) : [];
19
+ const spec = stages.find((s) => s.key === 'specification');
20
+ const criterionBlocks = spec ? parseCriteria(spec.body) : [];
21
+ const criteria = criterionBlocks.map((c) => c.id);
22
+ const progress = {
23
+ total: tasks.length,
24
+ done: tasks.filter((t) => t.state === 'done').length,
25
+ blocked: tasks.filter((t) => t.state === 'blocked').length,
26
+ };
27
+
28
+ return { frontmatter, stages, tasks, criteria, criterionBlocks, progress };
29
+ }
30
+
31
+ // Acceptance criteria declared in `## Specification` as `### CRn — name` blocks.
32
+ function parseCriteria(specBody) {
33
+ const blocks = [];
34
+ let current = null;
35
+ for (const line of specBody.split('\n')) {
36
+ const m = line.match(/^###\s+(CR\d+)\b/);
37
+ if (m) {
38
+ current = { id: m[1], steps: [] };
39
+ blocks.push(current);
40
+ continue;
41
+ }
42
+ const step = line.match(/^-\s+\*\*(Given|When|Then|And)\*\*/);
43
+ if (current && step) current.steps.push(step[1]);
44
+ }
45
+ return blocks;
46
+ }
47
+
48
+ function splitStages(body) {
49
+ const stages = [];
50
+ let current = null;
51
+ let fence = null;
52
+ for (const line of body.split('\n')) {
53
+ const fenceMark = line.match(/^(`{3,}|~{3,})/);
54
+ if (fenceMark) {
55
+ if (!fence) fence = { char: fenceMark[1][0], length: fenceMark[1].length };
56
+ else if (fenceMark[1][0] === fence.char && fenceMark[1].length >= fence.length) fence = null;
57
+ if (current) current.body += `${line}\n`;
58
+ continue;
59
+ }
60
+
61
+ const m = fence ? null : line.match(/^##\s+(.+?)\s*$/);
62
+ if (m) {
63
+ current = { key: m[1].trim().toLowerCase(), heading: m[1].trim(), body: '' };
64
+ stages.push(current);
65
+ } else if (current) {
66
+ current.body += `${line}\n`;
67
+ }
68
+ }
69
+ for (const s of stages) s.body = s.body.trim();
70
+ return stages;
71
+ }
72
+
73
+ function parseTasks(planBody) {
74
+ const tasks = [];
75
+ for (const line of planBody.split('\n')) {
76
+ const m = line.trim().match(TASK);
77
+ if (!m) continue;
78
+ const state = STATE_BY_MARK[m[1]];
79
+ let rest = m[2].trim();
80
+ let resolvedAt;
81
+ let reason;
82
+ let suffix;
83
+
84
+ const dash = rest.lastIndexOf(' — ');
85
+ if (dash !== -1) {
86
+ suffix = rest.slice(dash + 3).trim();
87
+ rest = rest.slice(0, dash).trim();
88
+ if (state === 'done') resolvedAt = suffix;
89
+ else if (state === 'blocked') reason = suffix;
90
+ }
91
+
92
+ let criteria = [];
93
+ const crMatch = rest.match(/\(([^)]*\bCR\d+[^)]*)\)\s*$/);
94
+ if (crMatch) {
95
+ criteria = crMatch[1].match(/CR\d+/g) ?? [];
96
+ rest = rest.slice(0, crMatch.index).trim();
97
+ }
98
+
99
+ tasks.push({ text: rest, state, criteria, resolvedAt, reason, suffix });
100
+ }
101
+ return tasks;
102
+ }