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.
- package/AGENTS.md +11 -3
- package/README.md +10 -1
- package/bin/changeledger.mjs +204 -21
- package/package.json +1 -1
- package/src/check.mjs +12 -0
- package/src/commands/agent-context.mjs +65 -0
- package/src/commands/agent-prompt.mjs +22 -0
- package/src/commands/agent.mjs +13 -10
- package/src/commands/check.mjs +55 -0
- package/src/commands/commit.mjs +39 -0
- package/src/commands/context.mjs +60 -27
- package/src/commands/fix.mjs +72 -0
- package/src/commands/register.mjs +9 -1
- package/src/commands/search.mjs +56 -0
- package/src/config-migration.mjs +44 -9
- package/src/config.mjs +13 -0
- package/src/contract.mjs +73 -15
- package/src/fix.mjs +127 -0
- package/src/framing.mjs +30 -0
- package/src/git.mjs +119 -2
- package/src/lifecycle.mjs +2 -2
- package/src/metrics.mjs +42 -0
- package/src/search.mjs +162 -0
- package/src/viewer/domain.mjs +2 -2
- package/src/viewer/public/app-state.js +62 -6
- package/src/viewer/public/app.js +138 -39
- package/src/viewer/public/index.html +2 -2
- package/src/viewer/public/state.js +6 -2
- package/src/viewer/public/styles.css +76 -5
- package/src/viewer/public/view-renderers.js +160 -48
- package/src/viewer/server/router.mjs +40 -6
- package/templates/config.yml +7 -1
- package/templates/contract/agent-contexts/audit.md +22 -0
- package/templates/contract/agent-contexts/implementation.md +18 -0
- package/templates/contract/agent-contexts/investigation.md +14 -0
- package/templates/contract/agent-contexts/review.md +22 -0
- package/templates/contract/agent-prompts/audit.md +37 -0
- package/templates/contract/agent-prompts/implementation.md +42 -0
- package/templates/contract/agent-prompts/investigation.md +41 -0
- package/templates/contract/agent-prompts/review.md +36 -0
- package/templates/contract/budgets.yml +16 -0
- package/templates/contract/close.md +19 -14
- package/templates/contract/core.md +41 -30
- package/templates/contract/delegation.md +2 -1
- package/templates/contract/implement.md +24 -10
- package/templates/contract/review.md +7 -9
- package/templates/contract/spec.md +14 -1
- 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
|
+
}
|
package/src/framing.mjs
ADDED
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/viewer/domain.mjs
CHANGED
|
@@ -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
|
|
410
|
+
summary: `Config migration ${migrationResult.fromVersion} → ${SUPPORTED_SCHEMA_VERSION} (dry run)`,
|
|
411
411
|
changes: migrationResult.changes,
|
|
412
412
|
yaml: migrationResult.yaml,
|
|
413
413
|
},
|