pi-jev-wiki 0.2.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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/package.json +63 -0
- package/skills/llm-wiki/SKILL.md +143 -0
- package/skills/llm-wiki/references/decision.md +32 -0
- package/skills/llm-wiki/references/flow.md +32 -0
- package/skills/llm-wiki/references/gotcha.md +16 -0
- package/skills/llm-wiki/references/invariant.md +20 -0
- package/skills/llm-wiki/references/module.md +30 -0
- package/src/config.ts +168 -0
- package/src/doctor.ts +171 -0
- package/src/extension.ts +1674 -0
- package/src/git.ts +69 -0
- package/src/grounding.ts +46 -0
- package/src/jev.ts +207 -0
- package/src/ledger.ts +85 -0
- package/src/lint.ts +407 -0
- package/src/metrics.ts +61 -0
- package/src/pipeline/adjudicate.ts +416 -0
- package/src/pipeline/capture.ts +109 -0
- package/src/pipeline/extract.ts +150 -0
- package/src/pipeline/write.ts +263 -0
- package/src/provenance.ts +137 -0
- package/src/redact.ts +46 -0
- package/src/review.ts +146 -0
- package/src/sessionlog.ts +107 -0
- package/src/structure.ts +215 -0
- package/src/sync.ts +295 -0
- package/src/wiki/frontmatter.ts +164 -0
- package/src/wiki/layout.ts +126 -0
- package/src/wiki/links.ts +17 -0
- package/src/wiki/lock.ts +86 -0
- package/src/wiki/search.ts +263 -0
- package/src/wiki/toc.ts +198 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for jev-wiki.
|
|
3
|
+
*
|
|
4
|
+
* Precedence: CLI overrides > process env > project .env > project config > global config > defaults.
|
|
5
|
+
* The API key is resolved from `apiKey` (`$VAR` indirection) with fallbacks to
|
|
6
|
+
* TYPESAFE_API_KEY and JEV_TOKEN.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export type WriterMode = "guided" | "draft" | "auto";
|
|
13
|
+
export type ReviewMode = "agent" | "human";
|
|
14
|
+
|
|
15
|
+
export interface ProviderPreset {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
model: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const PROVIDER_PRESETS: Record<string, ProviderPreset> = {
|
|
21
|
+
typesafe: { baseUrl: "https://api.typesafe.ai/v1/systemone", model: "jev-latest" },
|
|
22
|
+
openrouter: { baseUrl: "https://openrouter.ai/api/alpha/decisions", model: "~typesafe/jev-latest" },
|
|
23
|
+
aimlapi: { baseUrl: "https://api.aimlapi.com/v1/decisions", model: "typesafe/jev" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface JevWikiConfig {
|
|
27
|
+
provider: string;
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
model?: string;
|
|
30
|
+
apiKey?: string;
|
|
31
|
+
envFile: string;
|
|
32
|
+
wikiRoot: string;
|
|
33
|
+
globalWikiRoot?: string;
|
|
34
|
+
stateRoot: string;
|
|
35
|
+
writer: { mode: WriterMode; model?: string | null };
|
|
36
|
+
routing: { shardSize: number; minFit: number; newPageConfidence: number };
|
|
37
|
+
review: { mode: ReviewMode; escalateCriticality: number; maxPerSession: number };
|
|
38
|
+
sync: { onSessionStart: "off" | "check"; onCommit: boolean; backstopLintDays: number };
|
|
39
|
+
thresholds: {
|
|
40
|
+
autoAccept: number;
|
|
41
|
+
minSupport: number;
|
|
42
|
+
minDerivable: number;
|
|
43
|
+
minNovelty: number;
|
|
44
|
+
minImportance: number;
|
|
45
|
+
};
|
|
46
|
+
weights: { grounded: number; importance: number; nonDerivable: number; authority: number };
|
|
47
|
+
toc: { maxTokens: number };
|
|
48
|
+
lint: { orphanMinAgeDays: number; duplicateSimilarity: number };
|
|
49
|
+
gitCommit: boolean;
|
|
50
|
+
capture: { onCompact: boolean; onSettle: boolean };
|
|
51
|
+
search: { engine: "index" | "bm25" | "qmd"; qmdCollection?: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ResolvedConfig extends Omit<JevWikiConfig, "baseUrl" | "model"> {
|
|
55
|
+
baseUrl: string;
|
|
56
|
+
model: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const DEFAULT_CONFIG: JevWikiConfig = {
|
|
60
|
+
provider: "typesafe",
|
|
61
|
+
envFile: ".env",
|
|
62
|
+
wikiRoot: "docs/wiki",
|
|
63
|
+
stateRoot: ".jev-wiki",
|
|
64
|
+
writer: { mode: "guided", model: null },
|
|
65
|
+
routing: { shardSize: 250, minFit: 0.6, newPageConfidence: 0.7 },
|
|
66
|
+
review: { mode: "agent", escalateCriticality: 0.85, maxPerSession: 10 },
|
|
67
|
+
sync: { onSessionStart: "check", onCommit: false, backstopLintDays: 14 },
|
|
68
|
+
thresholds: { autoAccept: 0.8, minSupport: 0.7, minDerivable: 0.5, minNovelty: 0.6, minImportance: 1 },
|
|
69
|
+
weights: { grounded: 0.45, importance: 0.25, nonDerivable: 0.2, authority: 0.1 },
|
|
70
|
+
toc: { maxTokens: 3000 },
|
|
71
|
+
lint: { orphanMinAgeDays: 7, duplicateSimilarity: 0.72 },
|
|
72
|
+
gitCommit: false,
|
|
73
|
+
capture: { onCompact: false, onSettle: false },
|
|
74
|
+
search: { engine: "index" },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface LoadedConfig {
|
|
78
|
+
cwd: string;
|
|
79
|
+
agentDir: string;
|
|
80
|
+
projectConfigPath: string;
|
|
81
|
+
globalConfigPath: string;
|
|
82
|
+
envFilePath: string;
|
|
83
|
+
env: Record<string, string>;
|
|
84
|
+
config: ResolvedConfig;
|
|
85
|
+
apiKey?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readJson(path: string): Record<string, unknown> | undefined {
|
|
89
|
+
try {
|
|
90
|
+
if (!existsSync(path)) return undefined;
|
|
91
|
+
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseEnvFile(text: string): Record<string, string> {
|
|
98
|
+
const env: Record<string, string> = {};
|
|
99
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
100
|
+
const line = rawLine.trim();
|
|
101
|
+
if (!line || line.startsWith("#")) continue;
|
|
102
|
+
const eq = line.indexOf("=");
|
|
103
|
+
if (eq <= 0) continue;
|
|
104
|
+
const key = line.slice(0, eq).trim();
|
|
105
|
+
let value = line.slice(eq + 1).trim();
|
|
106
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
107
|
+
value = value.slice(1, -1);
|
|
108
|
+
}
|
|
109
|
+
if (key) env[key] = value;
|
|
110
|
+
}
|
|
111
|
+
return env;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function deepMerge<T>(base: T, patch: unknown): T {
|
|
115
|
+
if (patch === undefined || patch === null) return base;
|
|
116
|
+
if (Array.isArray(base) || typeof base !== "object") return patch as T;
|
|
117
|
+
if (typeof patch !== "object" || Array.isArray(patch)) return patch as T;
|
|
118
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
119
|
+
for (const [key, value] of Object.entries(patch as Record<string, unknown>)) {
|
|
120
|
+
out[key] = deepMerge((base as Record<string, unknown>)[key], value);
|
|
121
|
+
}
|
|
122
|
+
return out as T;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveEnvValue(value: string | undefined, env: Record<string, string>): string | undefined {
|
|
126
|
+
if (!value) return undefined;
|
|
127
|
+
if (!value.startsWith("$")) return value;
|
|
128
|
+
const name = value.slice(1);
|
|
129
|
+
return process.env[name] ?? env[name];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function loadConfig(cwd: string, overrides?: Partial<JevWikiConfig>): LoadedConfig {
|
|
133
|
+
const agentDir = getAgentDir();
|
|
134
|
+
const globalConfigPath = join(agentDir, "jev-wiki.json");
|
|
135
|
+
const projectConfigPath = join(cwd, CONFIG_DIR_NAME, "jev-wiki.json");
|
|
136
|
+
|
|
137
|
+
const globalFile = readJson(globalConfigPath) ?? {};
|
|
138
|
+
const projectFile = readJson(projectConfigPath) ?? {};
|
|
139
|
+
|
|
140
|
+
let merged = deepMerge(DEFAULT_CONFIG, globalFile);
|
|
141
|
+
merged = deepMerge(merged, projectFile);
|
|
142
|
+
if (overrides) merged = deepMerge(merged, overrides);
|
|
143
|
+
|
|
144
|
+
const envFilePath = resolve(cwd, merged.envFile);
|
|
145
|
+
const env = existsSync(envFilePath) ? parseEnvFile(readFileSync(envFilePath, "utf8")) : {};
|
|
146
|
+
|
|
147
|
+
const preset = PROVIDER_PRESETS[merged.provider] ?? PROVIDER_PRESETS.typesafe;
|
|
148
|
+
const resolved: ResolvedConfig = {
|
|
149
|
+
...merged,
|
|
150
|
+
baseUrl: merged.baseUrl ?? preset.baseUrl,
|
|
151
|
+
model: merged.model ?? preset.model,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const configured = resolveEnvValue(merged.apiKey, env);
|
|
155
|
+
const providerVars: Record<string, string[]> = {
|
|
156
|
+
typesafe: ["TYPESAFE_API_KEY", "JEV_TOKEN"],
|
|
157
|
+
openrouter: ["OPENROUTER_API_KEY", "JEV_TOKEN", "TYPESAFE_API_KEY"],
|
|
158
|
+
aimlapi: ["AIMLAPI_API_KEY", "JEV_TOKEN"],
|
|
159
|
+
};
|
|
160
|
+
const candidates = providerVars[merged.provider] ?? ["JEV_TOKEN", "TYPESAFE_API_KEY", "OPENROUTER_API_KEY"];
|
|
161
|
+
let apiKey = configured;
|
|
162
|
+
for (const name of candidates) {
|
|
163
|
+
apiKey ??= process.env[name] ?? env[name];
|
|
164
|
+
}
|
|
165
|
+
apiKey ??= env.OPENROUTER_API_KEY ?? process.env.OPENROUTER_API_KEY;
|
|
166
|
+
|
|
167
|
+
return { cwd, agentDir, projectConfigPath, globalConfigPath, envFilePath, env, config: resolved, apiKey };
|
|
168
|
+
}
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor: cheap, deterministic health checks for configuration, state, and
|
|
3
|
+
* environment. No model calls. Intended to be run before trusting a wiki or
|
|
4
|
+
* after moving it to a new machine/project.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import type { LoadedConfig } from "./config.ts";
|
|
11
|
+
import { git, headCommit, isGitRepo } from "./git.ts";
|
|
12
|
+
import { readLedger } from "./ledger.ts";
|
|
13
|
+
import { readReviews } from "./review.ts";
|
|
14
|
+
import { readSyncState } from "./sync.ts";
|
|
15
|
+
import { listMarkdownFiles, resolveLayout, type WikiLayout } from "./wiki/layout.ts";
|
|
16
|
+
import { isLocked } from "./wiki/lock.ts";
|
|
17
|
+
|
|
18
|
+
const run = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
export interface DoctorCheck {
|
|
21
|
+
name: string;
|
|
22
|
+
status: "ok" | "warn" | "fail";
|
|
23
|
+
detail: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DoctorReport {
|
|
27
|
+
checks: DoctorCheck[];
|
|
28
|
+
summary: { ok: number; warn: number; fail: number };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function check(name: string, status: DoctorCheck["status"], detail: string): DoctorCheck {
|
|
32
|
+
return { name, status, detail };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function within(value: number, min: number, max: number): boolean {
|
|
36
|
+
return Number.isFinite(value) && value >= min && value <= max;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runDoctor(loaded: LoadedConfig): Promise<DoctorReport> {
|
|
40
|
+
const checks: DoctorCheck[] = [];
|
|
41
|
+
const { config } = loaded;
|
|
42
|
+
const layout = resolveLayout(loaded.cwd, config.wikiRoot, config.stateRoot);
|
|
43
|
+
|
|
44
|
+
// --- configuration --------------------------------------------------------
|
|
45
|
+
const invalid: string[] = [];
|
|
46
|
+
for (const key of ["autoAccept", "minSupport", "minDerivable", "minNovelty"] as const) {
|
|
47
|
+
if (!within(config.thresholds[key], 0, 1)) invalid.push(`thresholds.${key}=${config.thresholds[key]}`);
|
|
48
|
+
}
|
|
49
|
+
if (config.thresholds.minImportance < 0 || config.thresholds.minImportance > 3) invalid.push(`thresholds.minImportance=${config.thresholds.minImportance}`);
|
|
50
|
+
if (!within(config.routing.minFit, 0, 1)) invalid.push(`routing.minFit=${config.routing.minFit}`);
|
|
51
|
+
if (!within(config.routing.newPageConfidence, 0, 1)) invalid.push(`routing.newPageConfidence=${config.routing.newPageConfidence}`);
|
|
52
|
+
if (!within(config.review.escalateCriticality, 0, 1)) invalid.push(`review.escalateCriticality=${config.review.escalateCriticality}`);
|
|
53
|
+
if (!within(config.lint.duplicateSimilarity, 0, 1)) invalid.push(`lint.duplicateSimilarity=${config.lint.duplicateSimilarity}`);
|
|
54
|
+
if (config.routing.shardSize < 10 || config.routing.shardSize > 255) invalid.push(`routing.shardSize=${config.routing.shardSize} (must be 10..255)`);
|
|
55
|
+
if (!["index", "bm25", "qmd"].includes(config.search.engine)) invalid.push(`search.engine=${config.search.engine}`);
|
|
56
|
+
checks.push(
|
|
57
|
+
invalid.length === 0
|
|
58
|
+
? check("config values", "ok", `provider ${config.provider}, writer ${config.writer.mode}, shard ${config.routing.shardSize}`)
|
|
59
|
+
: check("config values", "fail", `invalid: ${invalid.join(", ")}`),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
let urlValid = false;
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(config.baseUrl);
|
|
65
|
+
urlValid = url.protocol === "https:" || url.protocol === "http:";
|
|
66
|
+
} catch {
|
|
67
|
+
urlValid = false;
|
|
68
|
+
}
|
|
69
|
+
checks.push(urlValid ? check("endpoint", "ok", config.baseUrl) : check("endpoint", "fail", `not a valid URL: ${config.baseUrl}`));
|
|
70
|
+
checks.push(
|
|
71
|
+
loaded.apiKey
|
|
72
|
+
? check("api key", "ok", `configured (${loaded.apiKey.slice(0, 5)}…, ${loaded.envFilePath})`)
|
|
73
|
+
: check(
|
|
74
|
+
"api key",
|
|
75
|
+
"fail",
|
|
76
|
+
`missing; run wiki_setup action=guide provider=typesafe|openrouter — or add TYPESAFE_API_KEY / OPENROUTER_API_KEY / JEV_TOKEN to ${loaded.envFilePath}`,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
checks.push(
|
|
80
|
+
existsSync(loaded.envFilePath)
|
|
81
|
+
? check("env file", "ok", loaded.envFilePath)
|
|
82
|
+
: check("env file", "warn", `not found: ${loaded.envFilePath}`),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// --- layout and state -----------------------------------------------------
|
|
86
|
+
checks.push(
|
|
87
|
+
existsSync(layout.root)
|
|
88
|
+
? check("wiki root", "ok", layout.root)
|
|
89
|
+
: check("wiki root", "warn", `does not exist yet (created on first ingest): ${layout.root}`),
|
|
90
|
+
);
|
|
91
|
+
if (existsSync(layout.wikiDir)) {
|
|
92
|
+
const pages = (await listMarkdownFiles(layout.wikiDir)).length;
|
|
93
|
+
checks.push(check("wiki pages", "ok", `${pages} page file(s); index ${existsSync(`${layout.wikiDir}/index.md`) ? "present" : "MISSING"}, toc ${existsSync(`${layout.wikiDir}/toc.md`) ? "present" : "missing"}`));
|
|
94
|
+
}
|
|
95
|
+
const lock = await isLocked(layout);
|
|
96
|
+
if (lock.locked) {
|
|
97
|
+
checks.push(lock.stale ? check("wiki lock", "warn", `stale lock (${Math.round((lock.ageMs ?? 0) / 1000)}s old); next mutation takes it over`) : check("wiki lock", "warn", `held by another session (${Math.round((lock.ageMs ?? 0) / 1000)}s)`));
|
|
98
|
+
} else {
|
|
99
|
+
checks.push(check("wiki lock", "ok", "free"));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- ledger and queue -----------------------------------------------------
|
|
103
|
+
if (existsSync(layout.ledgerPath)) {
|
|
104
|
+
let bad = 0;
|
|
105
|
+
const lines = (await readFile(layout.ledgerPath, "utf8")).split(/\r?\n/).filter(Boolean);
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
try {
|
|
108
|
+
JSON.parse(line);
|
|
109
|
+
} catch {
|
|
110
|
+
bad++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
checks.push(bad === 0 ? check("ledger", "ok", `${lines.length} entries, all parse`) : check("ledger", "warn", `${bad} unparsable line(s) of ${lines.length}`));
|
|
114
|
+
} else {
|
|
115
|
+
checks.push(check("ledger", "ok", "no ledger yet"));
|
|
116
|
+
}
|
|
117
|
+
const reviews = await readReviews(layout);
|
|
118
|
+
const open = reviews.filter((item) => item.status === "open");
|
|
119
|
+
if (open.length > 10) {
|
|
120
|
+
const oldest = open.reduce((oldestDate, item) => (item.ts < oldestDate ? item.ts : oldestDate), open[0].ts);
|
|
121
|
+
checks.push(check("review queue", "warn", `${open.length} open items; oldest ${oldest.slice(0, 10)} — consider /wiki:review`));
|
|
122
|
+
} else {
|
|
123
|
+
checks.push(check("review queue", "ok", `${open.length} open item(s)`));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- git and sync ---------------------------------------------------------
|
|
127
|
+
if (await isGitRepo(loaded.cwd)) {
|
|
128
|
+
const head = await headCommit(loaded.cwd);
|
|
129
|
+
const state = await readSyncState(layout);
|
|
130
|
+
if (!state.lastSyncCommit) {
|
|
131
|
+
checks.push(check("sync baseline", "warn", `not initialized; run wiki_sync to set it at ${head?.slice(0, 7)}`));
|
|
132
|
+
} else if (state.lastSyncCommit === head) {
|
|
133
|
+
checks.push(check("sync baseline", "ok", `current at ${head?.slice(0, 7)}`));
|
|
134
|
+
} else {
|
|
135
|
+
const behind = await git(loaded.cwd, ["rev-list", "--count", `${state.lastSyncCommit}..HEAD`]);
|
|
136
|
+
checks.push(check("sync baseline", "warn", `${behind.stdout.trim() || "?"} commit(s) behind; run /wiki:sync`));
|
|
137
|
+
}
|
|
138
|
+
if (existsSync(loaded.envFilePath)) {
|
|
139
|
+
const ignored = await git(loaded.cwd, ["check-ignore", "-q", loaded.envFilePath]);
|
|
140
|
+
checks.push(ignored.code === 0 ? check("env gitignored", "ok", ".env is ignored") : check("env gitignored", "fail", `${loaded.envFilePath} is NOT gitignored — the token could be committed`));
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
checks.push(check("git", "warn", "not a repository; change-driven sync and commits are unavailable"));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- search ---------------------------------------------------------------
|
|
147
|
+
if (config.search.engine === "qmd") {
|
|
148
|
+
try {
|
|
149
|
+
await run("qmd", ["--version"], { timeout: 5000 });
|
|
150
|
+
checks.push(check("search engine", "ok", "qmd available"));
|
|
151
|
+
} catch {
|
|
152
|
+
checks.push(check("search engine", "warn", "search.engine=qmd but the qmd binary is not available; BM25 fallback will be used"));
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
checks.push(check("search engine", "ok", config.search.engine));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const summary = {
|
|
159
|
+
ok: checks.filter((item) => item.status === "ok").length,
|
|
160
|
+
warn: checks.filter((item) => item.status === "warn").length,
|
|
161
|
+
fail: checks.filter((item) => item.status === "fail").length,
|
|
162
|
+
};
|
|
163
|
+
return { checks, summary };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function renderDoctor(report: DoctorReport): string {
|
|
167
|
+
const icon = { ok: "✅", warn: "⚠️", fail: "❌" } as const;
|
|
168
|
+
const lines = [`# Wiki doctor — ${report.summary.ok} ok · ${report.summary.warn} warn · ${report.summary.fail} fail`, ""];
|
|
169
|
+
for (const item of report.checks) lines.push(`${icon[item.status]} **${item.name}** — ${item.detail}`);
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|