changeledger 0.9.0 → 0.10.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/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
  },
@@ -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>`
@@ -1207,7 +1246,7 @@ async function openManagedProject(id, { reload = false } = {}) {
1207
1246
  const structured = await getProjectConfigStructured(id);
1208
1247
  managedConfig = { id, ...structured };
1209
1248
  // Default to form for current schema, raw for future schema
1210
- if (structured.schemaVersion > SUPPORTED_SCHEMA_VERSION) {
1249
+ if (structured.schemaVersion > structured.supported) {
1211
1250
  configMode = 'raw';
1212
1251
  } else {
1213
1252
  configMode = 'form';
@@ -1639,6 +1678,17 @@ function bootstrap() {
1639
1678
 
1640
1679
  loadProjects();
1641
1680
  setInterval(load, 5000);
1681
+
1682
+ // The topbar wraps to multiple rows at content-dependent widths, so the
1683
+ // CSS fallback constant cannot bound the projects panels; track the real
1684
+ // rendered height instead.
1685
+ const topbar = document.querySelector('.topbar');
1686
+ if (topbar && typeof ResizeObserver === 'function') {
1687
+ const syncHeaderHeight = () =>
1688
+ document.documentElement.style.setProperty('--header-height', `${topbar.offsetHeight}px`);
1689
+ new ResizeObserver(syncHeaderHeight).observe(topbar);
1690
+ syncHeaderHeight();
1691
+ }
1642
1692
  }
1643
1693
 
1644
1694
  export function closeFilterMenusOnOutsideClick(menus, target) {
@@ -22,6 +22,8 @@
22
22
  --status-done: #3fb950;
23
23
  --status-discarded: #9aa0aa;
24
24
  --status-muted: #8b929e;
25
+ /* fallback only: app.js keeps this synced to the real .topbar height */
26
+ --header-height: 55px;
25
27
  font-family: "Avenir Next", Avenir, ui-sans-serif, sans-serif;
26
28
  color-scheme: dark;
27
29
  }
@@ -115,17 +117,20 @@ body {
115
117
  /* Project management */
116
118
  .projects-view {
117
119
  padding: 18px;
120
+ height: calc(100dvh - var(--header-height));
121
+ box-sizing: border-box;
118
122
  }
119
123
  .projects-shell {
120
124
  display: grid;
121
125
  grid-template-columns: minmax(320px, 0.8fr) minmax(460px, 1.2fr);
122
126
  gap: 16px;
123
- max-width: 1500px;
124
- margin: 0 auto;
127
+ height: 100%;
125
128
  }
126
129
  .projects-list,
127
130
  .project-editor {
128
131
  min-width: 0;
132
+ min-height: 0;
133
+ overflow-y: auto;
129
134
  border: 1px solid var(--line);
130
135
  border-radius: 14px;
131
136
  background: var(--panel);
@@ -303,8 +308,16 @@ body {
303
308
  cursor: wait;
304
309
  }
305
310
  @media (max-width: 900px) {
311
+ .projects-view {
312
+ height: auto;
313
+ }
306
314
  .projects-shell {
307
315
  grid-template-columns: 1fr;
316
+ height: auto;
317
+ }
318
+ .projects-list,
319
+ .project-editor {
320
+ overflow-y: visible;
308
321
  }
309
322
  .project-row {
310
323
  grid-template-columns: 10px minmax(0, 1fr) auto;
@@ -894,6 +907,49 @@ body {
894
907
  font-size: 13px;
895
908
  border-bottom: 1px solid var(--line);
896
909
  }
910
+ .muted {
911
+ color: var(--muted);
912
+ }
913
+ .metrics-grid {
914
+ display: grid;
915
+ grid-template-columns: repeat(2, minmax(0, 1fr));
916
+ gap: 14px;
917
+ margin-top: 18px;
918
+ }
919
+ @media (max-width: 1100px) {
920
+ .metrics-grid {
921
+ grid-template-columns: minmax(0, 1fr);
922
+ }
923
+ }
924
+ .metrics-panel {
925
+ background: var(--panel);
926
+ border: 1px solid var(--line);
927
+ border-radius: 8px;
928
+ padding: 4px 18px 16px;
929
+ min-width: 0;
930
+ }
931
+ .metrics-panel .metrics-h:first-child {
932
+ margin-top: 14px;
933
+ }
934
+ .throughput-svg {
935
+ width: 100%;
936
+ height: auto;
937
+ }
938
+ .tp-rect {
939
+ fill: var(--accent, #4a9eff);
940
+ }
941
+ .tp-value {
942
+ font-size: 11px;
943
+ fill: var(--text);
944
+ }
945
+ .tp-date {
946
+ font-size: 10px;
947
+ fill: var(--muted);
948
+ }
949
+ .tp-axis {
950
+ stroke: var(--line);
951
+ stroke-width: 1;
952
+ }
897
953
 
898
954
  /* Overlay + detail */
899
955
  .detail-open {
@@ -1321,10 +1377,10 @@ body {
1321
1377
  /* Specs view */
1322
1378
  .specs-view {
1323
1379
  padding: 16px;
1324
- display: flex;
1325
- flex-direction: column;
1380
+ display: grid;
1381
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
1326
1382
  gap: 10px;
1327
- max-width: 760px;
1383
+ align-items: start;
1328
1384
  }
1329
1385
  .spec-card {
1330
1386
  background: var(--panel);
@@ -1340,6 +1396,21 @@ body {
1340
1396
  font-weight: 600;
1341
1397
  margin-bottom: 6px;
1342
1398
  }
1399
+ .spec-excerpt {
1400
+ margin: 8px 0 0;
1401
+ color: var(--muted);
1402
+ font-size: 0.9em;
1403
+ overflow: hidden;
1404
+ display: -webkit-box;
1405
+ -webkit-line-clamp: 3;
1406
+ -webkit-box-orient: vertical;
1407
+ }
1408
+
1409
+ @media (max-width: 680px) {
1410
+ .specs-view {
1411
+ grid-template-columns: 1fr;
1412
+ }
1413
+ }
1343
1414
  .empty {
1344
1415
  color: var(--muted);
1345
1416
  padding: 8px;