karajan-code 3.5.1 → 3.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.
@@ -91,17 +91,29 @@ async function askAgents(wizard, available, config, logger) {
91
91
  * `available.length === 1` short-circuits: every role uses the only CLI
92
92
  * available, no point asking.
93
93
  */
94
- async function askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger) {
94
+ async function askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger, ask = true) {
95
95
  if (available.length <= 1) return;
96
96
 
97
- logger.info("");
98
- logger.info("Configure provider per role (Enter accepts the default):");
99
-
100
97
  // Make sure containers exist for every role/pipeline key we touch —
101
98
  // older configs may have a sparser shape than DEFAULTS.
102
99
  config.roles = config.roles || {};
103
100
  config.pipeline = config.pipeline || {};
104
101
 
102
+ // KJC-TSK-0571: per-role provider selection is advanced. By default every
103
+ // role inherits its provider from coder/reviewer — a newcomer has no context
104
+ // to pick an agent for "refactorer" or "perf". Only init the containers.
105
+ if (!ask) {
106
+ for (const role of PER_ROLE_QUESTIONS) {
107
+ config.roles[role.key] = config.roles[role.key] || { provider: null, model: null };
108
+ if (role.pipelineKey) config.pipeline[role.pipelineKey] = config.pipeline[role.pipelineKey] || { enabled: false };
109
+ }
110
+ logger.info(" Agent routing: every role inherits from your coder/reviewer (default).");
111
+ return;
112
+ }
113
+
114
+ logger.info("");
115
+ logger.info("Configure provider per role (Enter accepts the default):");
116
+
105
117
  for (const role of PER_ROLE_QUESTIONS) {
106
118
  config.roles[role.key] = config.roles[role.key] || { provider: null, model: null };
107
119
  if (role.pipelineKey) {
@@ -150,14 +162,14 @@ async function askFallbackChain(wizard, available, config, { coder, reviewer },
150
162
  if (available.length < 2) return;
151
163
 
152
164
  logger.info("");
153
- logger.info("Plan B — fallback automático cuando un provider se queda sin créditos:");
165
+ logger.info("Plan B — automatic fallback when a provider runs out of credits:");
154
166
  const enable = await wizard.confirm(
155
- " ¿Configurar fallback? (default: 12h; si el cooldown supera ese tiempo, switch al provider alternativo)",
167
+ " Configure a fallback? (default 12h; if the cooldown exceeds it, switch to the alternate provider)",
156
168
  true
157
169
  );
158
170
  if (!enable) return;
159
171
 
160
- const maxHoursRaw = await wizard.input(" Máximo tiempo de espera antes de fallback (horas):", "12");
172
+ const maxHoursRaw = await wizard.input(" Max wait before falling back (hours):", "12");
161
173
  const maxHours = Number.parseFloat(maxHoursRaw) || 12;
162
174
 
163
175
  config.roles = config.roles || {};
@@ -168,18 +180,18 @@ async function askFallbackChain(wizard, available, config, { coder, reviewer },
168
180
  ];
169
181
  for (const role of KEY_ROLES) {
170
182
  config.roles[role.key] = config.roles[role.key] || {};
171
- const opts = [{ label: "Sin fallback (hibernar)", value: "__none__", available: true }];
183
+ const opts = [{ label: "No fallback (hibernate)", value: "__none__", available: true }];
172
184
  for (const a of available) {
173
185
  if (a.name === role.primary) continue;
174
186
  opts.push({ label: a.name, value: a.name, available: true });
175
187
  }
176
- const choice = await wizard.select(` Fallback de ${role.label} cuando ${role.primary} se quede sin créditos:`, opts);
188
+ const choice = await wizard.select(` Fallback for ${role.label} when ${role.primary} runs out of credits:`, opts);
177
189
  if (choice === "__none__") {
178
190
  delete config.roles[role.key].fallback;
179
- logger.info(` -> ${role.key}: sin fallback (hibernará)`);
191
+ logger.info(` -> ${role.key}: no fallback (will hibernate)`);
180
192
  } else {
181
193
  config.roles[role.key].fallback = { provider: choice, max_wait_hours: maxHours };
182
- logger.info(` -> ${role.key}: ${role.primary} → ${choice} (tras ${maxHours}h)`);
194
+ logger.info(` -> ${role.key}: ${role.primary} → ${choice} (after ${maxHours}h)`);
183
195
  }
184
196
  }
185
197
  }
@@ -206,7 +218,7 @@ async function askLanguages(wizard, config, logger) {
206
218
  ...allLangEntries.filter((o) => o.value !== detectedLocale),
207
219
  ];
208
220
 
209
- const pipelineLang = await wizard.select("Pipeline language:", languageOptions);
221
+ const pipelineLang = await wizard.select("Language for agent messages and reports:", languageOptions);
210
222
  config.language = pipelineLang;
211
223
  logger.info(` -> Pipeline language: ${pipelineLang}`);
212
224
 
@@ -214,14 +226,14 @@ async function askLanguages(wizard, config, logger) {
214
226
  ...allLangEntries.filter((o) => o.value === pipelineLang),
215
227
  ...allLangEntries.filter((o) => o.value !== pipelineLang),
216
228
  ];
217
- const huLang = await wizard.select("HU language (for user stories):", huLangOptions);
229
+ const huLang = await wizard.select("Language for user stories (HUs):", huLangOptions);
218
230
  config.hu_language = huLang;
219
- logger.info(` -> HU language: ${huLang}`);
231
+ logger.info(` -> User-story language: ${huLang}`);
220
232
  }
221
233
 
222
234
  async function askGitAutomation(wizard, config, logger) {
223
235
  config.git = config.git || {};
224
- const autoCommit = await wizard.confirm("Auto-commit per HU? (recommended for `kj run` so commits show up in git log)", false);
236
+ const autoCommit = await wizard.confirm("Auto-commit per user story? (recommended for `kj run` so commits show up in git log)", false);
225
237
  config.git.auto_commit = autoCommit;
226
238
  logger.info(` -> auto_commit: ${autoCommit}`);
227
239
 
@@ -268,30 +280,20 @@ async function askBoardSecurity(wizard, config, logger) {
268
280
  /**
269
281
  * Explicit, opt-in telemetry consent. We do NOT inherit a "Karajan-installed
270
282
  * users want to help" assumption — silence and missing config keys both mean
271
- * "no". The prompt is rendered in the OS locale (EN/ES today; falls back to
272
- * EN for everything else) so users see it in their own language even before
273
- * they've set `config.language`. See src/utils/telemetry.js and the
283
+ * "no". The prompt is English to match the rest of the wizard (KJC-BUG-0088).
284
+ * See src/utils/telemetry.js and the
274
285
  * "Privacy & Telemetry" section of README.md for what gets sent.
275
286
  */
276
287
  async function askTelemetry(wizard, config, logger) {
277
- const locale = detectOsLocale();
278
- const prompts = {
279
- es: {
280
- question: "¿Quieres ayudar a mejorar Karajan enviando telemetría anónima al proyecto?",
281
- detail: " (versión, OS, comandos, duración del pipeline — sin código, sin tareas, sin datos personales)",
282
- previewIntro: " Ejemplo del payload exacto que se enviaría al ejecutar `kj run` (puedes inspeccionar más con `kj telemetry preview`):",
283
- enabled: " -> Telemetría: activada (¡gracias!)",
284
- disabled: " -> Telemetría: desactivada",
285
- },
286
- en: {
287
- question: "Help improve Karajan by sending anonymous telemetry to the project?",
288
- detail: " (version, OS, commands, pipeline duration — no code, no tasks, no personal data)",
289
- previewIntro: " Example of the exact payload that would be sent when you run `kj run` (inspect more with `kj telemetry preview`):",
290
- enabled: " -> Telemetry: enabled (thank you!)",
291
- disabled: " -> Telemetry: disabled",
292
- },
288
+ // English to stay consistent with the rest of the wizard (KJC-BUG-0088); the
289
+ // user's chosen pipeline/HU language drives `kj run` output, not this prompt.
290
+ const t = {
291
+ question: "Help improve Karajan by sending anonymous telemetry to the project?",
292
+ detail: " (version, OS, commands, pipeline duration — no code, no tasks, no personal data)",
293
+ previewIntro: " Example of the exact payload sent when you run `kj run` (inspect more with `kj telemetry preview`):",
294
+ enabled: " -> Telemetry: enabled (thank you!)",
295
+ disabled: " -> Telemetry: disabled",
293
296
  };
294
- const t = prompts[locale] || prompts.en;
295
297
  logger.info(t.detail);
296
298
  const example = buildTelemetryPayload("cli_command", { version: "x.y.z", command: "run" });
297
299
  logger.info(t.previewIntro);
@@ -330,7 +332,7 @@ async function runWizard(config, logger) {
330
332
  try {
331
333
  const { coder, reviewer } = await askAgents(wizard, available, config, logger);
332
334
 
333
- const enableTriage = await wizard.confirm("Enable triage (auto-classify task complexity)?", false);
335
+ const enableTriage = await wizard.confirm("Enable triage (auto-pick a lighter pipeline for simple tasks)?", false);
334
336
  config.pipeline.triage = config.pipeline.triage || {};
335
337
  config.pipeline.triage.enabled = enableTriage;
336
338
  logger.info(` -> Triage: ${enableTriage ? "enabled" : "disabled"}`);
@@ -339,7 +341,7 @@ async function runWizard(config, logger) {
339
341
  config.sonarqube.enabled = enableSonar;
340
342
  logger.info(` -> SonarQube: ${enableSonar ? "enabled" : "disabled"}`);
341
343
 
342
- const enableHuBoard = await wizard.confirm("Enable HU Board for story tracking?", false);
344
+ const enableHuBoard = await wizard.confirm("Enable the HU Board — a local web UI to track user stories (HUs)?", false);
343
345
  config.hu_board = config.hu_board || {};
344
346
  config.hu_board.enabled = enableHuBoard;
345
347
  if (enableHuBoard) {
@@ -348,8 +350,19 @@ async function runWizard(config, logger) {
348
350
  }
349
351
  logger.info(` -> HU Board: ${enableHuBoard ? "enabled (auto-start on kj run)" : "disabled"}`);
350
352
 
351
- await askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger);
352
- await askFallbackChain(wizard, available, config, { coder, reviewer }, logger);
353
+ // KJC-TSK-0571: per-role providers + fallback chains are advanced and
354
+ // collapse a newcomer's first run by ~12 questions. Ask one gate; default
355
+ // off → every role inherits from coder/reviewer.
356
+ const advancedRouting =
357
+ available.length > 1 &&
358
+ (await wizard.confirm(
359
+ "Configure advanced agent routing (a different AI per role + fallback chains)? Most users don't need this.",
360
+ false
361
+ ));
362
+ await askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger, advancedRouting);
363
+ if (advancedRouting) {
364
+ await askFallbackChain(wizard, available, config, { coder, reviewer }, logger);
365
+ }
353
366
  await askMethodology(wizard, config, logger);
354
367
  await askLanguages(wizard, config, logger);
355
368
 
@@ -686,16 +699,24 @@ export async function resolveConfigScope({ flags, interactive }) {
686
699
  if (flags?.global) return { configPath: getConfigPath(), scope: "global" };
687
700
  if (flags?.local) return { configPath: getProjectConfigPath(process.cwd()), scope: "local" };
688
701
  if (interactive) {
702
+ const globalPath = getConfigPath();
703
+ // KJC-BUG-0087: on a true first run (no global config yet) there is no
704
+ // meaningful choice — set up the global config and skip a cold question a
705
+ // newcomer has no context to answer. Only when a global already exists is
706
+ // "override just for this project?" an actual decision worth asking.
707
+ if (!(await exists(globalPath))) {
708
+ return { configPath: globalPath, scope: "global" };
709
+ }
689
710
  const wizard = createWizard();
690
711
  try {
691
712
  const choice = await wizard.select(
692
- "Where do you want to save the config? (default: global)",
713
+ "You already have a global Karajan config. For this project, do you want to…",
693
714
  [
694
- { value: "global", label: `Global (${getConfigPath()} — applies to all projects)` },
695
- { value: "local", label: `Local (${getProjectConfigPath(process.cwd())} — overrides global for this project only)` },
715
+ { value: "global", label: "Use the global config (your default for every project)" },
716
+ { value: "local", label: `Override it just for this project (${getProjectConfigPath(process.cwd())})` },
696
717
  ]
697
718
  );
698
- return { configPath: choice === "local" ? getProjectConfigPath(process.cwd()) : getConfigPath(), scope: choice };
719
+ return { configPath: choice === "local" ? getProjectConfigPath(process.cwd()) : globalPath, scope: choice };
699
720
  } finally {
700
721
  wizard.close();
701
722
  }
@@ -717,7 +738,11 @@ export async function initCommand({ logger, flags = {} }) {
717
738
  const reviewRulesPath = path.resolve(karajanDir, "review-rules.md");
718
739
  const coderRulesPath = path.resolve(karajanDir, "coder-rules.md");
719
740
 
720
- const { config, exists: configExists } = await loadConfig();
741
+ const { config } = await loadConfig();
742
+ // KJC-BUG-0087: ask "reconfigure?" only when a config exists AT THE CHOSEN
743
+ // scope path — not when the global cascade has one. Picking local scope in a
744
+ // fresh dir must not report the unrelated global config as "already exists".
745
+ const configExists = await exists(configPath);
721
746
 
722
747
  // Auto-detect language from OS locale for non-interactive mode
723
748
  if (!interactive && !configExists) {
@@ -807,7 +832,7 @@ export async function initCommand({ logger, flags = {} }) {
807
832
  await installAiTrashHook(logger);
808
833
  } else {
809
834
  logger.warn("ai-trash kj-trash binary not found — destructive ops unprotected.");
810
- logger.warn(" Reinstall karajan-code so kj-trash is on PATH.");
835
+ logger.warn(" Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH.");
811
836
  }
812
837
  }
813
838
 
@@ -864,6 +889,13 @@ export async function initCommand({ logger, flags = {} }) {
864
889
  const version = JSON.parse(readFileSync(pkgPath, "utf8")).version;
865
890
  sendTelemetryEvent("install", { version }, config).catch(() => {});
866
891
  } catch { /* non-blocking */ }
892
+
893
+ // Clear close so a first-time user knows setup finished and what to do next,
894
+ // instead of being left at the end of a wall of detection logs (KJC-BUG-0088).
895
+ logger.info("");
896
+ logger.info("✓ Karajan is set up in this project.");
897
+ logger.info(' Next: kj run "describe what to build or fix"');
898
+ logger.info(" More: kj --help · kj doctor (check your setup)");
867
899
  }
868
900
 
869
901
  // Internal exports for unit testing (KJC-TSK-0367)
@@ -0,0 +1,168 @@
1
+ /**
2
+ * advisory — per-artifact comparison engine for `kj harden` advisory mode
3
+ * (KJC-TSK-0565, épica KJC-PCS-0060). For each artifact harden manages it
4
+ * classifies the repo state: MISSING (no file nor equivalent), KJ_MANAGED (a
5
+ * kj:managed block, up to date or outdated vN) or USER_OWNED (the user's own,
6
+ * possibly in another format). Read-only; never writes or prints. The result
7
+ * feeds `--report` (Advisory B) / `--interactive` (Advisory C). Per-type
8
+ * improvement heuristics for USER_OWNED land in the follow-up slice (PR2).
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { findManagedBlock } from "../utils/managed-markers.js";
15
+ import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
16
+ import { detectStackRoots } from "./stack-roots.js";
17
+
18
+ const BLOCK_VERSION = 1; // current version every markered config ships at
19
+
20
+ // Other filenames/formats for the same artifact, so kj never reports a false
21
+ // "missing". `pkgKey` is a package.json field that can hold inline config.
22
+ const EQUIVALENTS = {
23
+ commitlint: { files: [".commitlintrc", ".commitlintrc.js", ".commitlintrc.json", ".commitlintrc.yaml"], pkgKey: "commitlint" },
24
+ eslint: { files: ["eslint.config.mjs", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml"], pkgKey: "eslintConfig" },
25
+ prettier: { files: [".prettierrc", ".prettierrc.js", ".prettierrc.yaml", "prettier.config.js"], pkgKey: "prettier" },
26
+ };
27
+
28
+ // Concrete gains kj's standard would add to a USER_OWNED config, detected by
29
+ // substring probes (no AST parse): each probe absent from the user's content
30
+ // becomes one improvement. Heuristic by design — conservative, never a false
31
+ // "you're missing this" when the signal is clearly present.
32
+ const IMPROVEMENTS = {
33
+ eslint: [
34
+ { needle: "no-var", id: "no-var", detail: "bans var (ES2025: const/let)" },
35
+ { needle: "no-restricted-globals", id: "no-legacy-globals", detail: "bans alert/confirm/prompt, escape/unescape" },
36
+ { needle: "no-restricted-properties", id: "no-legacy-apis", detail: "bans document.write, substr" },
37
+ ],
38
+ commitlint: [
39
+ { needle: "header-max-length", id: "header-max-length", detail: "caps the commit header at 100 chars" },
40
+ { needle: "subject-case", id: "subject-case", detail: "enforces a lowercase subject" },
41
+ ],
42
+ prettier: [{ needle: "printWidth", id: "print-width", detail: "pins printWidth (consistent wrapping)" }],
43
+ editorconfig: [
44
+ { needle: "insert_final_newline", id: "final-newline", detail: "enforces a final newline" },
45
+ { needle: "charset", id: "charset", detail: "pins charset = utf-8" },
46
+ { needle: "trim_trailing_whitespace", id: "trim-trailing", detail: "trims trailing whitespace" },
47
+ ],
48
+ };
49
+
50
+ /** Improvements kj would add to the user's own config for `artifactId`. */
51
+ function improvementsFor(artifactId, content) {
52
+ return (IMPROVEMENTS[artifactId] ?? [])
53
+ .filter((probe) => !content.includes(probe.needle))
54
+ .map(({ id, detail }) => ({ id, detail }));
55
+ }
56
+
57
+ /** Build an artifact descriptor from a config-template entry. */
58
+ function toArtifact(cfg) {
59
+ const id = cfg.blockId ?? "prettier"; // the only marker-less JSON config is prettier
60
+ return {
61
+ id,
62
+ file: cfg.file,
63
+ blockId: cfg.blockId ?? null,
64
+ currentVersion: cfg.blockId ? BLOCK_VERSION : null,
65
+ equivalents: EQUIVALENTS[id]?.files ?? [],
66
+ pkgKey: EQUIVALENTS[id]?.pkgKey ?? null,
67
+ };
68
+ }
69
+
70
+ /** Locate the user's file for an artifact under `searchDir`; null if absent. */
71
+ function findArtifactFile(searchDir, artifact) {
72
+ for (const rel of [artifact.file, ...artifact.equivalents]) {
73
+ if (existsSync(join(searchDir, rel))) return rel;
74
+ }
75
+ if (artifact.pkgKey && existsSync(join(searchDir, "package.json"))) {
76
+ try {
77
+ if (JSON.parse(readFileSync(join(searchDir, "package.json"), "utf8"))[artifact.pkgKey] != null) {
78
+ return `package.json#${artifact.pkgKey}`;
79
+ }
80
+ } catch {
81
+ /* unreadable package.json → not a match */
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** Read the artifact body, or the JSON of an inline package.json key. */
88
+ function readArtifactContent(searchDir, foundAt) {
89
+ const [file, key] = foundAt.split("#");
90
+ const raw = readFileSync(join(searchDir, file), "utf8");
91
+ if (!key) return raw;
92
+ try {
93
+ return JSON.stringify(JSON.parse(raw)[key] ?? null);
94
+ } catch {
95
+ return raw;
96
+ }
97
+ }
98
+
99
+ /** Map a classified state to a recommendation + human rationale. */
100
+ function recommend(state) {
101
+ if (state.status === "MISSING") return { recommendation: "install", rationale: `kj would add ${state.file}`, mergeable: true };
102
+ if (state.status === "USER_OWNED") {
103
+ const n = state.improvements?.length ?? 0;
104
+ return n === 0
105
+ ? { recommendation: "keep", rationale: "your own config — already covers the kj standard", mergeable: false }
106
+ : { recommendation: "review", rationale: `your own config — kj could add ${n} improvement(s)`, mergeable: true };
107
+ }
108
+ if (state.upToDate) return { recommendation: "keep", rationale: `managed by kj (v${state.managedVersion}), up to date`, mergeable: false };
109
+ return { recommendation: "update", rationale: `kj v${state.currentVersion} available (you have v${state.managedVersion})`, mergeable: true };
110
+ }
111
+
112
+ /** Classify a single artifact found (or not) under `searchDir`. */
113
+ export function classifyArtifact(searchDir, artifact) {
114
+ const foundAt = findArtifactFile(searchDir, artifact);
115
+ const base = { foundAt, managedVersion: null, upToDate: null, improvements: [] };
116
+ let state = { ...base, status: "MISSING" };
117
+ if (foundAt) {
118
+ const content = readArtifactContent(searchDir, foundAt);
119
+ if (artifact.blockId && content.includes(`kj:managed:${artifact.blockId}`)) {
120
+ const { version } = findManagedBlock(content, artifact.blockId);
121
+ state = { ...base, status: "KJ_MANAGED", managedVersion: version, upToDate: version != null && version >= artifact.currentVersion };
122
+ } else {
123
+ state = { ...base, status: "USER_OWNED", improvements: improvementsFor(artifact.id, content) };
124
+ }
125
+ }
126
+ const head = { id: artifact.id, file: artifact.file, currentVersion: artifact.currentVersion };
127
+ return { ...head, ...state, ...recommend({ ...state, ...head }) };
128
+ }
129
+
130
+ /**
131
+ * Compare the whole repo against the kj standard, artifact by artifact:
132
+ * universal configs at the root, then each language's lint config inside its
133
+ * own root (monorepo-aware, mirroring installConfigsForRoots). `minimal`
134
+ * profile ships no config, so there is nothing to compare.
135
+ */
136
+ export function compareHarden({ projectDir = process.cwd(), profile = "standard", only = [], exclude = [] } = {}) {
137
+ if (profile === "minimal") return { artifacts: [] };
138
+ const artifacts = UNIVERSAL_CONFIGS.map((cfg) => ({ dir: ".", ...classifyArtifact(projectDir, toArtifact(cfg)) }));
139
+ for (const { dir, language } of detectStackRoots(projectDir, { only, exclude })) {
140
+ const searchDir = dir === "." ? projectDir : join(projectDir, dir);
141
+ for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
142
+ const res = classifyArtifact(searchDir, toArtifact(cfg));
143
+ artifacts.push({ dir, ...res, foundAt: res.foundAt && dir !== "." ? join(dir, res.foundAt) : res.foundAt });
144
+ }
145
+ }
146
+ return { artifacts };
147
+ }
148
+
149
+ const MARK = { install: "+", update: "↑", review: "~", keep: "✓" };
150
+
151
+ /**
152
+ * Render a compareHarden() result as human-readable lines (Advisory B). Pure:
153
+ * returns an array of strings, prints nothing. One line per artifact, indented
154
+ * bullets for each improvement, then a one-line tally.
155
+ */
156
+ export function formatAdvisoryReport({ artifacts = [] } = {}) {
157
+ if (artifacts.length === 0) return ["kj harden — nothing to compare (minimal profile or empty repo)."];
158
+ const lines = ["kj harden — advisory report (read-only)", ""];
159
+ const tally = { install: 0, update: 0, review: 0, keep: 0 };
160
+ for (const a of artifacts) {
161
+ tally[a.recommendation] = (tally[a.recommendation] ?? 0) + 1;
162
+ const path = a.foundAt ?? (a.dir === "." ? a.file : join(a.dir, a.file));
163
+ lines.push(` ${MARK[a.recommendation] ?? "·"} ${a.recommendation.padEnd(7)} ${path} — ${a.rationale}`);
164
+ for (const imp of a.improvements ?? []) lines.push(` • ${imp.detail}`);
165
+ }
166
+ lines.push("", `${artifacts.length} artifact(s) · ${tally.install} missing · ${tally.review} to review · ${tally.update} outdated · ${tally.keep} ok`);
167
+ return lines;
168
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * interactive — `kj harden --interactive` (KJC-TSK-0567, épica KJC-PCS-0060).
3
+ *
4
+ * Walks the advisory comparison (Advisory A/B) and asks, artifact by artifact,
5
+ * whether to apply the kj standard — with a safe default of leaving the user's
6
+ * own config untouched. Only the pieces the user says yes to are written:
7
+ *
8
+ * MISSING → add it? (default yes — same as seed-if-absent)
9
+ * KJ_MANAGED outdated → update it? (default yes)
10
+ * USER_OWNED w/ gaps → adopt kj's? (default NO — keep yours)
11
+ *
12
+ * The full merge (user's config + kj rules) is out of scope here (v2): adopt
13
+ * replaces the file with kj's standard. `ask` is injected so the flow is
14
+ * testable without a TTY; the CLI passes a readline-backed confirm.
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+
20
+ import { upsertManagedBlock } from "../utils/managed-markers.js";
21
+ import { compareHarden } from "./advisory.js";
22
+ import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
23
+
24
+ const BLOCK_VERSION = 1; // mirrors config-engine.js
25
+
26
+ // Artifact id (blockId, or "prettier" for the marker-less JSON) → its template.
27
+ const TEMPLATE_BY_ID = new Map();
28
+ for (const cfg of [...UNIVERSAL_CONFIGS, ...Object.values(CONFIGS_BY_LANGUAGE).flat()]) {
29
+ TEMPLATE_BY_ID.set(cfg.blockId ?? "prettier", cfg);
30
+ }
31
+
32
+ /** What to ask (and do) for one classified artifact; null = nothing to offer. */
33
+ export function decideAction(a) {
34
+ if (a.recommendation === "install") return { action: "add", defaultYes: true, prompt: `Add ${a.file}? (${a.rationale})` };
35
+ if (a.recommendation === "update") return { action: "update", defaultYes: true, prompt: `Update ${a.file} to kj v${a.currentVersion}?` };
36
+ if (a.recommendation === "review") {
37
+ return { action: "adopt", defaultYes: false, prompt: `Adopt kj's ${a.file}? Replaces yours, adds ${a.improvements.length} improvement(s). Default keeps yours.` };
38
+ }
39
+ return null; // keep / up-to-date — nothing to ask
40
+ }
41
+
42
+ /** Write an artifact's kj body. `overwrite` discards the user's file (adopt). */
43
+ function applyArtifact(projectDir, dir, cfg, { overwrite }) {
44
+ const target = join(projectDir, dir === "." ? "" : dir, cfg.file);
45
+ mkdirSync(dirname(target), { recursive: true });
46
+ if (cfg.json) {
47
+ writeFileSync(target, `${cfg.body}\n`);
48
+ return;
49
+ }
50
+ const source = !overwrite && existsSync(target) ? readFileSync(target, "utf8") : "";
51
+ const { content } = upsertManagedBlock({ source, blockId: cfg.blockId, version: BLOCK_VERSION, body: cfg.body, style: cfg.style });
52
+ writeFileSync(target, content);
53
+ }
54
+
55
+ /**
56
+ * Run the interactive adoption flow. `ask(prompt, defaultYes) → Promise<bool>`.
57
+ * Returns the list of applied artifacts. Writes nothing the user declines.
58
+ */
59
+ export async function interactiveHarden({ projectDir = process.cwd(), profile = "standard", only = [], exclude = [], ask, logger = console } = {}) {
60
+ const { artifacts } = compareHarden({ projectDir, profile, only, exclude });
61
+ const applied = [];
62
+ for (const a of artifacts) {
63
+ const plan = decideAction(a);
64
+ if (!plan) continue;
65
+ const yes = await ask(plan.prompt, plan.defaultYes);
66
+ if (!yes) continue;
67
+ const cfg = TEMPLATE_BY_ID.get(a.id);
68
+ if (!cfg) continue;
69
+ applyArtifact(projectDir, a.dir, cfg, { overwrite: plan.action === "adopt" });
70
+ const where = a.dir === "." ? a.file : join(a.dir, a.file);
71
+ applied.push({ file: where, action: plan.action });
72
+ logger.info?.(` ✓ ${plan.action} ${where}`);
73
+ }
74
+ if (applied.length === 0) logger.info?.("kj harden — nothing applied (kept everything as-is).");
75
+ return { ok: true, interactive: true, applied };
76
+ }
@@ -21,9 +21,38 @@ const IGNORE_DIRS = new Set([
21
21
  "target",
22
22
  "__pycache__",
23
23
  "venv",
24
+ // Auxiliary code that isn't the app to harden (KJC-TSK-0564): demo/fixture
25
+ // dirs ship their own throwaway manifests and must not seed lint/CI config.
26
+ "examples",
27
+ "example",
28
+ "fixtures",
29
+ "samples",
30
+ "third_party",
31
+ "testdata",
32
+ "__fixtures__",
24
33
  ]);
25
34
  const DEFAULT_MAX_DEPTH = 2;
26
35
 
36
+ /**
37
+ * Minimal glob for harden scope flags — no dynamic RegExp (eslint security).
38
+ * Matches on exact equality, directory prefix (`wrappers` ⊇ `wrappers/python`)
39
+ * and `*` wildcards anchored at both ends unless the `*` is leading/trailing.
40
+ */
41
+ export function matchesGlob(value, pattern) {
42
+ if (value === pattern || value.startsWith(`${pattern}/`)) return true;
43
+ if (!pattern.includes("*")) return false;
44
+ const pieces = pattern.split("*");
45
+ let idx = 0;
46
+ for (let i = 0; i < pieces.length; i += 1) {
47
+ const piece = pieces[i];
48
+ if (!piece) continue;
49
+ const at = value.indexOf(piece, idx);
50
+ if (at === -1 || (i === 0 && at !== 0)) return false;
51
+ idx = at + piece.length;
52
+ }
53
+ return pattern.endsWith("*") || value.length === idx;
54
+ }
55
+
27
56
  /** Identify the language of a single directory by its manifest files. */
28
57
  export function languageAt(dir) {
29
58
  if (existsSync(join(dir, "package.json"))) {
@@ -43,7 +72,7 @@ export function languageAt(dir) {
43
72
  return null;
44
73
  }
45
74
 
46
- export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH } = {}) {
75
+ export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH, only = [], exclude = [] } = {}) {
47
76
  const found = [];
48
77
  function walk(dir, depth) {
49
78
  const language = languageAt(dir);
@@ -69,7 +98,8 @@ export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH } =
69
98
  const cur = byLanguage.get(f.language);
70
99
  if (!cur || f.depth < cur.depth) byLanguage.set(f.language, f);
71
100
  }
72
- return [...byLanguage.values()]
73
- .map(({ dir, language }) => ({ dir, language }))
74
- .sort((a, b) => a.dir.localeCompare(b.dir));
101
+ let roots = [...byLanguage.values()].map(({ dir, language }) => ({ dir, language }));
102
+ if (only.length) roots = roots.filter((r) => only.some((o) => matchesGlob(r.dir, o)));
103
+ if (exclude.length) roots = roots.filter((r) => !exclude.some((g) => matchesGlob(r.dir, g)));
104
+ return roots.sort((a, b) => a.dir.localeCompare(b.dir));
75
105
  }
@@ -8,7 +8,10 @@ export async function detectSqueezr() {
8
8
  try {
9
9
  const result = await runCommand("squeezr", ["--version"]);
10
10
  if (result.exitCode === 0) {
11
- const version = (result.stdout || "").trim() || null;
11
+ // `squeezr --version` may append an "update available" banner box; keep
12
+ // just the semver so callers never render that noise (KJC-BUG-0088).
13
+ const raw = (result.stdout || "").trim();
14
+ const version = raw.match(/\d+\.\d+\.\d+[\w.-]*/)?.[0] || raw.split("\n")[0].trim() || null;
12
15
  return { available: true, version };
13
16
  }
14
17
  return { available: false, version: null };