changeledger 0.8.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.
Files changed (48) hide show
  1. package/AGENTS.md +11 -3
  2. package/README.md +10 -1
  3. package/bin/changeledger.mjs +204 -21
  4. package/package.json +1 -1
  5. package/src/check.mjs +12 -0
  6. package/src/commands/agent-context.mjs +65 -0
  7. package/src/commands/agent-prompt.mjs +22 -0
  8. package/src/commands/agent.mjs +13 -10
  9. package/src/commands/check.mjs +55 -0
  10. package/src/commands/commit.mjs +39 -0
  11. package/src/commands/context.mjs +60 -27
  12. package/src/commands/fix.mjs +72 -0
  13. package/src/commands/register.mjs +9 -1
  14. package/src/commands/search.mjs +56 -0
  15. package/src/config-migration.mjs +44 -9
  16. package/src/config.mjs +13 -0
  17. package/src/contract.mjs +73 -15
  18. package/src/fix.mjs +127 -0
  19. package/src/framing.mjs +30 -0
  20. package/src/git.mjs +119 -2
  21. package/src/lifecycle.mjs +2 -2
  22. package/src/metrics.mjs +42 -0
  23. package/src/search.mjs +162 -0
  24. package/src/viewer/domain.mjs +2 -2
  25. package/src/viewer/public/app-state.js +62 -6
  26. package/src/viewer/public/app.js +138 -39
  27. package/src/viewer/public/index.html +2 -2
  28. package/src/viewer/public/state.js +6 -2
  29. package/src/viewer/public/styles.css +76 -5
  30. package/src/viewer/public/view-renderers.js +160 -48
  31. package/src/viewer/server/router.mjs +40 -6
  32. package/templates/config.yml +7 -1
  33. package/templates/contract/agent-contexts/audit.md +22 -0
  34. package/templates/contract/agent-contexts/implementation.md +18 -0
  35. package/templates/contract/agent-contexts/investigation.md +14 -0
  36. package/templates/contract/agent-contexts/review.md +22 -0
  37. package/templates/contract/agent-prompts/audit.md +37 -0
  38. package/templates/contract/agent-prompts/implementation.md +42 -0
  39. package/templates/contract/agent-prompts/investigation.md +41 -0
  40. package/templates/contract/agent-prompts/review.md +36 -0
  41. package/templates/contract/budgets.yml +16 -0
  42. package/templates/contract/close.md +19 -14
  43. package/templates/contract/core.md +41 -30
  44. package/templates/contract/delegation.md +2 -1
  45. package/templates/contract/implement.md +24 -10
  46. package/templates/contract/review.md +7 -9
  47. package/templates/contract/spec.md +14 -1
  48. package/templates/contract/validation.md +1 -1
package/src/fix.mjs ADDED
@@ -0,0 +1,127 @@
1
+ // Mechanical, unambiguous repairs for format defects `changeledger check` can
2
+ // only diagnose. Pure text-in/text-out — no IO. The `fix` command (and its
3
+ // `--dry-run`) do the reading/writing; `check` reuses `hasFixableDefects` to
4
+ // print a hint without duplicating the repair rules.
5
+ //
6
+ // Repairs (in order, per `## Plan` task line):
7
+ // 1. Checkbox marker variants `[ x ]` / `[X]` -> `[x]`.
8
+ // 2. A `(CRn) — verify: X` block reordered to `; verify: X (CRn)`.
9
+ // 3. A resolution suffix using a single hyphen instead of an em dash.
10
+ // 4. A near-ISO resolution timestamp normalized to strict ISO 8601 UTC.
11
+ //
12
+ // A task whose CR reference is not declared in `## Specification` is left
13
+ // completely untouched and reported under `manual` — the defect requires
14
+ // judgment (unknown criterion), not a mechanical rewrite.
15
+ import { parseChange } from './change.mjs';
16
+
17
+ const TASK_LINE = /^(\s*-\s)\[([^\]]*)\](\s+)(.*)$/;
18
+ const REORDERED_VERIFY = /^(.*?)\s*\(([^)]*\bCR\d+[^)]*)\)\s*—\s*verify:\s*(.+)$/;
19
+ const NEAR_ISO = /^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?(Z)?$/;
20
+
21
+ export function computeFixes(text) {
22
+ const criteria = new Set(parseChange(text).criteria ?? []);
23
+ const lines = text.split('\n');
24
+ const outLines = [];
25
+ const applied = [];
26
+ const manual = [];
27
+ let inPlan = false;
28
+
29
+ for (let i = 0; i < lines.length; i++) {
30
+ const rawLine = lines[i];
31
+ if (/^##\s+/.test(rawLine)) {
32
+ inPlan = /^##\s+Plan\s*$/.test(rawLine);
33
+ outLines.push(rawLine);
34
+ continue;
35
+ }
36
+ if (!inPlan) {
37
+ outLines.push(rawLine);
38
+ continue;
39
+ }
40
+ const result = fixTaskLine(rawLine, criteria, i + 1);
41
+ outLines.push(result.line);
42
+ applied.push(...result.applied);
43
+ manual.push(...result.manual);
44
+ }
45
+
46
+ return { text: outLines.join('\n'), applied, manual, changed: applied.length > 0 };
47
+ }
48
+
49
+ export function hasFixableDefects(text) {
50
+ if (typeof text !== 'string') return false;
51
+ try {
52
+ return computeFixes(text).changed;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function fixTaskLine(rawLine, declaredCR, lineNo) {
59
+ const m = rawLine.match(TASK_LINE);
60
+ if (!m) return { line: rawLine, applied: [], manual: [] };
61
+ const [, prefix, markerRaw, gap, restRaw] = m;
62
+
63
+ // A task referencing an undeclared criterion needs judgment, not a rewrite —
64
+ // leave the entire line untouched.
65
+ const referenced = restRaw.match(/CR\d+/g) ?? [];
66
+ const unknown = [...new Set(referenced.filter((cr) => !declaredCR.has(cr)))];
67
+ if (unknown.length) {
68
+ return {
69
+ line: rawLine,
70
+ applied: [],
71
+ manual: [`line ${lineNo}: references unknown criterion ${unknown.join(', ')}`],
72
+ };
73
+ }
74
+
75
+ const applied = [];
76
+ let rest = restRaw;
77
+
78
+ let marker = markerRaw;
79
+ if (marker.trim().toLowerCase() === 'x' && marker !== 'x') {
80
+ marker = 'x';
81
+ applied.push(`line ${lineNo}: checkbox marker normalized to [x]`);
82
+ }
83
+ const state = marker === 'x' ? 'done' : marker === '!' ? 'blocked' : 'todo';
84
+
85
+ const reorder = rest.match(REORDERED_VERIFY);
86
+ if (reorder) {
87
+ const [, target, crBlock, verify] = reorder;
88
+ rest = `${target.trim()}; verify: ${verify.trim()} (${crBlock.trim()})`;
89
+ applied.push(`line ${lineNo}: reordered verify suffix before (${crBlock.trim()})`);
90
+ }
91
+
92
+ // The resolution suffix is the LAST separator: a description may legitimately
93
+ // contain an em dash, so only a hyphen sitting after every em dash is a defect.
94
+ if (
95
+ (state === 'done' || state === 'blocked') &&
96
+ rest.lastIndexOf(' - ') > rest.lastIndexOf(' — ')
97
+ ) {
98
+ const hyphenIdx = rest.lastIndexOf(' - ');
99
+ if (hyphenIdx !== -1) {
100
+ rest = `${rest.slice(0, hyphenIdx)} — ${rest.slice(hyphenIdx + 3)}`;
101
+ applied.push(`line ${lineNo}: resolution suffix hyphen normalized to em dash`);
102
+ }
103
+ }
104
+
105
+ if (state === 'done') {
106
+ const dash = rest.lastIndexOf(' — ');
107
+ if (dash !== -1) {
108
+ const suffix = rest.slice(dash + 3);
109
+ const normalized = normalizeIsoTimestamp(suffix);
110
+ if (normalized && normalized !== suffix) {
111
+ rest = `${rest.slice(0, dash)} — ${normalized}`;
112
+ applied.push(`line ${lineNo}: resolution timestamp normalized to ISO 8601 UTC`);
113
+ }
114
+ }
115
+ }
116
+
117
+ if (!applied.length) return { line: rawLine, applied: [], manual: [] };
118
+ return { line: `${prefix}[${marker}]${gap}${rest}`, applied, manual: [] };
119
+ }
120
+
121
+ function normalizeIsoTimestamp(text) {
122
+ const m = text.trim().match(NEAR_ISO);
123
+ if (!m) return null;
124
+ const [, y, mo, d, h, mi, s] = m;
125
+ const pad = (v) => v.padStart(2, '0');
126
+ return `${y}-${pad(mo)}-${pad(d)}T${pad(h)}:${mi}:${s}Z`;
127
+ }
@@ -0,0 +1,30 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { packageRoot } from './paths.mjs';
5
+
6
+ // Single source of the installed version and the anti-truncation sentinels, so
7
+ // `context`, `agent-prompt` and `agent-context` share framing and never diverge
8
+ // through independent copies.
9
+ export const VERSION = JSON.parse(
10
+ fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'),
11
+ ).version;
12
+
13
+ const TRUNCATION_SUFFIX = 'if this line is missing, the output was truncated: stop and re-run';
14
+
15
+ // `kind` names the payload (e.g. "CONTEXT", "AGENT PROMPT"); `meta` is the
16
+ // already-formatted descriptor (e.g. "mode: implement — v0.8.0").
17
+ export function beginSentinel(kind, meta) {
18
+ return `===== CHANGELEDGER ${kind} BEGIN — ${meta} =====`;
19
+ }
20
+
21
+ export function endSentinel(kind) {
22
+ return `===== CHANGELEDGER ${kind} END — ${TRUNCATION_SUFFIX} =====`;
23
+ }
24
+
25
+ // 12-hex-char content revision for a composed body. Callers hash the body
26
+ // only (never the BEGIN/END lines themselves) so the revision never
27
+ // references its own framing.
28
+ export function contentRev(body) {
29
+ return crypto.createHash('sha256').update(body).digest('hex').slice(0, 12);
30
+ }
package/src/git.mjs CHANGED
@@ -6,8 +6,56 @@ import { execFileSync } from 'node:child_process';
6
6
 
7
7
  const SEP = String.fromCharCode(31); // ASCII unit separator — safe field delimiter
8
8
 
9
- function defaultRun(args, cwd) {
10
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
9
+ // Repo-location env vars git itself exports while running a hook (e.g. this
10
+ // project's own pre-commit). Left inherited, a child `git` call would silently
11
+ // target the hook's repo/worktree instead of the given `cwd` — strip them so
12
+ // every invocation stays anchored on `cwd`.
13
+ const GIT_LOCATION_ENV_VARS = [
14
+ 'GIT_DIR',
15
+ 'GIT_WORK_TREE',
16
+ 'GIT_INDEX_FILE',
17
+ 'GIT_OBJECT_DIRECTORY',
18
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
19
+ 'GIT_COMMON_DIR',
20
+ 'GIT_CEILING_DIRECTORIES',
21
+ ];
22
+
23
+ function sanitizedEnv() {
24
+ const env = { ...process.env };
25
+ for (const key of GIT_LOCATION_ENV_VARS) delete env[key];
26
+ return env;
27
+ }
28
+
29
+ // Exported so other commands (e.g. `changeledger commit`) share the same
30
+ // GIT_* sanitization instead of re-implementing it.
31
+ export function defaultRun(args, cwd) {
32
+ return execFileSync('git', args, {
33
+ cwd,
34
+ env: sanitizedEnv(),
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'ignore'],
37
+ });
38
+ }
39
+
40
+ // Run variant for mutating git commands (e.g. `commit`), where git's stderr is
41
+ // the only clue to a failure (failed hook, nothing staged, missing identity,
42
+ // lock). Pipes stderr and, on failure, throws an Error whose message includes
43
+ // the captured diagnostic. Query paths keep `defaultRun` and degrade silently.
44
+ export function mutatingRun(args, cwd) {
45
+ try {
46
+ return execFileSync('git', args, {
47
+ cwd,
48
+ env: sanitizedEnv(),
49
+ encoding: 'utf8',
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ });
52
+ } catch (e) {
53
+ const detail = [e.stderr, e.stdout]
54
+ .map((s) => (typeof s === 'string' ? s.trim() : ''))
55
+ .filter(Boolean)
56
+ .join('\n');
57
+ throw new Error(detail ? `${e.message}\n${detail}` : e.message, { cause: e });
58
+ }
11
59
  }
12
60
 
13
61
  // Local git identity (`git config user.name`), or '' if unavailable. Tolerant.
@@ -39,6 +87,75 @@ export function ownerHandle(cwd, run = defaultRun, ghRun = defaultGhRun) {
39
87
  return githubLogin(ghRun) || gitUser(cwd, run);
40
88
  }
41
89
 
90
+ // Detects the branch `changeledger check --commits` should diff against when
91
+ // no base is given: the remote's HEAD if configured, else a local `main` or
92
+ // `master`. Throws with actionable guidance if neither is resolvable.
93
+ export function defaultBaseBranch(repoRoot, run = defaultRun) {
94
+ try {
95
+ const out = run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repoRoot);
96
+ const name = out.trim().replace(/^origin\//, '');
97
+ if (name) return name;
98
+ } catch {
99
+ // no configured remote HEAD — fall through to local candidates
100
+ }
101
+ for (const candidate of ['main', 'master']) {
102
+ try {
103
+ run(['rev-parse', '--verify', '--quiet', candidate], repoRoot);
104
+ return candidate;
105
+ } catch {
106
+ // candidate branch does not exist locally — try the next one
107
+ }
108
+ }
109
+ throw new Error(
110
+ 'Could not detect a default branch (no origin/HEAD, main, or master); pass one explicitly: changeledger check --commits <base>',
111
+ );
112
+ }
113
+
114
+ // Commits in `range` (e.g. `main..HEAD`): sha, subject and whether each is a
115
+ // merge (more than one parent) — the git metadata `check --commits` lints.
116
+ export function commitsInRange(repoRoot, range, run = defaultRun) {
117
+ let out;
118
+ try {
119
+ out = run(['log', range, `--pretty=format:%H${SEP}%P${SEP}%s`], repoRoot);
120
+ } catch (e) {
121
+ throw new Error(`git log failed for range "${range}": ${e.message}`);
122
+ }
123
+ return out
124
+ .split('\n')
125
+ .filter(Boolean)
126
+ .map((line) => {
127
+ const [sha, parents, subject] = line.split(SEP);
128
+ return {
129
+ sha,
130
+ subject,
131
+ isMerge: parents.trim().split(/\s+/).filter(Boolean).length > 1,
132
+ };
133
+ });
134
+ }
135
+
136
+ // A well-formed marker is one or more `[#id]` groups, each separated by a
137
+ // single space, terminating the subject — the canonical multi-id shape is
138
+ // separate brackets (`[#A] [#B]`), never a comma list in one bracket.
139
+ const MARKER_RE = /(\[#[^\]\s]+\])(\s\[#[^\]\s]+\])*$/;
140
+ export function hasCommitMarker(subject) {
141
+ return MARKER_RE.test(subject.trim());
142
+ }
143
+
144
+ // Lints `range`: every non-merge commit must carry a well-formed `[#id]`
145
+ // marker, except `chore(release)` prep commits. Returns only the violations
146
+ // (sha + subject); never throws for a clean range.
147
+ export function lintCommitRange(repoRoot, range, run = defaultRun) {
148
+ const commits = commitsInRange(repoRoot, range, run);
149
+ const violations = [];
150
+ for (const c of commits) {
151
+ if (c.isMerge) continue;
152
+ if (/^chore\(release\):/.test(c.subject)) continue;
153
+ if (!hasCommitMarker(c.subject))
154
+ violations.push({ sha: c.sha.slice(0, 7), subject: c.subject });
155
+ }
156
+ return violations;
157
+ }
158
+
42
159
  export function gitRefs(repoRoot, id, run = defaultRun) {
43
160
  const refs = { commits: [], branches: [] };
44
161
  if (!id) return refs;
package/src/lifecycle.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // The change lifecycle as an explicit, testable graph — the single authority on
2
2
  // which status moves are legal. Shared by the CLI (`changeledger status`) and the viewer
3
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
4
+ // policy on top (approval plus final acceptance); it never relaxes
5
5
  // this graph.
6
6
 
7
7
  export const CANONICAL_STATUSES = [
@@ -21,7 +21,7 @@ export const CANONICAL_STATUSES = [
21
21
  // `in-validation` is the universal human gate before done. Review or validation
22
22
  // may route back to in-progress, while review may also block. `discarded` is a
23
23
  // terminal tombstone reachable only before either closing gate. `done` has one
24
- // policy-gated human reopen edge; generic agent commands do not own it.
24
+ // policy-gated provisional reopen edge; generic status commands do not own it.
25
25
  const TRANSITIONS = {
26
26
  draft: ['approved', 'discarded'],
27
27
  approved: ['in-progress', 'discarded'],
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
+ }
@@ -130,7 +130,7 @@ export function changeStatus(projects, { project, id, status, reason }) {
130
130
  }
131
131
  try {
132
132
  if (current === 'draft' && status === 'approved') {
133
- applyStatusCmd(id, status, proj.path);
133
+ applyStatusCmd(id, status, proj.path, { actor: 'human' });
134
134
  } else if (current === 'in-validation' && status === 'done') {
135
135
  applyValidation(id, 'pass', {}, proj.path);
136
136
  } else if (current === 'in-validation' && status === 'in-progress') {
@@ -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
  },