fix(review): address review findings (guardrails, prompts, tests, docs)

- permission-gate: block refs/tags/v* pushes, rm -r -f separated flags,
  git reset --hard, git clean (verified against actual bypasses)
- prompts: /bugfix /feature /review no longer hardcode go-standards —
  reference profile-gated <lang>-standards instead
- company-context: drop hardcoded Go stack, note TRACKER_URL priority,
  warn on context truncation instead of silently dropping rules
- repo-map/docs-map: graceful degradation when config values are TODO
- /kit-doctor: warn on unfilled config fields (repoMap/trackerUrl/docsUrl)
- audit: retry POSTs to endpoint (3 attempts, backoff), still best-effort
- install.sh: remove TODO course URL from cheat sheet
- tests: expand guardrails (43 node checks), add shell tests for create-mr.sh
  (scp/https origin parse, GITLAB_HOST override, protected branch refusal),
  cover company-context lib (normalize/fetch/truncation) and mcp-bridge
- commit package-lock.json for reproducible installs
- document npm test Node >= 22.6 requirement (type stripping)
This commit is contained in:
Aleksey Shakhmatov
2026-08-06 11:22:53 +03:00
parent 1df4ea1b72
commit c068dfae0c
21 changed files with 4970 additions and 131 deletions
+28 -93
View File
@@ -16,109 +16,33 @@
* or exclude it via the package manifest / settings.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
interface CompanyConfig {
gitHost: string;
trackerUrl: string;
docsUrl: string;
repoMap: string;
rules: string[];
remoteConfigUrl: string | null;
}
import {
readLocalConfig,
fetchRemoteConfig,
loadTemplate,
renderContext,
DEFAULT_CONFIG,
type CompanyConfig,
} from "./lib/company-config.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = path.join(HERE, "..", "config", "company.json");
const TEMPLATE_PATH = path.join(HERE, "company-context.md");
const REMOTE_TIMEOUT_MS = 2500;
const MAX_CONTEXT_LINES = 40;
const DEFAULT_CONFIG: CompanyConfig = {
gitHost: "TODO",
trackerUrl: "TODO",
docsUrl: "TODO",
repoMap: "TODO",
rules: [],
remoteConfigUrl: null,
};
/** Read and validate the local config file, falling back to defaults. */
function readLocalConfig(): CompanyConfig {
try {
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
return normalize(raw);
} catch {
return { ...DEFAULT_CONFIG };
}
}
/** Keep only known keys and coerce types, so junk in remote/local JSON is ignored. */
function normalize(raw: Record<string, unknown>): CompanyConfig {
const str = (v: unknown, d: string) => (typeof v === "string" ? v : d);
return {
gitHost: str(raw.gitHost, DEFAULT_CONFIG.gitHost),
trackerUrl: str(raw.trackerUrl, DEFAULT_CONFIG.trackerUrl),
docsUrl: str(raw.docsUrl, DEFAULT_CONFIG.docsUrl),
repoMap: str(raw.repoMap, DEFAULT_CONFIG.repoMap),
rules: Array.isArray(raw.rules) ? raw.rules.filter((r): r is string => typeof r === "string") : [],
remoteConfigUrl: typeof raw.remoteConfigUrl === "string" ? raw.remoteConfigUrl : null,
};
}
/** Try to fetch a fresh config from a URL. Returns null on any failure. */
async function fetchRemoteConfig(url: string): Promise<CompanyConfig | null> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS) });
if (!res.ok) return null;
const raw = (await res.json()) as Record<string, unknown>;
return normalize(raw);
} catch {
return null; // network error, timeout, bad JSON — fall back silently
}
}
/** Load the context template once; fall back to a minimal inline template. */
function loadTemplate(): string {
try {
return fs.readFileSync(TEMPLATE_PATH, "utf8");
} catch {
return [
"# Корпоративный контекст",
"git-хост: {{gitHost}}",
"трекер: {{trackerUrl}}",
"документация: {{docsUrl}}",
"репозитории: {{repoMap}}",
"правила:",
"{{rules}}",
].join("\n");
}
}
/** Render the corporate context from the template + effective config. */
function renderContext(template: string, cfg: CompanyConfig): string {
const rulesBlock = cfg.rules.length > 0 ? cfg.rules.map((r) => `- ${r}`).join("\n") : "- (правила не заданы)";
const rendered = template
.replaceAll("{{gitHost}}", cfg.gitHost)
.replaceAll("{{trackerUrl}}", cfg.trackerUrl)
.replaceAll("{{docsUrl}}", cfg.docsUrl)
.replaceAll("{{repoMap}}", cfg.repoMap)
.replaceAll("{{rules}}", rulesBlock);
// Safety cap: never inject more than MAX_CONTEXT_LINES lines.
return rendered.split("\n").slice(0, MAX_CONTEXT_LINES).join("\n").trimEnd();
}
export default function companyContextExtension(pi: ExtensionAPI) {
const template = loadTemplate();
const template = loadTemplate(TEMPLATE_PATH);
// Effective config and where it came from. Refreshed on every session start.
let effective: CompanyConfig = readLocalConfig();
let effective: CompanyConfig = { ...DEFAULT_CONFIG };
let source: "local" | "remote" = "local";
// Rendered once per session; injected by before_agent_start.
let rendered: string | null = null;
pi.on("session_start", async (_event, ctx) => {
effective = readLocalConfig();
effective = readLocalConfig(CONFIG_PATH);
source = "local";
if (effective.remoteConfigUrl) {
@@ -131,15 +55,26 @@ export default function companyContextExtension(pi: ExtensionAPI) {
}
}
const { text, truncated } = renderContext(template, effective);
rendered = text;
if (ctx.hasUI) {
ctx.ui.notify(`pi-kit: корпоративный контекст загружен (источник: ${source})`, "info");
if (truncated) {
ctx.ui.notify(
`pi-kit: корпоративный контекст обрезан до ${text.split("\n").length} строк (лимит) — часть правил не попала в контекст.`,
"warning",
);
} else {
ctx.ui.notify(`pi-kit: корпоративный контекст загружен (источник: ${source})`, "info");
}
}
});
pi.on("before_agent_start", async (event) => {
const rendered = renderContext(template, effective);
if (!rendered) return undefined;
return { systemPrompt: `${event.systemPrompt}\n\n${rendered}` };
// session_start may not have fired (rare); render on demand as a fallback.
const context = rendered ?? renderContext(template, effective).text;
if (!context) return undefined;
return { systemPrompt: `${event.systemPrompt}\n\n${context}` };
});
pi.registerCommand("kit-config", {