karajan-code 3.4.2 → 3.5.1

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 CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v3.4.2 released** — Hotfix: `npm install karajan-code@3.4.1` crashed at `kj --version` because the bundled `@karajan/core` dragged in `sqlite-vec` (which ships `files: []`) without its entry point. `@karajan/core`'s runtime deps are now `peerDependencies`, so they resolve complete from the consumer's top-level install. Clean install verified end-to-end. Full notes in [CHANGELOG.md](CHANGELOG.md).
26
+ > **v3.5.1 released** — PHP joins the quality harness as a first-class language: `kj harden` now detects `composer.json`, installs PHP-native hooks (`phpstan` / `php-cs-fixer` / `phpunit`), a `phpstan.neon` config and a `setup-php` CI workflow — same as JS/Python/Go, no npm/Node imposed at commit time. Builds on v3.5.0's quality harness (`kj harden` + `kj check`): idempotent git hooks, config, CI gates and agent guidelines for any repo, behind `kj:managed` markers. Full notes in [CHANGELOG.md](CHANGELOG.md).
27
27
 
28
28
  You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
29
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.4.2",
3
+ "version": "3.5.1",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -14,6 +14,8 @@ import { webperfCommand } from "../commands/webperf.js";
14
14
  import { undoCommand } from "../commands/undo.js";
15
15
  import { syncCommand } from "../commands/sync.js";
16
16
  import { cleanCommand } from "../commands/clean.js";
17
+ import { checkCommand } from "../commands/check.js";
18
+ import { hardenCommand } from "../commands/harden.js";
17
19
  import { telemetryPreviewCommand, telemetryStatusCommand } from "../commands/telemetry.js";
18
20
  import { withConfig } from "./_shared.js";
19
21
 
@@ -396,4 +398,39 @@ export function registerMeta(program, { pkgVersion }) {
396
398
  huDays: flags.huDays ? Number(flags.huDays) : undefined,
397
399
  });
398
400
  });
401
+
402
+ program
403
+ .command("harden")
404
+ .description("Install the quality harness (git hooks) into this repo — idempotent")
405
+ .option("--profile <name>", "minimal | standard | strict", "standard")
406
+ .option("--no-config", "Skip lint/format/commit config files (hooks only)")
407
+ .option("--no-ci", "Skip CI quality workflows")
408
+ .option("--no-guidelines", "Skip AI-agent guideline files (AGENTS.md/CLAUDE.md)")
409
+ .option("--dry-run", "Show what would change without writing")
410
+ .option("--json", "Emit machine-readable JSON")
411
+ .action(async (flags) => {
412
+ await withConfig(pkgVersion, "harden", flags, async ({ logger }) => {
413
+ await hardenCommand({
414
+ profile: flags.profile,
415
+ config: flags.config !== false,
416
+ ci: flags.ci !== false,
417
+ guidelines: flags.guidelines !== false,
418
+ dryRun: Boolean(flags.dryRun),
419
+ json: Boolean(flags.json),
420
+ logger,
421
+ });
422
+ });
423
+ });
424
+
425
+ program
426
+ .command("check")
427
+ .description("Verify the quality harness installed by kj harden (drift gate)")
428
+ .option("--profile <name>", "minimal | standard | strict", "standard")
429
+ .option("--json", "Emit machine-readable JSON")
430
+ .action(async (flags) => {
431
+ const code = await withConfig(pkgVersion, "check", flags, async ({ logger }) =>
432
+ checkCommand({ profile: flags.profile, json: Boolean(flags.json), logger })
433
+ );
434
+ if (Number.isInteger(code)) process.exit(code);
435
+ });
399
436
  }
@@ -30,6 +30,7 @@ export function registerPipeline(program, { pkgVersion }) {
30
30
  .option("--no-rtk", "Skip the RTK auto-install (token savings on Bash outputs). Karajan still runs but burns more tokens")
31
31
  .option("--no-squeezr", "Skip the Squeezr auto-install (context compression). Karajan still runs but burns more tokens")
32
32
  .option("--no-qmd", "Skip the QMD auto-install + collection registration (semantic wiki over docs/, .reviews/ and plans/)")
33
+ .option("--no-harden", "Skip the quality harness (git hooks, lint/commit config, CI gates, agent guidelines)")
33
34
  .action(async (flags) => {
34
35
  await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
35
36
  await initCommand({ logger, flags });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `kj check` — verify the quality harness installed by `kj harden`
3
+ * (KJC-TSK-0558). Prints a per-category table and returns an exit code so it
4
+ * works both locally and as a CI drift gate. `--json` for machine output.
5
+ */
6
+
7
+ import { checkHarden } from "../harden/check.js";
8
+
9
+ export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
10
+ const result = await checkHarden({ projectDir, profile });
11
+
12
+ if (json) {
13
+ logger.info?.(JSON.stringify(result));
14
+ return result.ok ? 0 : 1;
15
+ }
16
+
17
+ logger.info?.(`kj check (${profile})`);
18
+ for (const c of result.checks) logger.info?.(` ${c.ok ? "✓" : "✗"} ${c.id}: ${c.detail}`);
19
+ if (!result.ok) logger.info?.("Harness drift detected — run `kj harden` to repair.");
20
+ else logger.info?.("Harness OK.");
21
+ return result.ok ? 0 : 1;
22
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `kj harden` — install the quality harness (git hooks) into any repo.
3
+ *
4
+ * Thin CLI layer over harden-engine (KJC-TSK-0555): resolves stack-aware
5
+ * lint/format/test commands, runs the installer and reports. Config files
6
+ * (eslint/prettier) and CI gates land in later slices (H-C / H-D).
7
+ */
8
+
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { installConfigsForRoots } from "../harden/config-engine.js";
13
+ import { installGuidelines } from "../harden/guidelines-engine.js";
14
+ import { commandsForLanguage } from "../harden/hook-commands.js";
15
+ import { installHooks } from "../harden/harden-engine.js";
16
+ import { detectStackRoots } from "../harden/stack-roots.js";
17
+ import { installWorkflows } from "../harden/workflow-engine.js";
18
+ import { detectTestFramework } from "../utils/project-detect.js";
19
+
20
+ const JS_TEST_CMD = {
21
+ vitest: "npx vitest run",
22
+ jest: "npx jest",
23
+ mocha: "npx mocha",
24
+ };
25
+
26
+ /**
27
+ * Resolve the hook's lint/format/test commands for the primary language.
28
+ * Non-JS stacks use their native toolchain (go/ruff/…); JS/TS is built from
29
+ * the project's own package.json scripts so no npm command is ever assumed.
30
+ */
31
+ export async function resolveCmds(projectDir, language) {
32
+ const lang = language ?? detectStackRoots(projectDir)[0]?.language ?? null;
33
+ const cmds = { ...commandsForLanguage(lang) };
34
+ if (lang !== "javascript" && lang !== "typescript") return cmds;
35
+
36
+ const pkgPath = join(projectDir, "package.json");
37
+ if (existsSync(pkgPath)) {
38
+ try {
39
+ const scripts = JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {};
40
+ if (scripts.lint) cmds.lint = "npm run -s lint";
41
+ if (scripts["format:check"]) cmds.format = "npm run -s format:check";
42
+ else if (scripts.format) cmds.format = "npm run -s format";
43
+ if (scripts.test) cmds.test = "npm test";
44
+ } catch {
45
+ /* unreadable package.json → fall back to framework detection */
46
+ }
47
+ }
48
+ if (!cmds.test) {
49
+ const { framework } = await detectTestFramework(projectDir);
50
+ if (framework && JS_TEST_CMD[framework]) cmds.test = JS_TEST_CMD[framework];
51
+ }
52
+ return cmds;
53
+ }
54
+
55
+ export async function hardenCommand({
56
+ projectDir = process.cwd(),
57
+ profile = "standard",
58
+ config = true,
59
+ ci = true,
60
+ guidelines = true,
61
+ dryRun = false,
62
+ json = false,
63
+ logger = console,
64
+ } = {}) {
65
+ const roots = detectStackRoots(projectDir);
66
+ const cmds = await resolveCmds(projectDir, roots[0]?.language ?? null);
67
+ let result;
68
+ try {
69
+ result = await installHooks({ projectDir, profile, cmds, dryRun });
70
+ } catch (err) {
71
+ if (json) logger.info?.(JSON.stringify({ ok: false, error: err.message }));
72
+ else logger.error?.(`kj harden: ${err.message}`);
73
+ return { ok: false, error: err.message };
74
+ }
75
+
76
+ // Config (lint/format/commit) ships with standard+, per language root so a
77
+ // fullstack monorepo is hardened on every side, not just one.
78
+ const withConfig = config && profile !== "minimal";
79
+ const cfg = withConfig ? installConfigsForRoots({ projectDir, roots, dryRun }) : null;
80
+ const withCi = ci && profile !== "minimal";
81
+ const wf = withCi
82
+ ? installWorkflows({ projectDir, language: roots[0]?.language ?? null, profile, dryRun })
83
+ : null;
84
+ const withGuidelines = guidelines && profile !== "minimal";
85
+ const gl = withGuidelines ? installGuidelines({ projectDir, dryRun }) : null;
86
+ const out = {
87
+ ok: true,
88
+ ...result,
89
+ configs: cfg?.configs ?? [],
90
+ workflows: wf?.workflows ?? [],
91
+ guidelines: gl?.guidelines ?? [],
92
+ };
93
+
94
+ if (json) {
95
+ logger.info?.(JSON.stringify(out));
96
+ return out;
97
+ }
98
+
99
+ const verb = dryRun ? "would install" : "installed";
100
+ logger.info?.(`kj harden (${profile}) — ${verb} ${result.hooks.length} hook(s) → ${result.hooksPath}`);
101
+ for (const h of result.hooks) logger.info?.(` • ${h.hook}: ${h.action}`);
102
+ for (const c of out.configs) logger.info?.(` • ${c.file}: ${c.action}`);
103
+ for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
104
+ for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
105
+ if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check` (H-E).");
106
+ return out;
107
+ }
@@ -21,6 +21,8 @@ import { detectAiTrash, installAiTrashHook } from "../utils/ai-trash.js";
21
21
  import { detectQmd } from "../utils/qmd-detect.js";
22
22
  import { installQmd } from "../utils/qmd-install.js";
23
23
  import { registerQmdCollections } from "../utils/qmd-collection.js";
24
+ import { isGitRepo } from "../harden/harden-engine.js";
25
+ import { hardenCommand } from "./harden.js";
24
26
  import { detectProjectStack } from "../utils/stack-detect.js";
25
27
  import { bootstrapSonarToken } from "../sonar/token-bootstrap.js";
26
28
 
@@ -834,6 +836,19 @@ export async function initCommand({ logger, flags = {} }) {
834
836
  await setupSonarQube(config, logger, { interactive });
835
837
  await scaffoldCiGateway(config, flags, logger);
836
838
 
839
+ // Install the quality harness (git hooks, lint/format/commit config, CI
840
+ // quality gates, agent guidelines) through the SAME engine as `kj harden`,
841
+ // so init and harden never drift. Opt-out: --no-harden. Needs a git repo.
842
+ const skipHarden = flags?.noHarden === true || flags?.harden === false;
843
+ if (skipHarden) {
844
+ logger.info("Quality harness skipped (--no-harden).");
845
+ } else if (isGitRepo(process.cwd())) {
846
+ logger.info("Installing quality harness (kj harden)...");
847
+ await hardenCommand({ projectDir: process.cwd(), logger });
848
+ } else {
849
+ logger.info("Not a git repository — run `kj harden` after `git init` to add the quality harness.");
850
+ }
851
+
837
852
  // Persist any changes setupSonarQube made (token, etc.) back to disk.
838
853
  // Use writeInitConfig so the deprecated `sonarqube.enabled` key —
839
854
  // which setupSonarQube still mutates as an in-memory hint — never
@@ -0,0 +1,70 @@
1
+ /**
2
+ * checkHarden — verify the quality harness installed by `kj harden`
3
+ * (KJC-TSK-0558). Detects drift: a deleted hook, a hook that lost its kj
4
+ * marker or its executable bit, or a core.hooksPath that no longer points at
5
+ * `.karajan/hooks`. Returns a per-check report; the CLI maps it to exit 0/≠0.
6
+ *
7
+ * Config, workflow and language-advisory checks land in the next slice.
8
+ */
9
+
10
+ import { existsSync, readFileSync, statSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ import { runCommand } from "../utils/process.js";
14
+ import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
15
+ import { PROFILE_HOOKS } from "./hook-templates.js";
16
+ import { detectStackRoots } from "./stack-roots.js";
17
+ import { qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
18
+
19
+ const HOOKS_DIR = join(".karajan", "hooks");
20
+ const WORKFLOWS_DIR = join(".github", "workflows");
21
+
22
+ /** A config/workflow file just needs to be present (it may be user-owned). */
23
+ function presence(projectDir, rel, id, miss) {
24
+ const ok = existsSync(join(projectDir, rel));
25
+ return { id, ok, detail: ok ? "ok" : miss };
26
+ }
27
+
28
+ export async function checkHarden({ projectDir = process.cwd(), profile = "standard" } = {}) {
29
+ const checks = [];
30
+
31
+ const res = await runCommand("git", ["config", "--local", "core.hooksPath"], { cwd: projectDir });
32
+ const hooksPath = (res.stdout ?? "").trim();
33
+ checks.push({
34
+ id: "core.hooksPath",
35
+ ok: hooksPath === HOOKS_DIR,
36
+ detail: hooksPath === HOOKS_DIR ? "ok" : hooksPath || "(unset) — run kj harden",
37
+ });
38
+
39
+ for (const hook of PROFILE_HOOKS[profile] ?? []) {
40
+ const target = join(projectDir, HOOKS_DIR, hook);
41
+ const exists = existsSync(target);
42
+ const executable = exists && (statSync(target).mode & 0o100) !== 0;
43
+ const marked = exists && readFileSync(target, "utf8").includes(`kj:managed:hook:${hook}`);
44
+ const detail = !exists ? "missing" : !executable ? "not executable" : !marked ? "no kj marker" : "ok";
45
+ checks.push({ id: `hook:${hook}`, ok: exists && executable && marked, detail });
46
+ }
47
+
48
+ // Config + workflows ship with standard+ — and surface the greenfield gap:
49
+ // a language now present but never hardened ⇒ its config shows as missing.
50
+ if (profile !== "minimal") {
51
+ const roots = detectStackRoots(projectDir);
52
+ for (const cfg of UNIVERSAL_CONFIGS) {
53
+ checks.push(presence(projectDir, cfg.file, `config:${cfg.file}`, "missing — run kj harden"));
54
+ }
55
+ for (const { dir, language } of roots) {
56
+ for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
57
+ const rel = dir === "." ? cfg.file : join(dir, cfg.file);
58
+ checks.push(presence(projectDir, rel, `config:${rel}`, `missing for ${language} — run kj harden`));
59
+ }
60
+ }
61
+ const expectedWf = [...WORKFLOWS];
62
+ const quality = qualityWorkflowFor(roots[0]?.language ?? null);
63
+ if (quality) expectedWf.push(quality);
64
+ for (const wf of expectedWf) {
65
+ checks.push(presence(projectDir, join(WORKFLOWS_DIR, wf.file), `workflow:${wf.file}`, "missing — run kj harden"));
66
+ }
67
+ }
68
+
69
+ return { ok: checks.every((c) => c.ok), checks };
70
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * config-engine — seeds/refreshes lint/format/commit config for `kj harden`
3
+ * (KJC-TSK-0556). Seed-if-absent: a config the user already authored (no
4
+ * kj:managed marker) is left untouched and reported as "skipped". Markered
5
+ * files refresh in place; JSON files (no comment syntax) are seed-only.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+
11
+ import { upsertManagedBlock } from "../utils/managed-markers.js";
12
+ import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
13
+
14
+ const BLOCK_VERSION = 1;
15
+
16
+ function writeFile(target, content) {
17
+ mkdirSync(dirname(target), { recursive: true });
18
+ writeFileSync(target, content);
19
+ }
20
+
21
+ /** Seed a list of config entries into `targetDir`, labelling each by `prefix`. */
22
+ function seedInto(targetDir, configs, dryRun, prefix, results) {
23
+ for (const cfg of configs) {
24
+ const target = join(targetDir, cfg.file);
25
+ const exists = existsSync(target);
26
+ const file = prefix === "." ? cfg.file : join(prefix, cfg.file);
27
+
28
+ if (cfg.json) {
29
+ const action = exists ? "skipped" : "inserted";
30
+ if (!dryRun && !exists) writeFile(target, `${cfg.body}\n`);
31
+ results.push({ file, action });
32
+ continue;
33
+ }
34
+
35
+ const source = exists ? readFileSync(target, "utf8") : "";
36
+ if (exists && !source.includes(`kj:managed:${cfg.blockId}`)) {
37
+ results.push({ file, action: "skipped" });
38
+ continue;
39
+ }
40
+ const { content, action } = upsertManagedBlock({
41
+ source,
42
+ blockId: cfg.blockId,
43
+ version: BLOCK_VERSION,
44
+ body: cfg.body,
45
+ style: cfg.style,
46
+ });
47
+ if (!dryRun && action !== "unchanged") writeFile(target, content);
48
+ results.push({ file, action });
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Single-directory seed: universal (.editorconfig, commitlint) plus the
54
+ * language's lint/format config, all at `projectDir`. Unknown language ⇒
55
+ * universal only. `dryRun` computes actions without writing.
56
+ */
57
+ export function installConfigs({ projectDir = process.cwd(), language = "javascript", dryRun = false } = {}) {
58
+ const results = [];
59
+ seedInto(projectDir, [...UNIVERSAL_CONFIGS, ...(CONFIGS_BY_LANGUAGE[language] ?? [])], dryRun, ".", results);
60
+ return { dryRun, configs: results };
61
+ }
62
+
63
+ /**
64
+ * Monorepo seed: universal config once at the root, then each language's
65
+ * lint/format config inside its own root (from detectStackRoots). A fullstack
66
+ * repo gets eslint in frontend/, ruff in backend/, etc.
67
+ */
68
+ export function installConfigsForRoots({ projectDir = process.cwd(), roots = [], dryRun = false } = {}) {
69
+ const results = [];
70
+ seedInto(projectDir, UNIVERSAL_CONFIGS, dryRun, ".", results);
71
+ for (const { dir, language } of roots) {
72
+ seedInto(join(projectDir, dir), CONFIGS_BY_LANGUAGE[language] ?? [], dryRun, dir, results);
73
+ }
74
+ return { dryRun, configs: results };
75
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Config-file templates for `kj harden` (KJC-TSK-0556).
3
+ *
4
+ * Each entry is seeded only when absent (project rule: never overwrite an
5
+ * existing file); on re-run a kj:managed block is refreshed in place. JSON
6
+ * formats carry no comment syntax, so they are seed-only (no marker).
7
+ *
8
+ * The eslint ES2025 deprecated-API blacklist lands in a follow-up slice.
9
+ */
10
+
11
+ export const EDITORCONFIG_BODY = [
12
+ "root = true",
13
+ "",
14
+ "[*]",
15
+ "charset = utf-8",
16
+ "end_of_line = lf",
17
+ "insert_final_newline = true",
18
+ "trim_trailing_whitespace = true",
19
+ "indent_style = space",
20
+ "indent_size = 2",
21
+ ].join("\n");
22
+
23
+ export const PRETTIER_BODY = JSON.stringify(
24
+ { printWidth: 110, singleQuote: false, trailingComma: "es5", semi: true },
25
+ null,
26
+ 2
27
+ );
28
+
29
+ export const COMMITLINT_BODY = [
30
+ "// Conventional Commits — header <=100 chars, lowercase subject.",
31
+ "export default {",
32
+ ' extends: ["@commitlint/config-conventional"],',
33
+ " rules: {",
34
+ ' "header-max-length": [2, "always", 100],',
35
+ ' "subject-case": [2, "never", ["sentence-case", "start-case", "pascal-case", "upper-case"]],',
36
+ " },",
37
+ "};",
38
+ ].join("\n");
39
+
40
+ // Self-contained flat config (no plugin deps) banning APIs deprecated under
41
+ // an ES2025 target — mirrors the Karajan standard. Seeded only when the
42
+ // project has no eslint config of its own.
43
+ export const ESLINT_BODY = [
44
+ "export default [",
45
+ " {",
46
+ ' files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],',
47
+ ' languageOptions: { ecmaVersion: 2025, sourceType: "module" },',
48
+ " rules: {",
49
+ ' "no-var": "error",',
50
+ ' "no-eval": "error",',
51
+ ' "no-new-func": "error",',
52
+ ' "no-restricted-globals": [',
53
+ ' "error",',
54
+ ' { name: "alert", message: "Use the app modal, not alert()." },',
55
+ ' { name: "confirm", message: "Use the app modal, not confirm()." },',
56
+ ' { name: "prompt", message: "Use the app modal, not prompt()." },',
57
+ ' { name: "escape", message: "Use encodeURIComponent()." },',
58
+ ' { name: "unescape", message: "Use decodeURIComponent()." },',
59
+ ' { name: "event", message: "Use the handler parameter, not window.event." },',
60
+ " ],",
61
+ ' "no-restricted-properties": [',
62
+ ' "error",',
63
+ ' { object: "document", property: "write", message: "Use DOM nodes / Blob URLs, not document.write." },',
64
+ ' { object: "document", property: "writeln", message: "Use DOM nodes, not document.writeln." },',
65
+ ' { property: "substr", message: "Use slice() or substring(), not substr()." },',
66
+ " ],",
67
+ " },",
68
+ " },",
69
+ "];",
70
+ ].join("\n");
71
+
72
+ // Ruff covers lint + format for Python; `UP` (pyupgrade) is the analogue of
73
+ // the ES2025 deprecated-API ban — it rewrites legacy idioms to modern Python.
74
+ export const RUFF_BODY = [
75
+ "line-length = 110",
76
+ 'target-version = "py312"',
77
+ "",
78
+ "[lint]",
79
+ 'select = ["E", "F", "I", "UP", "B", "SIM"]',
80
+ ].join("\n");
81
+
82
+ export const GOLANGCI_BODY = [
83
+ "linters:",
84
+ " enable:",
85
+ " - govet",
86
+ " - staticcheck",
87
+ " - revive",
88
+ " - ineffassign",
89
+ " - gofmt",
90
+ ].join("\n");
91
+
92
+ /** Language-agnostic configs (every stack gets these). */
93
+ export const UNIVERSAL_CONFIGS = [
94
+ { file: ".editorconfig", blockId: "editorconfig", style: "hash", body: EDITORCONFIG_BODY },
95
+ { file: "commitlint.config.js", blockId: "commitlint", style: "slash", body: COMMITLINT_BODY },
96
+ ];
97
+
98
+ /** `json:true` ⇒ seed-only (no comment syntax for a marker). */
99
+ export const JS_CONFIGS = [
100
+ { file: "eslint.config.js", blockId: "eslint", style: "slash", body: ESLINT_BODY },
101
+ { file: ".prettierrc.json", json: true, body: PRETTIER_BODY },
102
+ ];
103
+ export const PY_CONFIGS = [{ file: "ruff.toml", blockId: "ruff", style: "hash", body: RUFF_BODY }];
104
+ export const GO_CONFIGS = [{ file: ".golangci.yml", blockId: "golangci", style: "hash", body: GOLANGCI_BODY }];
105
+
106
+ // PHPStan static analysis — the PHP analogue of ruff/golangci.
107
+ export const PHPSTAN_BODY = ["parameters:", " level: 6", " paths:", " - src", " - tests"].join("\n");
108
+ export const PHP_CONFIGS = [{ file: "phpstan.neon", blockId: "phpstan", style: "hash", body: PHPSTAN_BODY }];
109
+
110
+ /** Lint/format configs by detected language. Unknown ⇒ universal only. */
111
+ export const CONFIGS_BY_LANGUAGE = {
112
+ javascript: JS_CONFIGS,
113
+ typescript: JS_CONFIGS,
114
+ python: PY_CONFIGS,
115
+ go: GO_CONFIGS,
116
+ php: PHP_CONFIGS,
117
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * guidelines-engine — seeds AI-agent guideline files for `kj harden`
3
+ * (KJC-TSK-0559), absorbing dev-hooks' generate_guidelines without an external
4
+ * MCP. Writes a kj:managed block into AGENTS.md and CLAUDE.md, preserving any
5
+ * content the user keeps outside the markers. Idempotent.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ import { upsertManagedBlock } from "../utils/managed-markers.js";
12
+ import { GUIDELINES_BODY } from "./guidelines-templates.js";
13
+
14
+ const BLOCK_VERSION = 1;
15
+ const TARGETS = ["AGENTS.md", "CLAUDE.md"];
16
+ const DH_START = "<!-- @manufosela/dev-git-hooks:start -->";
17
+ const DH_END = "<!-- @manufosela/dev-git-hooks:end -->";
18
+
19
+ /** Remove a legacy dev-hooks managed block, preserving everything else. */
20
+ export function stripDevHooksBlock(source) {
21
+ const s = source.indexOf(DH_START);
22
+ if (s === -1) return { content: source, migrated: false };
23
+ const e = source.indexOf(DH_END, s);
24
+ if (e === -1) return { content: source, migrated: false };
25
+ const before = source.slice(0, s).replace(/\n+$/, "");
26
+ const after = source.slice(e + DH_END.length).replace(/^\n+/, "");
27
+ const joiner = before && after ? "\n\n" : "";
28
+ return { content: `${before}${joiner}${after}`, migrated: true };
29
+ }
30
+
31
+ export function installGuidelines({ projectDir = process.cwd(), dryRun = false } = {}) {
32
+ const results = [];
33
+ for (const file of TARGETS) {
34
+ const target = join(projectDir, file);
35
+ const raw = existsSync(target) ? readFileSync(target, "utf8") : "";
36
+ const { content: source, migrated } = stripDevHooksBlock(raw);
37
+ const { content, action } = upsertManagedBlock({
38
+ source,
39
+ blockId: "guidelines",
40
+ version: BLOCK_VERSION,
41
+ body: GUIDELINES_BODY,
42
+ style: "html",
43
+ });
44
+ if (!dryRun && (migrated || action !== "unchanged")) writeFileSync(target, content);
45
+ results.push({ file, action: migrated ? "migrated" : action });
46
+ }
47
+ return { dryRun, guidelines: results };
48
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Distilled AI-agent guidelines for `kj harden` (KJC-TSK-0559).
3
+ *
4
+ * A compact, high-signal rule set seeded into AGENTS.md / CLAUDE.md inside a
5
+ * kj:managed block, so it preserves whatever the user wrote outside it. Kept
6
+ * deliberately short — these rules enter the agent's context on every run, so
7
+ * bloat dilutes the signal. Distilled from the dev-toolkit guidelines.
8
+ */
9
+
10
+ export const GUIDELINES_BODY = [
11
+ "# Project guidelines (kj harden)",
12
+ "",
13
+ "## Code",
14
+ "- SOLID, DRY, KISS, YAGNI. `const` by default; arrow callbacks; template literals.",
15
+ "- ES2025 target: never `var`, `document.write`, `alert`/`confirm`/`prompt`, `escape`/`unescape`, `substr`.",
16
+ " Prefer modern APIs (`structuredClone`, `Object.groupBy`, `Array.at`/`findLast`/`toSorted`, optional chaining, `??`).",
17
+ "- ES modules (`import`/`export`), never `require` in new code. Names in English, descriptive.",
18
+ "- No silent fallbacks: the system works or fails loudly. Validate and sanitize all input.",
19
+ "",
20
+ "## Commits & PRs",
21
+ "- Conventional Commits (`feat:`/`fix:`/`refactor:`/`docs:`/`test:`/`chore:`). Header <=100 chars, lowercase subject.",
22
+ '- Never attribute commits or PRs to AI (no `Co-Authored-By`, no "generated by").',
23
+ "- Atomic PRs: one purpose, small (~150 LOC). Each PR compiles and passes its tests on its own.",
24
+ "",
25
+ "## Testing",
26
+ "- Test-first. Run the tests after each meaningful change. Never skip tests.",
27
+ "",
28
+ "## Security",
29
+ "- Never commit secrets, keys or tokens. Parameterized queries; sanitize output against XSS.",
30
+ "",
31
+ "## UI/UX",
32
+ "- No native `alert`/`confirm`/`prompt` — use the app's modal system. Loading states; accessible; mobile-first.",
33
+ "",
34
+ "## Files",
35
+ "- Edit existing files in place; never overwrite a whole file to make a small change.",
36
+ ].join("\n");
@@ -0,0 +1,66 @@
1
+ /**
2
+ * harden-engine — installs kj-managed git hooks idempotently (KJC-TSK-0555).
3
+ *
4
+ * Hooks live under `.karajan/hooks/` and are activated via `core.hooksPath`,
5
+ * so harden never fights husky/simple-git-hooks nor touches `.git/hooks/`.
6
+ * Each hook is a shebang + a kj:managed block; re-running is a no-op and any
7
+ * content outside the block is preserved.
8
+ */
9
+
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ import { upsertManagedBlock } from "../utils/managed-markers.js";
14
+ import { runCommand } from "../utils/process.js";
15
+ import { hookBody, PROFILE_HOOKS, SHEBANG } from "./hook-templates.js";
16
+
17
+ const BLOCK_VERSION = 1;
18
+ const HOOKS_DIR = join(".karajan", "hooks");
19
+ const HOOK_PREAMBLE = `${SHEBANG}\nset -e\n`;
20
+
21
+ export function isGitRepo(projectDir) {
22
+ return existsSync(join(projectDir, ".git"));
23
+ }
24
+
25
+ /**
26
+ * Install (or refresh) the profile's hooks under `.karajan/hooks/` and point
27
+ * `core.hooksPath` there. Idempotent. `dryRun` computes actions without
28
+ * touching disk or git config. `cmds` supplies stack-aware lint/format/test
29
+ * commands (npm-script defaults). Returns a per-hook action report.
30
+ */
31
+ export async function installHooks({
32
+ projectDir = process.cwd(),
33
+ profile = "standard",
34
+ cmds = {},
35
+ dryRun = false,
36
+ } = {}) {
37
+ if (!isGitRepo(projectDir)) throw new Error(`Not a git repository: ${projectDir}`);
38
+ const hooks = PROFILE_HOOKS[profile];
39
+ if (!hooks) throw new Error(`Unknown harden profile: ${profile}`);
40
+
41
+ const absHooksDir = join(projectDir, HOOKS_DIR);
42
+ const results = [];
43
+ for (const hook of hooks) {
44
+ const target = join(absHooksDir, hook);
45
+ const source = existsSync(target) ? readFileSync(target, "utf8") : HOOK_PREAMBLE;
46
+ const { content, action } = upsertManagedBlock({
47
+ source,
48
+ blockId: `hook:${hook}`,
49
+ version: BLOCK_VERSION,
50
+ body: hookBody(hook, cmds),
51
+ style: "hash",
52
+ });
53
+ results.push({ hook, target, action });
54
+ if (!dryRun && action !== "unchanged") {
55
+ mkdirSync(absHooksDir, { recursive: true });
56
+ writeFileSync(target, content);
57
+ chmodSync(target, 0o755);
58
+ }
59
+ }
60
+
61
+ if (!dryRun) {
62
+ await runCommand("git", ["config", "core.hooksPath", HOOKS_DIR], { cwd: projectDir });
63
+ }
64
+
65
+ return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results };
66
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Native lint/format/test commands per language for `kj harden` hooks
3
+ * (KJC-TSK-0562).
4
+ *
5
+ * The installed hooks call the project's OWN toolchain, never npm by default,
6
+ * so hardening a Go/Python repo never drags Node onto a contributor's machine.
7
+ * JavaScript/TypeScript is built from package.json scripts by the caller (its
8
+ * commands depend on what the project defines). Unknown language ⇒ no commands,
9
+ * leaving only the universal POSIX-shell checks.
10
+ */
11
+
12
+ export const COMMANDS_BY_LANGUAGE = {
13
+ python: { lint: "ruff check .", format: "ruff format --check .", test: "pytest" },
14
+ go: { lint: "go vet ./...", format: 'test -z "$(gofmt -l .)"', test: "go test ./..." },
15
+ php: {
16
+ lint: "vendor/bin/phpstan analyse --no-progress",
17
+ format: "vendor/bin/php-cs-fixer fix --dry-run --diff",
18
+ test: "vendor/bin/phpunit",
19
+ },
20
+ };
21
+
22
+ /** Native commands for a language, or {} when JS/TS or unknown. */
23
+ export function commandsForLanguage(language) {
24
+ return COMMANDS_BY_LANGUAGE[language] ?? {};
25
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Hook bodies + profile→hooks mapping for `kj harden` (KJC-TSK-0555, 0562).
3
+ *
4
+ * A body is the MANAGED portion only; harden-engine adds the shebang and the
5
+ * kj:managed markers around it. Pure POSIX sh — the universal checks (commit
6
+ * format, AI-attribution) need no toolchain, and lint/format/test lines call
7
+ * the project's NATIVE commands (from `cmds`), only when present. No npm/Node
8
+ * is imposed on non-JS repos. See docs/specs/quality-harness.md §5.
9
+ */
10
+
11
+ const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
12
+ const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
13
+
14
+ export const SHEBANG = "#!/usr/bin/env sh";
15
+
16
+ /** Hooks enabled per profile. */
17
+ export const PROFILE_HOOKS = {
18
+ minimal: ["commit-msg"],
19
+ standard: ["pre-commit", "commit-msg", "pre-push", "post-merge"],
20
+ strict: ["pre-commit", "commit-msg", "pre-push", "post-merge"],
21
+ };
22
+
23
+ /** Build the managed body for a single hook. */
24
+ export function hookBody(hook, cmds = {}) {
25
+ switch (hook) {
26
+ case "pre-commit": {
27
+ const lines = ["# Lint + format the working tree with the project's native tools."];
28
+ if (cmds.lint) lines.push(`${cmds.lint} || { echo 'kj harden: lint failed'; exit 1; }`);
29
+ if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
30
+ if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
31
+ return lines.join("\n");
32
+ }
33
+ case "commit-msg":
34
+ // Pure POSIX — Conventional Commits header, length cap, AI-attribution.
35
+ return [
36
+ 'msg_file="$1"',
37
+ 'header=$(head -n1 "$msg_file")',
38
+ `if ! printf '%s' "$header" | grep -qE '^(${CONVENTIONAL_TYPES})(\\([a-z0-9._-]+\\))?!?: .+'; then`,
39
+ " echo 'kj harden: commit header must follow Conventional Commits (type: subject)'; exit 1",
40
+ "fi",
41
+ 'if [ "${#header}" -gt 100 ]; then echo \'kj harden: commit header exceeds 100 chars\'; exit 1; fi',
42
+ `if grep -qiE '${AI_ATTRIBUTION}' "$msg_file"; then`,
43
+ " echo 'kj harden: AI attribution is not allowed in commit messages'; exit 1",
44
+ "fi",
45
+ ].join("\n");
46
+ case "pre-push": {
47
+ const lines = [
48
+ "# Identity guard — never push without a configured git identity.",
49
+ 'if [ -z "$(git config user.email)" ] || [ -z "$(git config user.name)" ]; then',
50
+ " echo 'kj harden: git user.name/user.email not set — refusing to push'; exit 1",
51
+ "fi",
52
+ 'echo "kj harden: pushing as $(git config user.name) <$(git config user.email)>"',
53
+ "# Run the test suite before pushing.",
54
+ ];
55
+ if (cmds.test) lines.push(`${cmds.test} || { echo 'kj harden: tests failed'; exit 1; }`);
56
+ else lines.push("# (no test command detected for this stack)");
57
+ return lines.join("\n");
58
+ }
59
+ case "post-merge":
60
+ return [
61
+ "# Refresh the local RAG index so retrieval never serves stale code.",
62
+ "if command -v kj >/dev/null 2>&1; then",
63
+ " kj rag index --since auto >/dev/null 2>&1 || true",
64
+ "fi",
65
+ ].join("\n");
66
+ default:
67
+ throw new Error(`Unknown hook: ${hook}`);
68
+ }
69
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * detectStackRoots — find every language root in a (possibly monorepo) tree
3
+ * for `kj harden` (KJC-TSK-0561).
4
+ *
5
+ * Walks the project up to a shallow depth and returns the shallowest
6
+ * directory where each language appears, e.g. a fullstack repo yields
7
+ * `[{dir:"frontend",language:"javascript"},{dir:"backend",language:"python"}]`
8
+ * so lint/format config is seeded on each side. Universal config (handled by
9
+ * the caller) stays at the project root. Noise dirs are never descended.
10
+ */
11
+
12
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
+ import { join, relative } from "node:path";
14
+
15
+ const IGNORE_DIRS = new Set([
16
+ "node_modules",
17
+ "dist",
18
+ "build",
19
+ "coverage",
20
+ "vendor",
21
+ "target",
22
+ "__pycache__",
23
+ "venv",
24
+ ]);
25
+ const DEFAULT_MAX_DEPTH = 2;
26
+
27
+ /** Identify the language of a single directory by its manifest files. */
28
+ export function languageAt(dir) {
29
+ if (existsSync(join(dir, "package.json"))) {
30
+ if (existsSync(join(dir, "tsconfig.json"))) return "typescript";
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
33
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
34
+ if (deps.typescript || deps["ts-node"]) return "typescript";
35
+ } catch {
36
+ /* unreadable package.json → treat as plain JS */
37
+ }
38
+ return "javascript";
39
+ }
40
+ if (["pyproject.toml", "requirements.txt", "setup.py"].some((f) => existsSync(join(dir, f)))) return "python";
41
+ if (existsSync(join(dir, "go.mod"))) return "go";
42
+ if (existsSync(join(dir, "composer.json"))) return "php";
43
+ return null;
44
+ }
45
+
46
+ export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH } = {}) {
47
+ const found = [];
48
+ function walk(dir, depth) {
49
+ const language = languageAt(dir);
50
+ if (language) found.push({ dir: relative(projectDir, dir) || ".", language, depth });
51
+ if (depth >= maxDepth) return;
52
+ let entries;
53
+ try {
54
+ entries = readdirSync(dir, { withFileTypes: true });
55
+ } catch {
56
+ return;
57
+ }
58
+ for (const e of entries) {
59
+ if (e.isDirectory() && !e.name.startsWith(".") && !IGNORE_DIRS.has(e.name)) {
60
+ walk(join(dir, e.name), depth + 1);
61
+ }
62
+ }
63
+ }
64
+ walk(projectDir, 0);
65
+
66
+ // Keep only the shallowest directory per language.
67
+ const byLanguage = new Map();
68
+ for (const f of found) {
69
+ const cur = byLanguage.get(f.language);
70
+ if (!cur || f.depth < cur.depth) byLanguage.set(f.language, f);
71
+ }
72
+ return [...byLanguage.values()]
73
+ .map(({ dir, language }) => ({ dir, language }))
74
+ .sort((a, b) => a.dir.localeCompare(b.dir));
75
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * workflow-engine — seeds CI quality workflows for `kj harden` (KJC-TSK-0557).
3
+ *
4
+ * Seed-if-absent into `.github/workflows/`: a workflow the user already wrote
5
+ * (no kj:managed marker) is left untouched and reported "skipped". kj-managed
6
+ * workflows refresh in place. Markers ride in YAML `#` comments.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { upsertManagedBlock } from "../utils/managed-markers.js";
13
+ import { extraWorkflowsFor, qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
14
+
15
+ const BLOCK_VERSION = 1;
16
+ const WORKFLOWS_DIR = join(".github", "workflows");
17
+
18
+ /** A package.json that ships code (not private, has bin or main) ⇒ publishable. */
19
+ export function isPublishableNpm(projectDir) {
20
+ const pkgPath = join(projectDir, "package.json");
21
+ if (!existsSync(pkgPath)) return false;
22
+ try {
23
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
24
+ return pkg.private !== true && Boolean(pkg.bin || pkg.main);
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ export function installWorkflows({
31
+ projectDir = process.cwd(),
32
+ language = null,
33
+ profile = "standard",
34
+ dryRun = false,
35
+ } = {}) {
36
+ const dir = join(projectDir, WORKFLOWS_DIR);
37
+ const quality = qualityWorkflowFor(language);
38
+ const extras = extraWorkflowsFor({ profile, publishable: isPublishableNpm(projectDir) });
39
+ const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras];
40
+ const results = [];
41
+ for (const wf of all) {
42
+ const target = join(dir, wf.file);
43
+ const label = join(WORKFLOWS_DIR, wf.file);
44
+ const exists = existsSync(target);
45
+ const source = exists ? readFileSync(target, "utf8") : "";
46
+ if (exists && !source.includes(`kj:managed:${wf.blockId}`)) {
47
+ results.push({ file: label, action: "skipped" });
48
+ continue;
49
+ }
50
+ const { content, action } = upsertManagedBlock({
51
+ source,
52
+ blockId: wf.blockId,
53
+ version: BLOCK_VERSION,
54
+ body: wf.body,
55
+ style: "hash",
56
+ });
57
+ if (!dryRun && action !== "unchanged") {
58
+ mkdirSync(dir, { recursive: true });
59
+ writeFileSync(target, content);
60
+ }
61
+ results.push({ file: label, action });
62
+ }
63
+ return { dryRun, workflows: results };
64
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * CI workflow templates for `kj harden` (KJC-TSK-0557).
3
+ *
4
+ * Generic, language-agnostic GitHub Actions that `kj harden` seeds into a
5
+ * target repo's `.github/workflows/`. Bodies are plain strings (NOT template
6
+ * literals) so the `${{ … }}` GitHub expressions survive verbatim.
7
+ */
8
+
9
+ // Blocks AI self-attribution in PR commit messages. Pure bash + grep, no Node.
10
+ // Pins github.base_ref to an env var (never interpolated into the run block).
11
+ export const NO_AI_ATTRIBUTION_WORKFLOW = [
12
+ "name: Block AI attribution",
13
+ "on:",
14
+ " pull_request:",
15
+ " branches: [main]",
16
+ "permissions:",
17
+ " contents: read",
18
+ "jobs:",
19
+ " scan:",
20
+ " runs-on: ubuntu-latest",
21
+ " steps:",
22
+ " - uses: actions/checkout@v4",
23
+ " with:",
24
+ " fetch-depth: 0",
25
+ " - name: Scan commit messages for AI attribution",
26
+ " env:",
27
+ " BASE_REF: ${{ github.base_ref }}",
28
+ " run: |",
29
+ ' msgs=$(git log --format=%B "origin/${BASE_REF}...HEAD" || true)',
30
+ " pat='co-authored-by:.*(claude|anthropic|openai|chatgpt|gpt-[0-9]|copilot|gemini|cursor|windsurf|codeium|deepseek)'",
31
+ " pat=\"$pat|(generated|written|authored|assisted|powered) (by|with|using) .*(claude|anthropic|openai|copilot|gemini)\"",
32
+ ' if printf "%s" "$msgs" | grep -qiE "$pat"; then',
33
+ ' echo "::error::AI attribution detected in commit messages"; exit 1',
34
+ " fi",
35
+ ' echo "AI attribution scan: clean"',
36
+ ].join("\n");
37
+
38
+ /** Universal workflows (every stack). style:hash ⇒ marker in YAML comments. */
39
+ export const WORKFLOWS = [
40
+ { file: "kj-no-ai-attribution.yml", blockId: "wf-no-ai", body: NO_AI_ATTRIBUTION_WORKFLOW },
41
+ ];
42
+
43
+ const header = (steps) =>
44
+ [
45
+ "name: Quality",
46
+ "on:",
47
+ " pull_request:",
48
+ " branches: [main]",
49
+ "jobs:",
50
+ " quality:",
51
+ " runs-on: ubuntu-latest",
52
+ " steps:",
53
+ " - uses: actions/checkout@v4",
54
+ ...steps,
55
+ ].join("\n");
56
+
57
+ const QUALITY_BY_LANGUAGE = {
58
+ javascript: header([
59
+ " - uses: actions/setup-node@v4",
60
+ " with:",
61
+ " node-version: 22",
62
+ " - run: npm ci",
63
+ " - run: npm run -s lint --if-present",
64
+ " - run: npm test",
65
+ ]),
66
+ python: header([
67
+ " - uses: actions/setup-python@v5",
68
+ " with:",
69
+ " python-version: '3.12'",
70
+ " - run: pip install ruff pytest",
71
+ " - run: ruff check .",
72
+ " - run: pytest",
73
+ ]),
74
+ go: header([
75
+ " - uses: actions/setup-go@v5",
76
+ " with:",
77
+ " go-version: stable",
78
+ " - run: go vet ./...",
79
+ " - run: go test ./...",
80
+ ]),
81
+ php: header([
82
+ " - uses: shivammathur/setup-php@v2",
83
+ " with:",
84
+ " php-version: '8.3'",
85
+ " - run: composer install --no-interaction --no-progress",
86
+ " - run: vendor/bin/phpstan analyse --no-progress",
87
+ " - run: vendor/bin/phpunit",
88
+ ]),
89
+ };
90
+ QUALITY_BY_LANGUAGE.typescript = QUALITY_BY_LANGUAGE.javascript;
91
+
92
+ /** Stack-aware Quality workflow entry, or null for an unknown language. */
93
+ export function qualityWorkflowFor(language) {
94
+ const body = QUALITY_BY_LANGUAGE[language];
95
+ return body ? { file: "kj-quality.yml", blockId: "wf-quality", body } : null;
96
+ }
97
+
98
+ // Caps the net LOC delta of a PR (strict profile only — it is opinionated).
99
+ export const SHRINK_BUDGET_WORKFLOW = [
100
+ "name: Shrink budget",
101
+ "on:",
102
+ " pull_request:",
103
+ " branches: [main]",
104
+ "permissions:",
105
+ " contents: read",
106
+ "jobs:",
107
+ " budget:",
108
+ " runs-on: ubuntu-latest",
109
+ ' env: { LOC_LIMIT: "200" }',
110
+ " steps:",
111
+ " - uses: actions/checkout@v4",
112
+ " with:",
113
+ " fetch-depth: 0",
114
+ " - name: Net LOC delta within budget",
115
+ " env:",
116
+ " BASE_REF: ${{ github.base_ref }}",
117
+ " run: |",
118
+ " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/*.md' ':!*.lock' ':!package-lock.json' || true)",
119
+ ' a=$(printf "%s\\n" "$d" | awk \'$1!="-"{s+=$1}END{print s+0}\')',
120
+ ' r=$(printf "%s\\n" "$d" | awk \'$2!="-"{s+=$2}END{print s+0}\')',
121
+ ' net=$((a - r)); echo "net=$net (limit=$LOC_LIMIT)"',
122
+ ' if [ "$net" -gt "$LOC_LIMIT" ]; then echo "::error::PR adds $net net lines (limit $LOC_LIMIT)"; exit 1; fi',
123
+ ].join("\n");
124
+
125
+ // Packs the tarball and installs it clean — only for publishable npm packages.
126
+ export const PACK_SMOKE_WORKFLOW = [
127
+ "name: Pack smoke",
128
+ "on:",
129
+ " pull_request:",
130
+ " branches: [main]",
131
+ "permissions:",
132
+ " contents: read",
133
+ "jobs:",
134
+ " pack-smoke:",
135
+ " runs-on: ubuntu-latest",
136
+ " steps:",
137
+ " - uses: actions/checkout@v4",
138
+ " - uses: actions/setup-node@v4",
139
+ " with:",
140
+ " node-version: 22",
141
+ " - run: npm ci",
142
+ " - name: Pack and install the tarball clean",
143
+ " run: |",
144
+ " tgz=$(npm pack --silent)",
145
+ " dir=$(mktemp -d)",
146
+ ' npm install --prefix "$dir" "$PWD/$tgz" --no-audit --no-fund',
147
+ ' echo "Tarball installs clean: $tgz"',
148
+ ].join("\n");
149
+
150
+ /** Conditional workflows: shrink-budget on strict, pack-smoke when publishable. */
151
+ export function extraWorkflowsFor({ profile = "standard", publishable = false } = {}) {
152
+ const extras = [];
153
+ if (profile === "strict") {
154
+ extras.push({ file: "kj-shrink-budget.yml", blockId: "wf-shrink", body: SHRINK_BUDGET_WORKFLOW });
155
+ }
156
+ if (publishable) {
157
+ extras.push({ file: "kj-pack-smoke.yml", blockId: "wf-pack-smoke", body: PACK_SMOKE_WORKFLOW });
158
+ }
159
+ return extras;
160
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Managed-markers — idempotent file-block scaffolding for `kj harden`
3
+ * (KJC-TSK-0555, design in docs/specs/quality-harness.md §4).
4
+ *
5
+ * harden owns ONLY the content between a pair of markers; everything else in
6
+ * the file is preserved verbatim. Re-running with the same input is a no-op.
7
+ * Generalises the `KJC-TSK-0455` marker already used by the post-merge hook
8
+ * installer, and honours the project rule "never overwrite existing files".
9
+ *
10
+ * Block shape (comment syntax varies by file type):
11
+ * # >>> kj:managed:<id> v<N> >>> (do not edit: regenerated by kj harden)
12
+ * …managed body…
13
+ * # <<< kj:managed:<id> <<<
14
+ */
15
+
16
+ const DEFAULT_NOTE = "do not edit: regenerated by kj harden";
17
+
18
+ /** Wrap marker payload text in the comment syntax for a given style. */
19
+ const COMMENT = {
20
+ hash: (text) => `# ${text}`,
21
+ slash: (text) => `// ${text}`,
22
+ html: (text) => `<!-- ${text} -->`,
23
+ };
24
+
25
+ /** Pick a comment style from a file path. Defaults to hash (shell/yaml/conf). */
26
+ export function commentStyleFor(filePath = "") {
27
+ if (/\.(md|markdown|mdx)$/i.test(filePath)) return "html";
28
+ if (/\.(m?[jt]sx?|c[jt]s)$/i.test(filePath)) return "slash";
29
+ return "hash";
30
+ }
31
+
32
+ const openPayload = (blockId, version) => `>>> kj:managed:${blockId} v${version} >>>`;
33
+ const closePayload = (blockId) => `<<< kj:managed:${blockId} <<<`;
34
+
35
+ /** Render the full managed block (open marker + body + close marker). */
36
+ export function buildManagedBlock({ blockId, version, body, style = "hash", note = DEFAULT_NOTE }) {
37
+ const comment = COMMENT[style] ?? COMMENT.hash;
38
+ const open = comment(`${openPayload(blockId, version)} (${note})`);
39
+ const close = comment(closePayload(blockId));
40
+ return `${open}\n${body}\n${close}`;
41
+ }
42
+
43
+ /**
44
+ * Locate a managed block by id in `source`, regardless of comment style or
45
+ * version. Returns line indices and the detected version, or { found:false }.
46
+ */
47
+ export function findManagedBlock(source = "", blockId) {
48
+ const lines = source.split("\n");
49
+ const openTag = `>>> kj:managed:${blockId} v`;
50
+ const closeTag = `<<< kj:managed:${blockId} <<<`;
51
+ let startLine = -1;
52
+ let version = null;
53
+ for (let i = 0; i < lines.length; i += 1) {
54
+ const at = lines[i].indexOf(openTag);
55
+ if (at === -1) continue;
56
+ const rest = lines[i].slice(at + openTag.length);
57
+ const end = rest.indexOf(" >>>");
58
+ const digits = end === -1 ? "" : rest.slice(0, end);
59
+ if (/^\d+$/.test(digits)) {
60
+ startLine = i;
61
+ version = Number(digits);
62
+ break;
63
+ }
64
+ }
65
+ if (startLine === -1) return { found: false };
66
+ for (let i = startLine + 1; i < lines.length; i += 1) {
67
+ if (lines[i].includes(closeTag)) return { found: true, startLine, endLine: i, version };
68
+ }
69
+ return { found: false, malformed: true, startLine };
70
+ }
71
+
72
+ /**
73
+ * Insert or update the managed block for `blockId` in `source`, preserving all
74
+ * content outside the markers. Idempotent: identical input → action "unchanged".
75
+ *
76
+ * @returns {{content: string, action: "inserted"|"updated"|"unchanged", previousVersion: number|null}}
77
+ */
78
+ export function upsertManagedBlock({ source = "", blockId, version, body, style = "hash", note }) {
79
+ const block = buildManagedBlock({ blockId, version, body, style, note });
80
+ const existing = findManagedBlock(source, blockId);
81
+
82
+ if (!existing.found) {
83
+ const base = source === "" ? "" : source.replace(/\n*$/, "\n");
84
+ return { content: `${base}${block}\n`, action: "inserted", previousVersion: null };
85
+ }
86
+
87
+ const lines = source.split("\n");
88
+ const before = lines.slice(0, existing.startLine);
89
+ const after = lines.slice(existing.endLine + 1);
90
+ const content = [...before, ...block.split("\n"), ...after].join("\n");
91
+ if (content === source) return { content, action: "unchanged", previousVersion: existing.version };
92
+ return { content, action: "updated", previousVersion: existing.version };
93
+ }