mulch-cli 0.4.3 → 0.6.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/README.md +24 -4
- package/package.json +11 -16
- package/src/api.ts +310 -0
- package/src/cli.ts +54 -0
- package/src/commands/add.ts +61 -0
- package/src/commands/compact.ts +924 -0
- package/src/commands/delete.ts +103 -0
- package/src/commands/diff.ts +209 -0
- package/src/commands/doctor.ts +586 -0
- package/src/commands/edit.ts +253 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/learn.ts +170 -0
- package/src/commands/onboard.ts +362 -0
- package/src/commands/prime.ts +327 -0
- package/src/commands/prune.ts +128 -0
- package/src/commands/query.ts +177 -0
- package/src/commands/ready.ts +194 -0
- package/src/commands/record.ts +959 -0
- package/src/commands/search.ts +234 -0
- package/src/commands/setup.ts +823 -0
- package/src/commands/status.ts +83 -0
- package/src/commands/sync.ts +224 -0
- package/src/commands/update.ts +112 -0
- package/src/commands/validate.ts +107 -0
- package/src/index.ts +50 -0
- package/src/schemas/config.ts +31 -0
- package/src/schemas/index.ts +18 -0
- package/src/schemas/record-schema.ts +177 -0
- package/src/schemas/record.ts +83 -0
- package/src/utils/bm25.ts +243 -0
- package/src/utils/budget.ts +157 -0
- package/src/utils/config.ts +117 -0
- package/src/utils/expertise.ts +379 -0
- package/src/utils/format.ts +767 -0
- package/src/utils/git.ts +89 -0
- package/src/utils/index.ts +54 -0
- package/src/utils/json-output.ts +13 -0
- package/src/utils/lock.ts +82 -0
- package/src/utils/markers.ts +51 -0
- package/src/utils/scoring.ts +101 -0
- package/src/utils/version.ts +46 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -50
- package/dist/cli.js.map +0 -1
- package/dist/commands/add.d.ts +0 -3
- package/dist/commands/add.d.ts.map +0 -1
- package/dist/commands/add.js +0 -47
- package/dist/commands/add.js.map +0 -1
- package/dist/commands/compact.d.ts +0 -5
- package/dist/commands/compact.d.ts.map +0 -1
- package/dist/commands/compact.js +0 -709
- package/dist/commands/compact.js.map +0 -1
- package/dist/commands/delete.d.ts +0 -3
- package/dist/commands/delete.d.ts.map +0 -1
- package/dist/commands/delete.js +0 -82
- package/dist/commands/delete.js.map +0 -1
- package/dist/commands/diff.d.ts +0 -11
- package/dist/commands/diff.d.ts.map +0 -1
- package/dist/commands/diff.js +0 -170
- package/dist/commands/diff.js.map +0 -1
- package/dist/commands/doctor.d.ts +0 -3
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js +0 -391
- package/dist/commands/doctor.js.map +0 -1
- package/dist/commands/edit.d.ts +0 -3
- package/dist/commands/edit.d.ts.map +0 -1
- package/dist/commands/edit.js +0 -210
- package/dist/commands/edit.js.map +0 -1
- package/dist/commands/init.d.ts +0 -3
- package/dist/commands/init.d.ts.map +0 -1
- package/dist/commands/init.js +0 -30
- package/dist/commands/init.js.map +0 -1
- package/dist/commands/learn.d.ts +0 -12
- package/dist/commands/learn.d.ts.map +0 -1
- package/dist/commands/learn.js +0 -130
- package/dist/commands/learn.js.map +0 -1
- package/dist/commands/onboard.d.ts +0 -10
- package/dist/commands/onboard.d.ts.map +0 -1
- package/dist/commands/onboard.js +0 -286
- package/dist/commands/onboard.js.map +0 -1
- package/dist/commands/prime.d.ts +0 -3
- package/dist/commands/prime.d.ts.map +0 -1
- package/dist/commands/prime.js +0 -242
- package/dist/commands/prime.js.map +0 -1
- package/dist/commands/prune.d.ts +0 -8
- package/dist/commands/prune.d.ts.map +0 -1
- package/dist/commands/prune.js +0 -90
- package/dist/commands/prune.js.map +0 -1
- package/dist/commands/query.d.ts +0 -3
- package/dist/commands/query.d.ts.map +0 -1
- package/dist/commands/query.js +0 -118
- package/dist/commands/query.js.map +0 -1
- package/dist/commands/ready.d.ts +0 -3
- package/dist/commands/ready.d.ts.map +0 -1
- package/dist/commands/ready.js +0 -160
- package/dist/commands/ready.js.map +0 -1
- package/dist/commands/record.d.ts +0 -13
- package/dist/commands/record.d.ts.map +0 -1
- package/dist/commands/record.js +0 -688
- package/dist/commands/record.js.map +0 -1
- package/dist/commands/search.d.ts +0 -3
- package/dist/commands/search.d.ts.map +0 -1
- package/dist/commands/search.js +0 -163
- package/dist/commands/search.js.map +0 -1
- package/dist/commands/setup.d.ts +0 -29
- package/dist/commands/setup.d.ts.map +0 -1
- package/dist/commands/setup.js +0 -548
- package/dist/commands/setup.js.map +0 -1
- package/dist/commands/status.d.ts +0 -3
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/status.js +0 -61
- package/dist/commands/status.js.map +0 -1
- package/dist/commands/sync.d.ts +0 -3
- package/dist/commands/sync.d.ts.map +0 -1
- package/dist/commands/sync.js +0 -176
- package/dist/commands/sync.js.map +0 -1
- package/dist/commands/update.d.ts +0 -3
- package/dist/commands/update.d.ts.map +0 -1
- package/dist/commands/update.js +0 -72
- package/dist/commands/update.js.map +0 -1
- package/dist/commands/validate.d.ts +0 -3
- package/dist/commands/validate.d.ts.map +0 -1
- package/dist/commands/validate.js +0 -86
- package/dist/commands/validate.js.map +0 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -8
- package/dist/index.js.map +0 -1
- package/dist/schemas/config.d.ts +0 -17
- package/dist/schemas/config.d.ts.map +0 -1
- package/dist/schemas/config.js +0 -16
- package/dist/schemas/config.js.map +0 -1
- package/dist/schemas/index.d.ts +0 -5
- package/dist/schemas/index.d.ts.map +0 -1
- package/dist/schemas/index.js +0 -3
- package/dist/schemas/index.js.map +0 -1
- package/dist/schemas/record-schema.d.ts +0 -379
- package/dist/schemas/record-schema.d.ts.map +0 -1
- package/dist/schemas/record-schema.js +0 -148
- package/dist/schemas/record-schema.js.map +0 -1
- package/dist/schemas/record.d.ts +0 -60
- package/dist/schemas/record.d.ts.map +0 -1
- package/dist/schemas/record.js +0 -2
- package/dist/schemas/record.js.map +0 -1
- package/dist/utils/bm25.d.ts +0 -39
- package/dist/utils/bm25.d.ts.map +0 -1
- package/dist/utils/bm25.js +0 -171
- package/dist/utils/bm25.js.map +0 -1
- package/dist/utils/budget.d.ts +0 -35
- package/dist/utils/budget.d.ts.map +0 -1
- package/dist/utils/budget.js +0 -114
- package/dist/utils/budget.js.map +0 -1
- package/dist/utils/config.d.ts +0 -12
- package/dist/utils/config.d.ts.map +0 -1
- package/dist/utils/config.js +0 -89
- package/dist/utils/config.js.map +0 -1
- package/dist/utils/expertise.d.ts +0 -57
- package/dist/utils/expertise.d.ts.map +0 -1
- package/dist/utils/expertise.js +0 -264
- package/dist/utils/expertise.js.map +0 -1
- package/dist/utils/format.d.ts +0 -31
- package/dist/utils/format.d.ts.map +0 -1
- package/dist/utils/format.js +0 -556
- package/dist/utils/format.js.map +0 -1
- package/dist/utils/git.d.ts +0 -6
- package/dist/utils/git.d.ts.map +0 -1
- package/dist/utils/git.js +0 -81
- package/dist/utils/git.js.map +0 -1
- package/dist/utils/index.d.ts +0 -8
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -8
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/json-output.d.ts +0 -8
- package/dist/utils/json-output.d.ts.map +0 -1
- package/dist/utils/json-output.js +0 -7
- package/dist/utils/json-output.js.map +0 -1
- package/dist/utils/lock.d.ts +0 -6
- package/dist/utils/lock.d.ts.map +0 -1
- package/dist/utils/lock.js +0 -70
- package/dist/utils/lock.js.map +0 -1
- package/dist/utils/markers.d.ts +0 -22
- package/dist/utils/markers.d.ts.map +0 -1
- package/dist/utils/markers.js +0 -42
- package/dist/utils/markers.js.map +0 -1
- package/dist/utils/scoring.d.ts +0 -73
- package/dist/utils/scoring.d.ts.map +0 -1
- package/dist/utils/scoring.js +0 -80
- package/dist/utils/scoring.js.map +0 -1
- package/dist/utils/version.d.ts +0 -15
- package/dist/utils/version.d.ts.map +0 -1
- package/dist/utils/version.js +0 -48
- package/dist/utils/version.js.map +0 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
import type { MulchConfig } from "../schemas/config.ts";
|
|
6
|
+
import { DEFAULT_CONFIG } from "../schemas/config.ts";
|
|
7
|
+
|
|
8
|
+
const MULCH_DIR = ".mulch";
|
|
9
|
+
const CONFIG_FILE = "mulch.config.yaml";
|
|
10
|
+
const EXPERTISE_DIR = "expertise";
|
|
11
|
+
|
|
12
|
+
export const GITATTRIBUTES_LINE = ".mulch/expertise/*.jsonl merge=union";
|
|
13
|
+
|
|
14
|
+
export const MULCH_README = `# .mulch/
|
|
15
|
+
|
|
16
|
+
This directory is managed by [mulch](https://github.com/jayminwest/mulch) — a structured expertise layer for coding agents.
|
|
17
|
+
|
|
18
|
+
## Key Commands
|
|
19
|
+
|
|
20
|
+
- \`mulch init\` — Initialize a .mulch directory
|
|
21
|
+
- \`mulch add\` — Add a new domain
|
|
22
|
+
- \`mulch record\` — Record an expertise record
|
|
23
|
+
- \`mulch edit\` — Edit an existing record
|
|
24
|
+
- \`mulch query\` — Query expertise records
|
|
25
|
+
- \`mulch prime [domain]\` — Output a priming prompt (optionally scoped to one domain)
|
|
26
|
+
- \`mulch search\` — Search records across domains
|
|
27
|
+
- \`mulch status\` — Show domain statistics
|
|
28
|
+
- \`mulch validate\` — Validate all records against the schema
|
|
29
|
+
- \`mulch prune\` — Remove expired records
|
|
30
|
+
|
|
31
|
+
## Structure
|
|
32
|
+
|
|
33
|
+
- \`mulch.config.yaml\` — Configuration file
|
|
34
|
+
- \`expertise/\` — JSONL files, one per domain
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export function getMulchDir(cwd: string = process.cwd()): string {
|
|
38
|
+
return join(cwd, MULCH_DIR);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getConfigPath(cwd: string = process.cwd()): string {
|
|
42
|
+
return join(getMulchDir(cwd), CONFIG_FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getExpertiseDir(cwd: string = process.cwd()): string {
|
|
46
|
+
return join(getMulchDir(cwd), EXPERTISE_DIR);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateDomainName(domain: string): void {
|
|
50
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(domain)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Invalid domain name: "${domain}". Only alphanumeric characters, hyphens, and underscores are allowed.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getExpertisePath(
|
|
58
|
+
domain: string,
|
|
59
|
+
cwd: string = process.cwd(),
|
|
60
|
+
): string {
|
|
61
|
+
validateDomainName(domain);
|
|
62
|
+
return join(getExpertiseDir(cwd), `${domain}.jsonl`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function readConfig(
|
|
66
|
+
cwd: string = process.cwd(),
|
|
67
|
+
): Promise<MulchConfig> {
|
|
68
|
+
const configPath = getConfigPath(cwd);
|
|
69
|
+
const content = await readFile(configPath, "utf-8");
|
|
70
|
+
return yaml.load(content) as MulchConfig;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function writeConfig(
|
|
74
|
+
config: MulchConfig,
|
|
75
|
+
cwd: string = process.cwd(),
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const configPath = getConfigPath(cwd);
|
|
78
|
+
const content = yaml.dump(config, { lineWidth: -1 });
|
|
79
|
+
await writeFile(configPath, content, "utf-8");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function initMulchDir(cwd: string = process.cwd()): Promise<void> {
|
|
83
|
+
const mulchDir = getMulchDir(cwd);
|
|
84
|
+
const expertiseDir = getExpertiseDir(cwd);
|
|
85
|
+
await mkdir(mulchDir, { recursive: true });
|
|
86
|
+
await mkdir(expertiseDir, { recursive: true });
|
|
87
|
+
|
|
88
|
+
// Only write default config if none exists — preserve user customizations
|
|
89
|
+
const configPath = getConfigPath(cwd);
|
|
90
|
+
if (!existsSync(configPath)) {
|
|
91
|
+
await writeConfig({ ...DEFAULT_CONFIG }, cwd);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create or append .gitattributes with merge=union for JSONL files
|
|
95
|
+
const gitattributesPath = join(cwd, ".gitattributes");
|
|
96
|
+
let existing = "";
|
|
97
|
+
try {
|
|
98
|
+
existing = await readFile(gitattributesPath, "utf-8");
|
|
99
|
+
} catch {
|
|
100
|
+
// File doesn't exist yet — will create it
|
|
101
|
+
}
|
|
102
|
+
if (!existing.includes(GITATTRIBUTES_LINE)) {
|
|
103
|
+
const separator =
|
|
104
|
+
existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
|
|
105
|
+
await writeFile(
|
|
106
|
+
gitattributesPath,
|
|
107
|
+
`${existing + separator + GITATTRIBUTES_LINE}\n`,
|
|
108
|
+
"utf-8",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Create .mulch/README.md if missing
|
|
113
|
+
const readmePath = join(mulchDir, "README.md");
|
|
114
|
+
if (!existsSync(readmePath)) {
|
|
115
|
+
await writeFile(readmePath, MULCH_README, "utf-8");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
appendFile,
|
|
4
|
+
readFile,
|
|
5
|
+
rename,
|
|
6
|
+
stat,
|
|
7
|
+
unlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import type {
|
|
11
|
+
Classification,
|
|
12
|
+
ExpertiseRecord,
|
|
13
|
+
RecordType,
|
|
14
|
+
} from "../schemas/record.ts";
|
|
15
|
+
import { DEFAULT_BM25_PARAMS, searchBM25 } from "./bm25.ts";
|
|
16
|
+
|
|
17
|
+
export async function readExpertiseFile(
|
|
18
|
+
filePath: string,
|
|
19
|
+
): Promise<ExpertiseRecord[]> {
|
|
20
|
+
let content: string;
|
|
21
|
+
try {
|
|
22
|
+
content = await readFile(filePath, "utf-8");
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const records: ExpertiseRecord[] = [];
|
|
28
|
+
const lines = content.split("\n").filter((line) => line.trim().length > 0);
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const raw = JSON.parse(line) as Record<string, unknown>;
|
|
31
|
+
// Normalize legacy outcome (singular) to outcomes (array) for backward compat
|
|
32
|
+
if (
|
|
33
|
+
"outcome" in raw &&
|
|
34
|
+
raw.outcome !== null &&
|
|
35
|
+
raw.outcome !== undefined &&
|
|
36
|
+
!("outcomes" in raw)
|
|
37
|
+
) {
|
|
38
|
+
const legacy = raw.outcome as Record<string, unknown>;
|
|
39
|
+
raw.outcomes = [
|
|
40
|
+
{
|
|
41
|
+
status: legacy.status,
|
|
42
|
+
...(legacy.duration !== undefined
|
|
43
|
+
? { duration: legacy.duration }
|
|
44
|
+
: {}),
|
|
45
|
+
...(legacy.test_results !== undefined
|
|
46
|
+
? { test_results: legacy.test_results }
|
|
47
|
+
: {}),
|
|
48
|
+
...(legacy.agent !== undefined ? { agent: legacy.agent } : {}),
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
raw.outcome = undefined;
|
|
52
|
+
}
|
|
53
|
+
records.push(raw as unknown as ExpertiseRecord);
|
|
54
|
+
}
|
|
55
|
+
return records;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function generateRecordId(record: ExpertiseRecord): string {
|
|
59
|
+
let key: string;
|
|
60
|
+
switch (record.type) {
|
|
61
|
+
case "convention":
|
|
62
|
+
key = `convention:${record.content}`;
|
|
63
|
+
break;
|
|
64
|
+
case "pattern":
|
|
65
|
+
key = `pattern:${record.name}`;
|
|
66
|
+
break;
|
|
67
|
+
case "failure":
|
|
68
|
+
key = `failure:${record.description}`;
|
|
69
|
+
break;
|
|
70
|
+
case "decision":
|
|
71
|
+
key = `decision:${record.title}`;
|
|
72
|
+
break;
|
|
73
|
+
case "reference":
|
|
74
|
+
key = `reference:${record.name}`;
|
|
75
|
+
break;
|
|
76
|
+
case "guide":
|
|
77
|
+
key = `guide:${record.name}`;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
return `mx-${createHash("sha256").update(key).digest("hex").slice(0, 6)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function appendRecord(
|
|
84
|
+
filePath: string,
|
|
85
|
+
record: ExpertiseRecord,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
if (!record.id) {
|
|
88
|
+
record.id = generateRecordId(record);
|
|
89
|
+
}
|
|
90
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
91
|
+
await appendFile(filePath, line, "utf-8");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function createExpertiseFile(filePath: string): Promise<void> {
|
|
95
|
+
await writeFile(filePath, "", "utf-8");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function getFileModTime(filePath: string): Promise<Date | null> {
|
|
99
|
+
try {
|
|
100
|
+
const stats = await stat(filePath);
|
|
101
|
+
return stats.mtime;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function writeExpertiseFile(
|
|
108
|
+
filePath: string,
|
|
109
|
+
records: ExpertiseRecord[],
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
for (const r of records) {
|
|
112
|
+
if (!r.id) {
|
|
113
|
+
r.id = generateRecordId(r);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const content =
|
|
117
|
+
records.map((r) => JSON.stringify(r)).join("\n") +
|
|
118
|
+
(records.length > 0 ? "\n" : "");
|
|
119
|
+
const tmpPath = `${filePath}.tmp.${randomBytes(8).toString("hex")}`;
|
|
120
|
+
await writeFile(tmpPath, content, "utf-8");
|
|
121
|
+
try {
|
|
122
|
+
await rename(tmpPath, filePath);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
try {
|
|
125
|
+
await unlink(tmpPath);
|
|
126
|
+
} catch {
|
|
127
|
+
/* best-effort cleanup */
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function countRecords(records: ExpertiseRecord[]): number {
|
|
134
|
+
return records.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function filterByType(
|
|
138
|
+
records: ExpertiseRecord[],
|
|
139
|
+
type: string,
|
|
140
|
+
): ExpertiseRecord[] {
|
|
141
|
+
return records.filter((r) => r.type === type);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function filterByClassification(
|
|
145
|
+
records: ExpertiseRecord[],
|
|
146
|
+
classification: string,
|
|
147
|
+
): ExpertiseRecord[] {
|
|
148
|
+
return records.filter((r) => r.classification === classification);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function filterByFile(
|
|
152
|
+
records: ExpertiseRecord[],
|
|
153
|
+
file: string,
|
|
154
|
+
): ExpertiseRecord[] {
|
|
155
|
+
const fileLower = file.toLowerCase();
|
|
156
|
+
return records.filter((r) => {
|
|
157
|
+
if ("files" in r && r.files) {
|
|
158
|
+
return r.files.some((f) => f.toLowerCase().includes(fileLower));
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function findDuplicate(
|
|
165
|
+
existing: ExpertiseRecord[],
|
|
166
|
+
newRecord: ExpertiseRecord,
|
|
167
|
+
): { index: number; record: ExpertiseRecord } | null {
|
|
168
|
+
for (let i = 0; i < existing.length; i++) {
|
|
169
|
+
const record = existing[i];
|
|
170
|
+
if (record.type !== newRecord.type) continue;
|
|
171
|
+
|
|
172
|
+
switch (record.type) {
|
|
173
|
+
case "pattern":
|
|
174
|
+
if (newRecord.type === "pattern" && record.name === newRecord.name) {
|
|
175
|
+
return { index: i, record };
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
case "decision":
|
|
179
|
+
if (newRecord.type === "decision" && record.title === newRecord.title) {
|
|
180
|
+
return { index: i, record };
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
case "convention":
|
|
184
|
+
if (
|
|
185
|
+
newRecord.type === "convention" &&
|
|
186
|
+
record.content === newRecord.content
|
|
187
|
+
) {
|
|
188
|
+
return { index: i, record };
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
case "failure":
|
|
192
|
+
if (
|
|
193
|
+
newRecord.type === "failure" &&
|
|
194
|
+
record.description === newRecord.description
|
|
195
|
+
) {
|
|
196
|
+
return { index: i, record };
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
case "reference":
|
|
200
|
+
if (newRecord.type === "reference" && record.name === newRecord.name) {
|
|
201
|
+
return { index: i, record };
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
case "guide":
|
|
205
|
+
if (newRecord.type === "guide" && record.name === newRecord.name) {
|
|
206
|
+
return { index: i, record };
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type ResolveResult =
|
|
215
|
+
| { ok: true; index: number; record: ExpertiseRecord }
|
|
216
|
+
| { ok: false; error: string };
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolve an identifier to a record within a domain.
|
|
220
|
+
* Accepts: full ID (mx-abc123), bare hash (abc123), or prefix (abc / mx-abc).
|
|
221
|
+
* Returns the unique matching record or an error if not found / ambiguous.
|
|
222
|
+
*/
|
|
223
|
+
export function resolveRecordId(
|
|
224
|
+
records: ExpertiseRecord[],
|
|
225
|
+
identifier: string,
|
|
226
|
+
): ResolveResult {
|
|
227
|
+
// Normalize: strip mx- prefix if present to get the hash part
|
|
228
|
+
const hash = identifier.startsWith("mx-") ? identifier.slice(3) : identifier;
|
|
229
|
+
|
|
230
|
+
// Try exact match first
|
|
231
|
+
const exactIndex = records.findIndex((r) => r.id === `mx-${hash}`);
|
|
232
|
+
if (exactIndex !== -1) {
|
|
233
|
+
return { ok: true, index: exactIndex, record: records[exactIndex] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Try prefix match
|
|
237
|
+
const matches: Array<{ index: number; record: ExpertiseRecord }> = [];
|
|
238
|
+
for (let i = 0; i < records.length; i++) {
|
|
239
|
+
const rid = records[i].id;
|
|
240
|
+
if (rid?.startsWith(`mx-${hash}`)) {
|
|
241
|
+
matches.push({ index: i, record: records[i] });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (matches.length === 1) {
|
|
246
|
+
return { ok: true, index: matches[0].index, record: matches[0].record };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (matches.length > 1) {
|
|
250
|
+
const ids = matches.map((m) => m.record.id).join(", ");
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
error: `Ambiguous identifier "${identifier}" matches ${matches.length} records: ${ids}. Use more characters to disambiguate.`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
error: `Record "${identifier}" not found. Run \`mulch query\` to see record IDs.`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Search records using BM25 ranking algorithm.
|
|
265
|
+
* Returns records sorted by relevance (highest score first).
|
|
266
|
+
*/
|
|
267
|
+
export function searchRecords(
|
|
268
|
+
records: ExpertiseRecord[],
|
|
269
|
+
query: string,
|
|
270
|
+
): ExpertiseRecord[] {
|
|
271
|
+
const results = searchBM25(records, query, DEFAULT_BM25_PARAMS);
|
|
272
|
+
return results.map((r) => r.record);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface DomainHealth {
|
|
276
|
+
governance_utilization: number;
|
|
277
|
+
stale_count: number;
|
|
278
|
+
type_distribution: Record<RecordType, number>;
|
|
279
|
+
classification_distribution: Record<Classification, number>;
|
|
280
|
+
oldest_timestamp: string | null;
|
|
281
|
+
newest_timestamp: string | null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Check if a record is stale based on classification and shelf life.
|
|
286
|
+
*/
|
|
287
|
+
export function isRecordStale(
|
|
288
|
+
record: ExpertiseRecord,
|
|
289
|
+
now: Date,
|
|
290
|
+
shelfLife: { tactical: number; observational: number },
|
|
291
|
+
): boolean {
|
|
292
|
+
const classification: Classification = record.classification;
|
|
293
|
+
|
|
294
|
+
if (classification === "foundational") {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const recordedAt = new Date(record.recorded_at);
|
|
299
|
+
const ageInDays = Math.floor(
|
|
300
|
+
(now.getTime() - recordedAt.getTime()) / (1000 * 60 * 60 * 24),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (classification === "tactical") {
|
|
304
|
+
return ageInDays > shelfLife.tactical;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (classification === "observational") {
|
|
308
|
+
return ageInDays > shelfLife.observational;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Calculate comprehensive health metrics for a domain.
|
|
316
|
+
*/
|
|
317
|
+
export function calculateDomainHealth(
|
|
318
|
+
records: ExpertiseRecord[],
|
|
319
|
+
maxEntries: number,
|
|
320
|
+
shelfLife: { tactical: number; observational: number },
|
|
321
|
+
): DomainHealth {
|
|
322
|
+
const now = new Date();
|
|
323
|
+
|
|
324
|
+
// Initialize distributions
|
|
325
|
+
const typeDistribution: Record<RecordType, number> = {
|
|
326
|
+
convention: 0,
|
|
327
|
+
pattern: 0,
|
|
328
|
+
failure: 0,
|
|
329
|
+
decision: 0,
|
|
330
|
+
reference: 0,
|
|
331
|
+
guide: 0,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const classificationDistribution: Record<Classification, number> = {
|
|
335
|
+
foundational: 0,
|
|
336
|
+
tactical: 0,
|
|
337
|
+
observational: 0,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
let staleCount = 0;
|
|
341
|
+
let oldestTimestamp: string | null = null;
|
|
342
|
+
let newestTimestamp: string | null = null;
|
|
343
|
+
|
|
344
|
+
// Calculate metrics
|
|
345
|
+
for (const record of records) {
|
|
346
|
+
// Type distribution
|
|
347
|
+
typeDistribution[record.type]++;
|
|
348
|
+
|
|
349
|
+
// Classification distribution
|
|
350
|
+
classificationDistribution[record.classification]++;
|
|
351
|
+
|
|
352
|
+
// Stale count
|
|
353
|
+
if (isRecordStale(record, now, shelfLife)) {
|
|
354
|
+
staleCount++;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Oldest/newest timestamps
|
|
358
|
+
const recordedAt = record.recorded_at;
|
|
359
|
+
if (!oldestTimestamp || recordedAt < oldestTimestamp) {
|
|
360
|
+
oldestTimestamp = recordedAt;
|
|
361
|
+
}
|
|
362
|
+
if (!newestTimestamp || recordedAt > newestTimestamp) {
|
|
363
|
+
newestTimestamp = recordedAt;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Governance utilization (as percentage, 0-100)
|
|
368
|
+
const governanceUtilization =
|
|
369
|
+
maxEntries > 0 ? Math.round((records.length / maxEntries) * 100) : 0;
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
governance_utilization: governanceUtilization,
|
|
373
|
+
stale_count: staleCount,
|
|
374
|
+
type_distribution: typeDistribution,
|
|
375
|
+
classification_distribution: classificationDistribution,
|
|
376
|
+
oldest_timestamp: oldestTimestamp,
|
|
377
|
+
newest_timestamp: newestTimestamp,
|
|
378
|
+
};
|
|
379
|
+
}
|