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,138 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { withFileLock, writeFileAtomic } from '../atomic-write.mjs';
4
+ import { nowUtc } from '../paths.mjs';
5
+ import {
6
+ bumpVersion,
7
+ compareVersions,
8
+ parseVersion,
9
+ RELEASE_IMPACTS,
10
+ resolveReleasesDir,
11
+ } from '../release.mjs';
12
+ import { loadRepo } from '../repo.mjs';
13
+ import { stringifyYaml } from '../yaml.mjs';
14
+
15
+ const IMPACT_RANK = new Map(RELEASE_IMPACTS.map((impact, index) => [impact, index]));
16
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
17
+
18
+ export function releasePlan(cwd = process.cwd()) {
19
+ const repo = loadRepo(cwd);
20
+ if (!repo.releases.length) {
21
+ throw new Error(
22
+ 'Release history is not initialized. Run `changeledger release init <version>`.',
23
+ );
24
+ }
25
+
26
+ const currentVersion = latestVersion(repo.releases);
27
+ const releasedIds = new Set(
28
+ repo.releases.flatMap((release) => release.changes ?? []).map(String),
29
+ );
30
+ const changes = repo.changes
31
+ .filter((change) => change.frontmatter.status === 'done')
32
+ .filter((change) => !releasedIds.has(String(change.frontmatter.id)))
33
+ .map((change) => ({
34
+ id: String(change.frontmatter.id),
35
+ title: change.frontmatter.title,
36
+ type: change.frontmatter.type,
37
+ releaseImpact: effectiveImpact(change, repo.config),
38
+ }));
39
+
40
+ const impact = changes.reduce(
41
+ (highest, change) =>
42
+ IMPACT_RANK.get(change.releaseImpact) > IMPACT_RANK.get(highest)
43
+ ? change.releaseImpact
44
+ : highest,
45
+ 'none',
46
+ );
47
+
48
+ return {
49
+ currentVersion,
50
+ nextVersion: impact === 'none' ? null : bumpVersion(currentVersion, impact),
51
+ impact,
52
+ releasable: impact !== 'none',
53
+ changes,
54
+ };
55
+ }
56
+
57
+ export function initReleaseHistory(version, cwd = process.cwd(), now = nowUtc()) {
58
+ parseVersion(version);
59
+ assertTimestamp(now);
60
+ const initial = loadRepo(cwd);
61
+ const releasesDir = resolveReleasesDir(initial.repoRoot);
62
+ fs.mkdirSync(releasesDir, { recursive: true });
63
+ const historyLock = path.join(releasesDir, '.history');
64
+
65
+ return withFileLock(historyLock, () => {
66
+ const repo = loadRepo(cwd);
67
+ if (repo.releases.length) throw new Error('Release history is already initialized.');
68
+ const manifest = {
69
+ version,
70
+ created: now,
71
+ baseline: true,
72
+ changes: repo.changes
73
+ .filter((change) => change.frontmatter.status === 'done')
74
+ .map((change) => String(change.frontmatter.id)),
75
+ };
76
+ const file = path.join(releasesDir, `${version}.yml`);
77
+ writeNewManifest(file, manifest);
78
+ return { file, manifest };
79
+ });
80
+ }
81
+
82
+ export function recordRelease(version, cwd = process.cwd(), now = nowUtc()) {
83
+ parseVersion(version);
84
+ assertTimestamp(now);
85
+ const initial = loadRepo(cwd);
86
+ const releasesDir = resolveReleasesDir(initial.repoRoot);
87
+ const historyLock = path.join(releasesDir, '.history');
88
+
89
+ return withFileLock(historyLock, () => {
90
+ const plan = releasePlan(cwd);
91
+ if (!plan.releasable) throw new Error('No releasable changes (highest impact is none).');
92
+ if (version !== plan.nextVersion) {
93
+ throw new Error(`Version "${version}" does not match the calculated ${plan.nextVersion}.`);
94
+ }
95
+ const manifest = {
96
+ version,
97
+ created: now,
98
+ changes: plan.changes.map((change) => change.id),
99
+ };
100
+ const file = path.join(releasesDir, `${version}.yml`);
101
+ writeNewManifest(file, manifest);
102
+ return { file, manifest, plan };
103
+ });
104
+ }
105
+
106
+ function latestVersion(releases) {
107
+ return [...releases]
108
+ .map((release) => release.version)
109
+ .sort(compareVersions)
110
+ .at(-1);
111
+ }
112
+
113
+ function effectiveImpact(change, config) {
114
+ const override = change.frontmatter.release_impact;
115
+ if (override !== undefined) {
116
+ if (!RELEASE_IMPACTS.includes(override)) {
117
+ throw new Error(`Change #${change.frontmatter.id} has invalid release_impact "${override}".`);
118
+ }
119
+ return override;
120
+ }
121
+ const impact = config.release?.impacts?.[change.frontmatter.type];
122
+ if (!RELEASE_IMPACTS.includes(impact)) {
123
+ throw new Error(
124
+ `Change #${change.frontmatter.id} type "${change.frontmatter.type}" has no valid release impact; configure release.impacts.${change.frontmatter.type} or set release_impact.`,
125
+ );
126
+ }
127
+ return impact;
128
+ }
129
+
130
+ function writeNewManifest(file, manifest) {
131
+ if (fs.existsSync(file))
132
+ throw new Error(`Release manifest already exists: ${path.basename(file)}`);
133
+ writeFileAtomic(file, stringifyYaml(manifest));
134
+ }
135
+
136
+ function assertTimestamp(value) {
137
+ if (!ISO_UTC.test(value)) throw new Error(`Invalid release timestamp "${value}"`);
138
+ }
@@ -0,0 +1,52 @@
1
+ import { spawn } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import http from 'node:http';
4
+ import { resolveProjects } from '../viewer/domain.mjs';
5
+ import { createRequestListener, staticFile } from '../viewer/server/router.mjs';
6
+ import { hostnameOf, isAuthorizedWrite, isLocalHost } from '../viewer/server/security.mjs';
7
+
8
+ export { changeStatus, resolveProjects, searchProjects, serialize } from '../viewer/domain.mjs';
9
+ export { createRequestListener, hostnameOf, isAuthorizedWrite, isLocalHost, staticFile };
10
+
11
+ export async function view(args = [], cwd = process.cwd()) {
12
+ const localOnly = args.includes('.');
13
+ resolveProjects(cwd, localOnly); // fail fast if local mode outside a repo
14
+
15
+ const token = crypto.randomBytes(16).toString('hex');
16
+ const server = http.createServer(createRequestListener(cwd, localOnly, token));
17
+ server.requestTimeout = 30_000;
18
+ server.headersTimeout = 10_000;
19
+
20
+ const host = '127.0.0.1';
21
+ const port = await listen(server, host, Number(args.find((a) => /^\d+$/.test(a))) || 4040);
22
+ const url = `http://${host}:${port}`;
23
+ console.log(`ChangeLedger viewer → ${url} (Ctrl+C to stop)`);
24
+ openBrowser(url);
25
+ }
26
+
27
+ function listen(server, host, port, attempts = 10) {
28
+ return new Promise((resolve, reject) => {
29
+ const tryPort = (p, left) => {
30
+ server.once('error', (e) => {
31
+ if (e.code === 'EADDRINUSE' && left > 0) tryPort(p + 1, left - 1);
32
+ else reject(e);
33
+ });
34
+ server.listen(p, host, () => resolve(p));
35
+ };
36
+ tryPort(port, attempts);
37
+ });
38
+ }
39
+
40
+ function openBrowser(url) {
41
+ const cmd =
42
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
43
+ try {
44
+ spawn(cmd, [url], {
45
+ stdio: 'ignore',
46
+ detached: true,
47
+ shell: process.platform === 'win32',
48
+ }).unref();
49
+ } catch {
50
+ // best-effort; the URL is printed above
51
+ }
52
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseYaml } from './yaml.mjs';
4
+
5
+ // Walk up from `start` looking for a `.changeledger/` directory. Returns its absolute path
6
+ // or null if none is found.
7
+ export function findChangeledgerDir(start = process.cwd()) {
8
+ let dir = path.resolve(start);
9
+ for (;;) {
10
+ const candidate = path.join(dir, '.changeledger');
11
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
12
+ const parent = path.dirname(dir);
13
+ if (parent === dir) return null;
14
+ dir = parent;
15
+ }
16
+ }
17
+
18
+ export function loadConfig(changeledgerDir) {
19
+ const file = path.join(changeledgerDir, 'config.yml');
20
+ if (!fs.existsSync(file)) throw new Error(`Missing config: ${file}`);
21
+ return parseYaml(fs.readFileSync(file, 'utf8'));
22
+ }
23
+
24
+ // Resolves a configured directory (changes_dir/specs_dir) against the repo root,
25
+ // refusing any value that escapes it. A cloned repo's config is untrusted input:
26
+ // running a command must not let it read or write outside the repo it discovered.
27
+ // Absolute paths and `..` traversal are rejected by shape; symlinks are rejected
28
+ // by comparing real paths. The target may not exist yet (a command is about to
29
+ // create it), so we realpath the nearest existing ancestor: an intermediate
30
+ // symlink leading outside is caught before any mkdir lands in the external
31
+ // target. Returns the absolute, contained path.
32
+ export function resolveRepoPath(repoRoot, configured, field) {
33
+ if (typeof configured !== 'string' || configured === '') {
34
+ throw new Error(`config "${field}" must be a non-empty relative path`);
35
+ }
36
+ if (path.isAbsolute(configured)) {
37
+ throw new Error(`config "${field}" must be relative to the repo root: ${configured}`);
38
+ }
39
+ const root = path.resolve(repoRoot);
40
+ const resolved = path.resolve(root, configured);
41
+ if (!isInside(root, resolved)) {
42
+ throw new Error(`config "${field}" escapes the repo root: ${configured}`);
43
+ }
44
+ const realRoot = fs.realpathSync(root);
45
+ const realAncestor = fs.realpathSync(nearestExisting(resolved));
46
+ if (!isInside(realRoot, realAncestor)) {
47
+ throw new Error(`config "${field}" resolves outside the repo via a symlink: ${configured}`);
48
+ }
49
+ return resolved;
50
+ }
51
+
52
+ // Nearest path component of `p` that exists on disk. The gap between it and `p`
53
+ // is non-existent (so it cannot hide a symlink); the ancestor is what we realpath
54
+ // to detect an intermediate symlink escaping the repo.
55
+ function nearestExisting(p) {
56
+ let cur = p;
57
+ while (!fs.existsSync(cur)) {
58
+ const parent = path.dirname(cur);
59
+ if (parent === cur) break;
60
+ cur = parent;
61
+ }
62
+ return cur;
63
+ }
64
+
65
+ function isInside(root, target) {
66
+ return target === root || target.startsWith(root + path.sep);
67
+ }
68
+
69
+ // Single source of the specs directory: the configured `specs_dir` or the
70
+ // default, always resolved through the containment guard. Shared by `loadRepo`
71
+ // and `graduate` so a graduated spec lands where the repo will later read it.
72
+ export const DEFAULT_SPECS_DIR = '.changeledger/specs';
73
+
74
+ export function resolveSpecsDir(repoRoot, config) {
75
+ return resolveRepoPath(repoRoot, config.specs_dir ?? DEFAULT_SPECS_DIR, 'specs_dir');
76
+ }
@@ -0,0 +1,100 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { writeFileAtomic } from './atomic-write.mjs';
4
+ import { agentsTemplate } from './paths.mjs';
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
+ const CONTRACT_FILES = ['AGENTS.md', 'CLAUDE.md'];
15
+
16
+ const MARKER = '<!-- changeledger -->';
17
+ const REFERENCE = `${MARKER}
18
+ > [!IMPORTANT]
19
+ > This repo uses **ChangeLedger**. Read and follow \`.changeledger/AGENTS.md\` (the change
20
+ > contract). If it is missing, run \`changeledger register\`.
21
+ `;
22
+
23
+ export const contractLink = (changeledgerDir) => path.join(changeledgerDir, 'AGENTS.md');
24
+ export const rootContract = (repoRoot) => path.join(repoRoot, 'AGENTS.md');
25
+
26
+ // A real, non-symlink file we may safely append to.
27
+ function isPlainFile(file) {
28
+ try {
29
+ return fs.lstatSync(file).isFile();
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
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;
54
+ }
55
+
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).
58
+ export function ensureReference(repoRoot) {
59
+ const touched = [];
60
+ for (const name of CONTRACT_FILES) {
61
+ const file = path.join(repoRoot, name);
62
+ if (!isPlainFile(file)) continue;
63
+ const text = fs.readFileSync(file, 'utf8');
64
+ if (text.includes(MARKER)) continue;
65
+ writeFileAtomic(file, `${text}${text.endsWith('\n') ? '' : '\n'}\n${REFERENCE}`);
66
+ touched.push(name);
67
+ }
68
+ return touched;
69
+ }
70
+
71
+ // Ensure `.changeledger/AGENTS.md` is gitignored (it is a per-machine artifact).
72
+ export function ensureGitignore(repoRoot) {
73
+ 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`);
78
+ return true;
79
+ }
80
+
81
+ // IO-level discovery validation (repo-wide). Returns a list of error messages.
82
+ export function checkContract(repoRoot, changeledgerDir) {
83
+ const errors = [];
84
+ const root = rootContract(repoRoot);
85
+ if (!fs.existsSync(root)) {
86
+ errors.push('missing AGENTS.md at the repo root (ChangeLedger contract reference lives here)');
87
+ }
88
+ // Every present, non-symlink contract file must carry the reference.
89
+ for (const name of CONTRACT_FILES) {
90
+ const file = path.join(repoRoot, name);
91
+ if (!isPlainFile(file)) continue;
92
+ if (!fs.readFileSync(file, 'utf8').includes(MARKER)) {
93
+ errors.push(`${name} has no ChangeLedger reference — run \`changeledger register\``);
94
+ }
95
+ }
96
+ if (!fs.existsSync(contractLink(changeledgerDir))) {
97
+ errors.push('`.changeledger/AGENTS.md` is missing or dangling — run `changeledger register`');
98
+ }
99
+ return errors;
100
+ }
package/src/git.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // Links a change to git via its `[#<id>]` commit-message convention (AGENTS.md
2
+ // §6.4). `run(args)` executes git and returns stdout; it is injectable so the
3
+ // logic is testable without a real repo. Any git failure yields empty refs.
4
+
5
+ import { execFileSync } from 'node:child_process';
6
+
7
+ const SEP = String.fromCharCode(31); // ASCII unit separator — safe field delimiter
8
+
9
+ function defaultRun(args, cwd) {
10
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
11
+ }
12
+
13
+ // Local git identity (`git config user.name`), or '' if unavailable. Tolerant.
14
+ export function gitUser(cwd, run = defaultRun) {
15
+ try {
16
+ return run(['config', 'user.name'], cwd).trim();
17
+ } catch {
18
+ return '';
19
+ }
20
+ }
21
+
22
+ function defaultGhRun(args) {
23
+ return execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
24
+ }
25
+
26
+ // GitHub username via `gh api user --jq .login`, or '' if gh is missing,
27
+ // unauthenticated, or offline. Tolerant by design.
28
+ export function githubLogin(run = defaultGhRun) {
29
+ try {
30
+ return run(['api', 'user', '--jq', '.login']).trim();
31
+ } catch {
32
+ return '';
33
+ }
34
+ }
35
+
36
+ // Preferred owner handle when work starts: the GitHub login, falling back to the
37
+ // local git user.name. Empty if neither is available.
38
+ export function ownerHandle(cwd, run = defaultRun, ghRun = defaultGhRun) {
39
+ return githubLogin(ghRun) || gitUser(cwd, run);
40
+ }
41
+
42
+ export function gitRefs(repoRoot, id, run = defaultRun) {
43
+ const refs = { commits: [], branches: [] };
44
+ if (!id) return refs;
45
+
46
+ try {
47
+ const out = run(
48
+ ['log', '--all', '-n', '100', '-F', `--grep=[#${id}]`, `--pretty=format:%H${SEP}%s${SEP}%cI`],
49
+ repoRoot,
50
+ );
51
+ refs.commits = out
52
+ .split('\n')
53
+ .filter(Boolean)
54
+ .map((line) => {
55
+ const [sha, subject, date] = line.split(SEP);
56
+ return { sha, subject, date };
57
+ });
58
+ } catch {
59
+ // not a git repo, or git unavailable — leave commits empty
60
+ }
61
+
62
+ try {
63
+ const out = run(['branch', '--all', '--format=%(refname:short)'], repoRoot);
64
+ refs.branches = out
65
+ .split('\n')
66
+ .map((s) => s.trim())
67
+ .filter((name) => name?.includes(id));
68
+ } catch {
69
+ // leave branches empty
70
+ }
71
+
72
+ return refs;
73
+ }
@@ -0,0 +1,57 @@
1
+ // The change lifecycle as an explicit, testable graph — the single authority on
2
+ // which status moves are legal. Shared by the CLI (`changeledger status`) and the viewer
3
+ // so both decide validity the same way. The viewer layers an extra human-only
4
+ // policy on top (approval plus final acceptance/rejection); it never relaxes
5
+ // this graph.
6
+
7
+ export const CANONICAL_STATUSES = [
8
+ 'draft',
9
+ 'approved',
10
+ 'in-progress',
11
+ 'in-review',
12
+ 'in-validation',
13
+ 'blocked',
14
+ 'done',
15
+ 'discarded',
16
+ ];
17
+
18
+ // from → set of allowed next states. Forward progress plus the blocked round
19
+ // trip and the review/validation gates; regressions, skips and self-loops are
20
+ // absent and therefore rejected. `in-review` is optional by type;
21
+ // `in-validation` is the universal human gate before done. Review or validation
22
+ // may route back to in-progress, while review may also block. `discarded` is a
23
+ // terminal tombstone reachable only before either closing gate.
24
+ const TRANSITIONS = {
25
+ draft: ['approved', 'discarded'],
26
+ approved: ['in-progress', 'discarded'],
27
+ 'in-progress': ['in-review', 'in-validation', 'blocked', 'discarded'],
28
+ 'in-review': ['in-validation', 'in-progress', 'blocked'],
29
+ 'in-validation': ['done', 'in-progress'],
30
+ blocked: ['in-progress', 'discarded'],
31
+ done: [],
32
+ discarded: [],
33
+ };
34
+
35
+ const canonical = new Set(CANONICAL_STATUSES);
36
+
37
+ export function canTransition(from, to) {
38
+ return (TRANSITIONS[from] ?? []).includes(to);
39
+ }
40
+
41
+ // Throws when a move is illegal. Enforced only between canonical statuses; a
42
+ // repo with custom statuses keeps the prior enum-only behavior, since this graph
43
+ // cannot reason about states it does not model.
44
+ //
45
+ // The review gate: a `review_required` type cannot skip from in-progress to
46
+ // human validation. All canonical changes must pass through `in-validation`
47
+ // before done. `opts.reviewRequired` comes from the change's type in config.yml.
48
+ export function assertTransition(from, to, { type, reviewRequired = false } = {}) {
49
+ if (!canonical.has(from) || !canonical.has(to)) return;
50
+ if (from === to) throw new Error(`change is already "${to}"`);
51
+ if (!canTransition(from, to)) {
52
+ throw new Error(`invalid lifecycle transition: ${from} → ${to}`);
53
+ }
54
+ if (reviewRequired && from === 'in-progress' && to === 'in-validation') {
55
+ throw new Error(`${type} changes must be reviewed before validation — move to in-review first`);
56
+ }
57
+ }
@@ -0,0 +1,143 @@
1
+ // Pure delivery metrics derived from change timestamps. No IO and no clock —
2
+ // time-relative metrics (aging, live WIP durations) take `now` as a parameter.
3
+ // Everything is reconstructed from `created` plus the `## Log` status
4
+ // transitions. The viewer renders the result.
5
+
6
+ const ACTIVE = ['approved', 'in-progress', 'in-review', 'in-validation', 'blocked'];
7
+
8
+ function logBody(change) {
9
+ return (change.stages ?? []).find((s) => s.key === 'log')?.body ?? '';
10
+ }
11
+
12
+ function logTransition(line) {
13
+ const m = line.match(/\*\*([^*]+)\*\*\s*—\s*(?:status:.*|review|validation)\s*→\s*([a-z-]+)\b/);
14
+ if (!m) return null;
15
+ return { at: m[1].trim(), state: m[2].trim() };
16
+ }
17
+
18
+ // The moment a change reached `done`: the last lifecycle transition to done, or
19
+ // null. Canonical done is written by a passed human validation.
20
+ export function doneAt(change) {
21
+ let at = null;
22
+ for (const line of logBody(change).split('\n')) {
23
+ const event = logTransition(line);
24
+ if (event?.state === 'done') at = event.at;
25
+ }
26
+ return at;
27
+ }
28
+
29
+ // Ordered status transitions parsed from the Log: [{ at, state }]. Assumes the
30
+ // change began in `draft` at `created`.
31
+ function transitions(change) {
32
+ const created = change.frontmatter?.created;
33
+ if (!created) return [];
34
+ const events = [{ at: created, state: 'draft' }];
35
+ for (const line of logBody(change).split('\n')) {
36
+ const event = logTransition(line);
37
+ if (event) events.push(event);
38
+ }
39
+ return events;
40
+ }
41
+
42
+ // Time spent in each state: [{ state, ms }]. The final state runs until `now`
43
+ // (or until it reached `done`).
44
+ export function statusTimeline(change, now) {
45
+ const events = transitions(change);
46
+ if (!events.length) return [];
47
+ const segs = [];
48
+ for (let i = 0; i < events.length; i++) {
49
+ const start = Date.parse(events[i].at);
50
+ const endIso = events[i + 1]?.at ?? now;
51
+ const end = Date.parse(endIso);
52
+ if (Number.isNaN(start) || Number.isNaN(end)) continue;
53
+ // `done` is terminal — there is no meaningful dwell time in it.
54
+ if (events[i].state === 'done') continue;
55
+ segs.push({ state: events[i].state, ms: Math.max(0, end - start) });
56
+ }
57
+ return segs;
58
+ }
59
+
60
+ function median(sorted) {
61
+ if (!sorted.length) return 0;
62
+ const mid = Math.floor(sorted.length / 2);
63
+ return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
64
+ }
65
+
66
+ function avg(nums) {
67
+ return nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length) : 0;
68
+ }
69
+
70
+ export function computeMetrics(changes = [], { now } = {}) {
71
+ const nowIso = now ?? '9999-12-31T23:59:59Z';
72
+
73
+ const perChange = [];
74
+ const byDate = new Map();
75
+ const statusTotals = new Map(); // state → { ms, count }
76
+ const wip = {};
77
+ const aging = [];
78
+ let blockedMs = 0;
79
+ const byType = new Map(); // type → { closed, cycles:[] }
80
+
81
+ for (const c of changes) {
82
+ const status = c.frontmatter?.status;
83
+ const type = c.frontmatter?.type ?? 'unknown';
84
+ const created = c.frontmatter?.created;
85
+
86
+ if (ACTIVE.includes(status)) wip[status] = (wip[status] ?? 0) + 1;
87
+
88
+ const segs = statusTimeline(c, nowIso);
89
+ for (const s of segs) {
90
+ const t = statusTotals.get(s.state) ?? { ms: 0, count: 0 };
91
+ t.ms += s.ms;
92
+ t.count += 1;
93
+ statusTotals.set(s.state, t);
94
+ if (s.state === 'blocked') blockedMs += s.ms;
95
+ }
96
+
97
+ if (status === 'in-progress') {
98
+ const events = transitions(c);
99
+ const entered = [...events].reverse().find((e) => e.state === 'in-progress');
100
+ if (entered)
101
+ aging.push({ id: c.frontmatter.id, ms: Date.parse(nowIso) - Date.parse(entered.at) });
102
+ }
103
+
104
+ if (status === 'done' && created) {
105
+ const closed = doneAt(c);
106
+ const cycleMs = closed ? Date.parse(closed) - Date.parse(created) : NaN;
107
+ if (!Number.isNaN(cycleMs)) {
108
+ perChange.push({ id: c.frontmatter.id, cycleMs });
109
+ byDate.set(closed.slice(0, 10), (byDate.get(closed.slice(0, 10)) ?? 0) + 1);
110
+ const bt = byType.get(type) ?? { closed: 0, cycles: [] };
111
+ bt.closed += 1;
112
+ bt.cycles.push(cycleMs);
113
+ byType.set(type, bt);
114
+ }
115
+ }
116
+ }
117
+
118
+ const cycles = perChange.map((p) => p.cycleMs).sort((a, b) => a - b);
119
+ const throughput = [...byDate.entries()]
120
+ .map(([date, count]) => ({ date, count }))
121
+ .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
122
+ const timeInStatus = [...statusTotals.entries()].map(([state, t]) => ({
123
+ state,
124
+ totalMs: t.ms,
125
+ avgMs: Math.round(t.ms / t.count),
126
+ }));
127
+ const byTypeArr = [...byType.entries()]
128
+ .map(([type, v]) => ({ type, closed: v.closed, avgCycleMs: avg(v.cycles) }))
129
+ .sort((a, b) => b.closed - a.closed);
130
+
131
+ return {
132
+ count: perChange.length,
133
+ avgCycleMs: avg(cycles),
134
+ medianCycleMs: median(cycles),
135
+ perChange,
136
+ throughput,
137
+ timeInStatus,
138
+ wip,
139
+ aging: aging.sort((a, b) => b.ms - a.ms),
140
+ blockedMs,
141
+ byType: byTypeArr,
142
+ };
143
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,12 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
5
+ export const templatesDir = path.join(packageRoot, 'templates');
6
+ export const agentsTemplate = path.join(templatesDir, 'AGENTS.md');
7
+ export const publicDir = path.join(packageRoot, 'src', 'viewer', 'public');
8
+
9
+ // ISO 8601 UTC at second precision, matching the change `created`/task convention.
10
+ export function nowUtc() {
11
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
12
+ }