changeledger 0.9.0 → 0.11.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/src/git.mjs CHANGED
@@ -5,9 +5,58 @@
5
5
  import { execFileSync } from 'node:child_process';
6
6
 
7
7
  const SEP = String.fromCharCode(31); // ASCII unit separator — safe field delimiter
8
+ const RECORD_SEP = String.fromCharCode(30);
8
9
 
9
- function defaultRun(args, cwd) {
10
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
10
+ // Repo-location env vars git itself exports while running a hook (e.g. this
11
+ // project's own pre-commit). Left inherited, a child `git` call would silently
12
+ // target the hook's repo/worktree instead of the given `cwd` — strip them so
13
+ // every invocation stays anchored on `cwd`.
14
+ const GIT_LOCATION_ENV_VARS = [
15
+ 'GIT_DIR',
16
+ 'GIT_WORK_TREE',
17
+ 'GIT_INDEX_FILE',
18
+ 'GIT_OBJECT_DIRECTORY',
19
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
20
+ 'GIT_COMMON_DIR',
21
+ 'GIT_CEILING_DIRECTORIES',
22
+ ];
23
+
24
+ function sanitizedEnv() {
25
+ const env = { ...process.env };
26
+ for (const key of GIT_LOCATION_ENV_VARS) delete env[key];
27
+ return env;
28
+ }
29
+
30
+ // Exported so other commands (e.g. `changeledger commit`) share the same
31
+ // GIT_* sanitization instead of re-implementing it.
32
+ export function defaultRun(args, cwd) {
33
+ return execFileSync('git', args, {
34
+ cwd,
35
+ env: sanitizedEnv(),
36
+ encoding: 'utf8',
37
+ stdio: ['ignore', 'pipe', 'ignore'],
38
+ });
39
+ }
40
+
41
+ // Run variant for mutating git commands (e.g. `commit`), where git's stderr is
42
+ // the only clue to a failure (failed hook, nothing staged, missing identity,
43
+ // lock). Pipes stderr and, on failure, throws an Error whose message includes
44
+ // the captured diagnostic. Query paths keep `defaultRun` and degrade silently.
45
+ export function mutatingRun(args, cwd) {
46
+ try {
47
+ return execFileSync('git', args, {
48
+ cwd,
49
+ env: sanitizedEnv(),
50
+ encoding: 'utf8',
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ });
53
+ } catch (e) {
54
+ const detail = [e.stderr, e.stdout]
55
+ .map((s) => (typeof s === 'string' ? s.trim() : ''))
56
+ .filter(Boolean)
57
+ .join('\n');
58
+ throw new Error(detail ? `${e.message}\n${detail}` : e.message, { cause: e });
59
+ }
11
60
  }
12
61
 
13
62
  // Local git identity (`git config user.name`), or '' if unavailable. Tolerant.
@@ -39,6 +88,92 @@ export function ownerHandle(cwd, run = defaultRun, ghRun = defaultGhRun) {
39
88
  return githubLogin(ghRun) || gitUser(cwd, run);
40
89
  }
41
90
 
91
+ // Detects the branch `changeledger check --commits` should diff against when
92
+ // no base is given: the remote's HEAD if configured, else a local `main` or
93
+ // `master`. Throws with actionable guidance if neither is resolvable.
94
+ export function defaultBaseBranch(repoRoot, run = defaultRun) {
95
+ try {
96
+ const out = run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repoRoot);
97
+ const name = out.trim().replace(/^origin\//, '');
98
+ if (name) return name;
99
+ } catch {
100
+ // no configured remote HEAD — fall through to local candidates
101
+ }
102
+ for (const candidate of ['main', 'master']) {
103
+ try {
104
+ run(['rev-parse', '--verify', '--quiet', candidate], repoRoot);
105
+ return candidate;
106
+ } catch {
107
+ // candidate branch does not exist locally — try the next one
108
+ }
109
+ }
110
+ throw new Error(
111
+ 'Could not detect a default branch (no origin/HEAD, main, or master); pass one explicitly: changeledger check --commits <base>',
112
+ );
113
+ }
114
+
115
+ // Commits in `range` (e.g. `main..HEAD`): sha, subject, body and whether each is
116
+ // a merge (more than one parent) — the git metadata `check --commits` lints.
117
+ export function commitsInRange(repoRoot, range, run = defaultRun) {
118
+ let out;
119
+ try {
120
+ out = run(['log', range, `--pretty=format:%H${SEP}%P${SEP}%s${SEP}%b${RECORD_SEP}`], repoRoot);
121
+ } catch (e) {
122
+ throw new Error(`git log failed for range "${range}": ${e.message}`);
123
+ }
124
+ return out
125
+ .split(RECORD_SEP)
126
+ .map((record) => record.trim())
127
+ .filter(Boolean)
128
+ .map((record) => {
129
+ const [sha, parents, subject, body = ''] = record.split(SEP);
130
+ return {
131
+ sha,
132
+ subject,
133
+ body: body.trim(),
134
+ isMerge: parents.trim().split(/\s+/).filter(Boolean).length > 1,
135
+ };
136
+ });
137
+ }
138
+
139
+ const MARKER_RE = /\[#[^\]\s]+\]$/;
140
+ const ANY_MARKER_RE = /\[#[^\]\s]+\]/g;
141
+ const MULTI_BODY_RE = /^ChangeLedger: (\[#[^\]\s]+\])( \[#[^\]\s]+\])+$/;
142
+
143
+ export function hasCommitMarker(subject) {
144
+ return MARKER_RE.test(subject.trim());
145
+ }
146
+
147
+ function commitMarkerViolation({ subject, body }) {
148
+ const subjectMarkers = subject.match(ANY_MARKER_RE) ?? [];
149
+ const trimmedBody = body.trim();
150
+ const hasBodyLabel = trimmedBody.includes('ChangeLedger:');
151
+ const validMultiBody = MULTI_BODY_RE.test(trimmedBody);
152
+
153
+ if (subjectMarkers.length > 1) return 'multiple [#id] markers must be in the body';
154
+ if (hasBodyLabel && !validMultiBody) return 'malformed ChangeLedger body';
155
+ if (subjectMarkers.length === 1 && hasCommitMarker(subject) && !hasBodyLabel) return null;
156
+ if (subjectMarkers.length === 0 && validMultiBody) return null;
157
+ if (subjectMarkers.length === 1 && validMultiBody)
158
+ return 'ambiguous [#id] markers in both subject and body';
159
+ return 'missing [#id] marker';
160
+ }
161
+
162
+ // Lints `range`: every non-merge commit must carry a well-formed `[#id]`
163
+ // marker, except `chore(release)` prep commits. Returns only the violations
164
+ // (sha + subject); never throws for a clean range.
165
+ export function lintCommitRange(repoRoot, range, run = defaultRun) {
166
+ const commits = commitsInRange(repoRoot, range, run);
167
+ const violations = [];
168
+ for (const c of commits) {
169
+ if (c.isMerge) continue;
170
+ if (/^chore\(release\):/.test(c.subject)) continue;
171
+ const reason = commitMarkerViolation(c);
172
+ if (reason) violations.push({ sha: c.sha.slice(0, 7), subject: c.subject, reason });
173
+ }
174
+ return violations;
175
+ }
176
+
42
177
  export function gitRefs(repoRoot, id, run = defaultRun) {
43
178
  const refs = { commits: [], branches: [] };
44
179
  if (!id) return refs;
package/src/metrics.mjs CHANGED
@@ -70,6 +70,29 @@ function avg(nums) {
70
70
  return nums.length ? Math.round(nums.reduce((a, b) => a + b, 0) / nums.length) : 0;
71
71
  }
72
72
 
73
+ // Nearest-rank percentile over an ascending-sorted array; 0 for an empty input
74
+ // so callers never divide by zero or render NaN.
75
+ function percentile(sorted, p) {
76
+ if (!sorted.length) return 0;
77
+ const rank = Math.min(sorted.length, Math.max(1, Math.ceil((p / 100) * sorted.length)));
78
+ return sorted[rank - 1];
79
+ }
80
+
81
+ // Count of `review → in-progress` verdicts (the `fail --retry` outcome) — the
82
+ // same event grammar as lifecycle transitions, filtered to the implicit
83
+ // review-verdict shape so an explicit `status:` move or a validation verdict
84
+ // isn't miscounted as a retry.
85
+ function reviewRetryCount(change) {
86
+ let count = 0;
87
+ for (const line of logBody(change).split('\n')) {
88
+ const event = parseLogEvent(line);
89
+ if (event && !event.explicit && event.from === 'in-review' && event.to === 'in-progress') {
90
+ count++;
91
+ }
92
+ }
93
+ return count;
94
+ }
95
+
73
96
  export function computeMetrics(changes = [], { now } = {}) {
74
97
  const nowIso = now ?? '9999-12-31T23:59:59Z';
75
98
 
@@ -80,12 +103,17 @@ export function computeMetrics(changes = [], { now } = {}) {
80
103
  const aging = [];
81
104
  let blockedMs = 0;
82
105
  const byType = new Map(); // type → { closed, cycles:[] }
106
+ const byOwner = new Map(); // owner → { closed, cycles:[] }
107
+ let reviewRetries = 0;
83
108
 
84
109
  for (const c of changes) {
85
110
  const status = c.frontmatter?.status;
86
111
  const type = c.frontmatter?.type ?? 'unknown';
112
+ const owner = c.frontmatter?.owner || 'unassigned';
87
113
  const created = c.frontmatter?.created;
88
114
 
115
+ reviewRetries += reviewRetryCount(c);
116
+
89
117
  if (ACTIVE.includes(status)) wip[status] = (wip[status] ?? 0) + 1;
90
118
 
91
119
  const segs = statusTimeline(c, nowIso);
@@ -114,6 +142,11 @@ export function computeMetrics(changes = [], { now } = {}) {
114
142
  bt.closed += 1;
115
143
  bt.cycles.push(cycleMs);
116
144
  byType.set(type, bt);
145
+
146
+ const bo = byOwner.get(owner) ?? { closed: 0, cycles: [] };
147
+ bo.closed += 1;
148
+ bo.cycles.push(cycleMs);
149
+ byOwner.set(owner, bo);
117
150
  }
118
151
  }
119
152
  }
@@ -130,17 +163,26 @@ export function computeMetrics(changes = [], { now } = {}) {
130
163
  const byTypeArr = [...byType.entries()]
131
164
  .map(([type, v]) => ({ type, closed: v.closed, avgCycleMs: avg(v.cycles) }))
132
165
  .sort((a, b) => b.closed - a.closed);
166
+ const byOwnerArr = [...byOwner.entries()]
167
+ .map(([owner, v]) => ({ owner, closed: v.closed, avgCycleMs: avg(v.cycles) }))
168
+ .sort((a, b) => b.closed - a.closed);
169
+ const validationWaitMs = timeInStatus.find((t) => t.state === 'in-validation')?.avgMs ?? 0;
133
170
 
134
171
  return {
135
172
  count: perChange.length,
136
173
  avgCycleMs: avg(cycles),
137
174
  medianCycleMs: median(cycles),
175
+ p50CycleMs: percentile(cycles, 50),
176
+ p85CycleMs: percentile(cycles, 85),
138
177
  perChange,
139
178
  throughput,
140
179
  timeInStatus,
141
180
  wip,
142
181
  aging: aging.sort((a, b) => b.ms - a.ms),
143
182
  blockedMs,
183
+ validationWaitMs,
184
+ reviewRetries,
144
185
  byType: byTypeArr,
186
+ byOwner: byOwnerArr,
145
187
  };
146
188
  }
package/src/search.mjs ADDED
@@ -0,0 +1,162 @@
1
+ // Deterministic lexical search over changes and specs. No embeddings, no
2
+ // network — a fixed-boost term-frequency score (title x3, headings/CR x2,
3
+ // body x1) with a stable tie-break, so repeated runs are byte-for-byte
4
+ // identical. See change 20260711-103758.
5
+
6
+ const ACCENTS = /[̀-ͯ]/g;
7
+ const CR_HEADING = /^###\s+CR\d+.*$/gm;
8
+ const MARKDOWN_HEADING = /^#{1,6}\s+(.+)$/gm;
9
+
10
+ // Lowercase + strip diacritics so "búsqueda" and "busqueda" match the same term.
11
+ export function normalize(text) {
12
+ return (text ?? '').normalize('NFD').replace(ACCENTS, '').toLowerCase();
13
+ }
14
+
15
+ function tokenize(query) {
16
+ return normalize(query)
17
+ .split(/\s+/)
18
+ .map((t) => t.trim())
19
+ .filter(Boolean);
20
+ }
21
+
22
+ function countOccurrences(haystackNormalized, term) {
23
+ if (!term) return 0;
24
+ let count = 0;
25
+ let idx = 0;
26
+ for (;;) {
27
+ const found = haystackNormalized.indexOf(term, idx);
28
+ if (found === -1) break;
29
+ count++;
30
+ idx = found + term.length;
31
+ }
32
+ return count;
33
+ }
34
+
35
+ // Locates the first case/accent-insensitive occurrence of `term` in `rawText`
36
+ // and returns a short surrounding snippet, or null when there is no match.
37
+ function findSnippet(rawText, term, span = 40) {
38
+ const normalized = normalize(rawText);
39
+ const idx = normalized.indexOf(term);
40
+ if (idx === -1) return null;
41
+ const start = Math.max(0, idx - span);
42
+ const end = Math.min(rawText.length, idx + term.length + span);
43
+ const prefix = start > 0 ? '…' : '';
44
+ const suffix = end < rawText.length ? '…' : '';
45
+ return `${prefix}${rawText.slice(start, end).replace(/\s+/g, ' ').trim()}${suffix}`;
46
+ }
47
+
48
+ // Splits a change's stages into a heading/CR bucket (boost x2) and a body
49
+ // bucket (boost x1). CR heading lines are lifted out of the body so they are
50
+ // scored once, at the heading boost, not twice.
51
+ function changeHeadingsAndBody(stages) {
52
+ const headings = [];
53
+ const bodyParts = [];
54
+ for (const stage of stages) {
55
+ headings.push(stage.heading);
56
+ const crHeadings = stage.body.match(CR_HEADING) ?? [];
57
+ headings.push(...crHeadings);
58
+ bodyParts.push(stage.body.replace(CR_HEADING, ''));
59
+ }
60
+ return { headings: headings.join('\n'), body: bodyParts.join('\n\n') };
61
+ }
62
+
63
+ // Same split for a spec's free-form body: its own `#`..`######` headings
64
+ // (boost x2) versus the remaining prose (boost x1).
65
+ function specHeadingsAndBody(body) {
66
+ const headings = [...body.matchAll(MARKDOWN_HEADING)].map((m) => m[1]);
67
+ const stripped = body.replace(MARKDOWN_HEADING, '');
68
+ return { headings: headings.join('\n'), body: stripped };
69
+ }
70
+
71
+ // Builds the searchable corpus from a loaded repo (see repo.mjs `loadRepo`):
72
+ // every change — including archived ones — and every spec, reduced to the
73
+ // fields the scorer needs.
74
+ export function buildCorpus({ changes, specs }) {
75
+ const fromChanges = changes.map((c) => {
76
+ const { headings, body } = changeHeadingsAndBody(c.stages);
77
+ return {
78
+ ref: `#${c.frontmatter.id}`,
79
+ kind: 'change',
80
+ title: c.frontmatter.title ?? '',
81
+ status: c.frontmatter.status ?? null,
82
+ type: c.frontmatter.type ?? null,
83
+ headings,
84
+ body,
85
+ };
86
+ });
87
+
88
+ const fromSpecs = specs.map((s) => {
89
+ const slug = s.name.replace(/\.md$/, '');
90
+ const { headings, body } = specHeadingsAndBody(s.body);
91
+ return {
92
+ ref: `spec:${slug}`,
93
+ kind: 'spec',
94
+ title: s.frontmatter.title ?? slug,
95
+ status: null,
96
+ type: null,
97
+ headings,
98
+ body,
99
+ };
100
+ });
101
+
102
+ return [...fromChanges, ...fromSpecs];
103
+ }
104
+
105
+ function scoreDocument(doc, terms) {
106
+ const titleN = normalize(doc.title);
107
+ const headingsN = normalize(doc.headings);
108
+ const bodyN = normalize(doc.body);
109
+ let score = 0;
110
+ for (const term of terms) {
111
+ score += countOccurrences(titleN, term) * 3;
112
+ score += countOccurrences(headingsN, term) * 2;
113
+ score += countOccurrences(bodyN, term) * 1;
114
+ }
115
+ return score;
116
+ }
117
+
118
+ function buildSnippet(doc, terms) {
119
+ for (const field of [doc.title, doc.headings, doc.body]) {
120
+ for (const term of terms) {
121
+ const snippet = findSnippet(field, term);
122
+ if (snippet) return snippet;
123
+ }
124
+ }
125
+ return '';
126
+ }
127
+
128
+ // Scores and ranks `docs` (as produced by `buildCorpus`) against `query`.
129
+ // On an equal score, a spec sorts before a change — specs are the current
130
+ // persistent truth, so that's what an agent should read first. Among equals
131
+ // of the same kind, ties break by `ref` descending so the same repo state
132
+ // always yields the same byte-for-byte output.
133
+ export function searchDocuments(docs, query, { limit = 10, type, status } = {}) {
134
+ const terms = tokenize(query);
135
+ if (!terms.length) return [];
136
+
137
+ const hits = [];
138
+ for (const doc of docs) {
139
+ if (type && doc.type !== type) continue;
140
+ if (status && doc.status !== status) continue;
141
+ const score = scoreDocument(doc, terms);
142
+ if (score <= 0) continue;
143
+ hits.push({
144
+ ref: doc.ref,
145
+ kind: doc.kind,
146
+ title: doc.title,
147
+ type: doc.type,
148
+ status: doc.status,
149
+ score,
150
+ snippet: buildSnippet(doc, terms),
151
+ });
152
+ }
153
+
154
+ hits.sort((a, b) => {
155
+ if (b.score !== a.score) return b.score - a.score;
156
+ if (a.kind !== b.kind) return a.kind === 'spec' ? -1 : 1;
157
+ if (a.ref === b.ref) return 0;
158
+ return a.ref < b.ref ? 1 : -1;
159
+ });
160
+
161
+ return hits.slice(0, limit);
162
+ }
@@ -407,7 +407,7 @@ export function previewConfigMigration(projects, id, rev) {
407
407
  return {
408
408
  code: 200,
409
409
  body: {
410
- summary: `Config migration 0 → ${SUPPORTED_SCHEMA_VERSION} (dry run)`,
410
+ summary: `Config migration ${migrationResult.fromVersion} → ${SUPPORTED_SCHEMA_VERSION} (dry run)`,
411
411
  changes: migrationResult.changes,
412
412
  yaml: migrationResult.yaml,
413
413
  },
@@ -468,6 +468,7 @@ const PATCH_ALLOWED = new Set([
468
468
  'readiness',
469
469
  'types',
470
470
  'release',
471
+ 'git',
471
472
  ]);
472
473
 
473
474
  const CANONICAL_STATUSES_REQUIRED = new Set([
@@ -500,6 +501,8 @@ function applyPatch(doc, patch, currentConfig) {
500
501
  applyReleasePatch(doc, value);
501
502
  } else if (key === 'readiness') {
502
503
  applyReadinessPatch(doc, value);
504
+ } else if (key === 'git') {
505
+ applyGitPatch(doc, value);
503
506
  } else if (key === 'statuses') {
504
507
  applyRequiredListPatch(doc, 'statuses', value, CANONICAL_STATUSES_REQUIRED);
505
508
  } else if (key === 'stages') {
@@ -554,3 +557,12 @@ function applyReadinessPatch(doc, readinessPatch) {
554
557
  doc.setIn(['readiness', 'verification_patterns'], readinessPatch.verification_patterns);
555
558
  }
556
559
  }
560
+
561
+ function applyGitPatch(doc, gitPatch) {
562
+ if (!gitPatch || typeof gitPatch !== 'object') return;
563
+ if (typeof gitPatch.integration_branch === 'string' && gitPatch.integration_branch.trim()) {
564
+ doc.setIn(['git', 'integration_branch'], gitPatch.integration_branch.trim());
565
+ } else if (gitPatch.integration_branch === null) {
566
+ doc.deleteIn(['git', 'integration_branch']);
567
+ }
568
+ }
@@ -48,7 +48,7 @@ import {
48
48
  tableRow,
49
49
  validationPanel,
50
50
  } from './view-parts.js';
51
- import { graphSvg, metricsHtml, specsListHtml } from './view-renderers.js';
51
+ import { graphSvg, metricsHtml, sortSpecsByUpdated, specsListHtml } from './view-renderers.js';
52
52
 
53
53
  export { cssIdent, esc, makeMermaidExpandable, safeHtml } from './security.js';
54
54
  export { boardStatuses, isVisible, passesTombstones } from './state.js';
@@ -156,7 +156,7 @@ function renderChoiceFilter(host, label, choices, selected, toggle, clear, owner
156
156
  html`<details class="filter-menu">
157
157
  <summary class="filter-trigger">
158
158
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.25h12M4.25 8h7.5M6.5 12.75h3"></path></svg>
159
- <span>${summary}</span>
159
+ <span data-choice-summary>${summary}</span>
160
160
  <svg class="filter-chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6.25 3.5 3.5 3.5-3.5"></path></svg>
161
161
  </summary>
162
162
  <div class="filter-popover">
@@ -169,19 +169,32 @@ function renderChoiceFilter(host, label, choices, selected, toggle, clear, owner
169
169
  </details>`,
170
170
  host,
171
171
  );
172
+ const syncSummary = () => {
173
+ host.querySelector('[data-choice-summary]').textContent = choiceFilterSummary(
174
+ label,
175
+ selected,
176
+ owners && state.filters.includeUnassigned,
177
+ );
178
+ };
172
179
  host.querySelectorAll('[data-choice]').forEach((input) => {
173
180
  input.onchange = () => {
174
181
  toggle(input.dataset.choice);
182
+ syncSummary();
175
183
  render();
176
184
  };
177
185
  });
178
186
  if (owners)
179
187
  host.querySelector('[data-unassigned]').onchange = () => {
180
188
  toggleUnassignedOwner();
189
+ syncSummary();
181
190
  render();
182
191
  };
183
192
  host.querySelector('[data-clear]').onclick = () => {
184
193
  clear();
194
+ host.querySelectorAll('[data-choice], [data-unassigned]').forEach((input) => {
195
+ input.checked = false;
196
+ });
197
+ syncSummary();
185
198
  render();
186
199
  };
187
200
  }
@@ -722,8 +735,10 @@ function sortVal(c, key) {
722
735
  /* Specs view */
723
736
  function renderSpecs() {
724
737
  const q = state.filters.text.toLowerCase();
725
- const specs = (state.repo.specs || []).filter(
726
- (s) => !q || `${s.title} ${(s.tags || []).join(' ')} ${s.body}`.toLowerCase().includes(q),
738
+ const specs = sortSpecsByUpdated(
739
+ (state.repo.specs || []).filter(
740
+ (s) => !q || `${s.title} ${(s.tags || []).join(' ')} ${s.body}`.toLowerCase().includes(q),
741
+ ),
727
742
  );
728
743
  litRender(specsListHtml(specs, fmtDateTime), $('#specs'));
729
744
  $('#specs')
@@ -789,8 +804,32 @@ export function handleSpecBodyClick(event, _openSpecByName) {
789
804
 
790
805
  const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics', 'projects'];
791
806
 
792
- function renderMetrics() {
793
- litRender(metricsHtml(state.repo.metrics || {}), $('#metrics'));
807
+ // The shared metrics module is dynamic-imported once and cached: the client
808
+ // computes metrics itself, over the filtered set, instead of duplicating
809
+ // `computeMetrics` (20260711-155721 CR2). Served read-only from the CLI's own
810
+ // `src/metrics.mjs` by the router.
811
+ let sharedMetricsModule;
812
+ function loadMetricsModule() {
813
+ sharedMetricsModule ??= import('/shared/metrics.mjs');
814
+ return sharedMetricsModule;
815
+ }
816
+
817
+ // `computeMetrics` speaks the CLI's native change shape (`{ frontmatter,
818
+ // stages }`); `state.repo.changes` is already flattened for board/table
819
+ // rendering. Adapt back rather than reshaping the shared module's contract,
820
+ // which the server also relies on unchanged.
821
+ function toMetricsChange(c) {
822
+ return {
823
+ frontmatter: { id: c.id, type: c.type, status: c.status, owner: c.owner, created: c.created },
824
+ stages: c.stages,
825
+ };
826
+ }
827
+
828
+ async function renderMetrics() {
829
+ const changes = visibleChanges();
830
+ const { computeMetrics } = await loadMetricsModule();
831
+ const metrics = computeMetrics(changes.map(toMetricsChange), { now: new Date().toISOString() });
832
+ litRender(metricsHtml(metrics, changes.length), $('#metrics'));
794
833
  }
795
834
 
796
835
  export function syncViewerShell(root = document, renderContent = true) {
@@ -825,7 +864,6 @@ let configMode = 'form'; // 'form' | 'raw'
825
864
  let configDirty = false; // true when form/raw has unsaved edits
826
865
  let migrationPreview = null; // null | { summary, changes, yaml } | { already_current }
827
866
 
828
- const SUPPORTED_SCHEMA_VERSION = 1;
829
867
  // Confirm dialog — uses native <dialog> for proper focus-trap, ESC and backdrop.
830
868
  // _confirmImpl is replaceable in tests (JSDOM lacks showModal).
831
869
  let _confirmImpl = null;
@@ -929,8 +967,9 @@ function configSectionTemplate(config, mode, preview) {
929
967
  }
930
968
 
931
969
  const schema = config.schemaVersion ?? 0;
932
- const futureSch = schema > SUPPORTED_SCHEMA_VERSION;
933
- const outdated = schema < SUPPORTED_SCHEMA_VERSION;
970
+ const supported = config.supported;
971
+ const futureSch = schema > supported;
972
+ const outdated = schema < supported;
934
973
 
935
974
  return html`<div class="config-section">
936
975
  ${
@@ -964,7 +1003,7 @@ function configSectionTemplate(config, mode, preview) {
964
1003
  : outdated
965
1004
  ? html`<div class="config-migration-card">
966
1005
  <h3>Migration required</h3>
967
- <p>Config schema ${schema} is outdated. Preview and apply the migration to schema ${SUPPORTED_SCHEMA_VERSION} to enable the Form editor.</p>
1006
+ <p>Config schema ${schema} is outdated. Preview and apply the migration to schema ${supported} to enable the Form editor.</p>
968
1007
  ${
969
1008
  preview?.already_current
970
1009
  ? html`<p class="config-migration-ok">Migration already applied.</p>`
@@ -1055,6 +1094,14 @@ function formEditorTemplate(config) {
1055
1094
  </label>
1056
1095
  </fieldset>
1057
1096
 
1097
+ <fieldset class="config-group">
1098
+ <legend>Git</legend>
1099
+ <label>Integration branch
1100
+ <input name="integration_branch" .value=${cfg.git?.integration_branch ?? ''} placeholder="Auto-detect" />
1101
+ </label>
1102
+ <p class="config-note">Change branches start from and merge into this branch. Leave empty to auto-detect.</p>
1103
+ </fieldset>
1104
+
1058
1105
  <fieldset class="config-group">
1059
1106
  <legend>Lifecycle statuses</legend>
1060
1107
  <label>Status order (one per line)
@@ -1207,7 +1254,7 @@ async function openManagedProject(id, { reload = false } = {}) {
1207
1254
  const structured = await getProjectConfigStructured(id);
1208
1255
  managedConfig = { id, ...structured };
1209
1256
  // Default to form for current schema, raw for future schema
1210
- if (structured.schemaVersion > SUPPORTED_SCHEMA_VERSION) {
1257
+ if (structured.schemaVersion > structured.supported) {
1211
1258
  configMode = 'raw';
1212
1259
  } else {
1213
1260
  configMode = 'form';
@@ -1300,6 +1347,13 @@ export function collectFormPatch(formEl, currentConfig) {
1300
1347
  if (els.specs_dir && els.specs_dir.value !== (currentConfig.specs_dir ?? '.changeledger/specs')) {
1301
1348
  patch.specs_dir = els.specs_dir.value;
1302
1349
  }
1350
+ if (els.integration_branch) {
1351
+ const currentBranch = currentConfig.git?.integration_branch ?? '';
1352
+ const proposedBranch = els.integration_branch.value.trim();
1353
+ if (proposedBranch !== currentBranch) {
1354
+ patch.git = { integration_branch: proposedBranch || null };
1355
+ }
1356
+ }
1303
1357
 
1304
1358
  const statuses = listFromControl(els.statuses);
1305
1359
  if (els.statuses && !sameList(statuses, currentConfig.statuses ?? [])) patch.statuses = statuses;
@@ -1639,6 +1693,17 @@ function bootstrap() {
1639
1693
 
1640
1694
  loadProjects();
1641
1695
  setInterval(load, 5000);
1696
+
1697
+ // The topbar wraps to multiple rows at content-dependent widths, so the
1698
+ // CSS fallback constant cannot bound the projects panels; track the real
1699
+ // rendered height instead.
1700
+ const topbar = document.querySelector('.topbar');
1701
+ if (topbar && typeof ResizeObserver === 'function') {
1702
+ const syncHeaderHeight = () =>
1703
+ document.documentElement.style.setProperty('--header-height', `${topbar.offsetHeight}px`);
1704
+ new ResizeObserver(syncHeaderHeight).observe(topbar);
1705
+ syncHeaderHeight();
1706
+ }
1642
1707
  }
1643
1708
 
1644
1709
  export function closeFilterMenusOnOutsideClick(menus, target) {