karajan-code 2.19.4 → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/package.json +1 -1
- package/src/cli/register-meta.js +12 -0
- package/src/cli/register-pipeline.js +2 -0
- package/src/cli/register-plan.js +4 -0
- package/src/commands/init.js +34 -3
- package/src/commands/onboard.js +35 -0
- package/src/commands/plan/generate.js +29 -1
- package/src/config/loader.js +12 -0
- package/src/onboarder/cache.js +24 -0
- package/src/onboarder/collectors/index.js +119 -0
- package/src/plan/preflight-hu.js +102 -0
- package/src/roles/onboarder-role.js +61 -0
- package/templates/roles/onboarder.md +36 -0
package/README.md
CHANGED
|
@@ -345,6 +345,34 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
|
|
|
345
345
|
|
|
346
346
|
## Recent releases
|
|
347
347
|
|
|
348
|
+
> **v2.21.0 released** — Minor. **Brownfield Onboarder role**. Karajan now ships a dedicated path to analyze any existing codebase and produce a Markdown Architecture Brief that the planner can consume as automatic context. Closes KJC-TSK-0384 (3 PRs).
|
|
349
|
+
>
|
|
350
|
+
> KJC-TSK-0384 (PRs #804 + #805 + #806): **`kj onboard`** runs five deterministic collectors over a project root — directory walk (ignoring `node_modules` / `.git` / `dist` / `build`), git log (commits, branches, hot files via `--name-only` over the last 200 commits), 18 well-known config patterns + `package.json` scripts, ADR-style filenames under `docs/adr/`, `docs/adrs/`, `docs/architecture/`, plus a one-shot bundle. Then optionally synthesises a Markdown Architecture Brief via the new OnboarderRole. Output lands at `~/.karajan/onboarding/<slug>.md`. Flags: `--no-synth` (skip the LLM call, dump the raw collectors — useful for CI), `--output <path>` (override default target). Greenfield projects produce `# Project is greenfield` instead of erroring.
|
|
351
|
+
>
|
|
352
|
+
> **New `--use-onboarding` flag on `kj plan generate`**: reads the cached Architecture Brief and prepends it to the planner context under a `## Architecture Brief (from kj onboard)` heading. Silent on cache miss without the flag; loud `warn` when the flag is set but no cache exists, so a missed `kj onboard` invocation surfaces immediately.
|
|
353
|
+
>
|
|
354
|
+
> Workflow:
|
|
355
|
+
>
|
|
356
|
+
> ```bash
|
|
357
|
+
> kj onboard # produces ~/.karajan/onboarding/<slug>.md once
|
|
358
|
+
> kj plan generate task.md \
|
|
359
|
+
> --use-onboarding # next plan uses it as context
|
|
360
|
+
> ```
|
|
361
|
+
>
|
|
362
|
+
> **What's next** — The **Project RAG** epic (KJC-PCS-0049) starts in v2.22.0: vector store + Ollama embedder + indexer + retriever + CLI / MCP / HU Board consumers. Onboarder is its prerequisite (the brief feeds the indexer's first pass).
|
|
363
|
+
>
|
|
364
|
+
> **v2.20.0 released** — Minor. **HU Board polish + UX papercuts** cluster: 5 cards closed across two net-new features, two PG housekeeping syncs for work that had already landed, and one docs refresh.
|
|
365
|
+
>
|
|
366
|
+
> KJC-TSK-0397 (PR #801): **`kj plan generate` now prepends a `[PREFLIGHT-000]` HU to every plan** and gates every functional HU on it. The HU's acceptance tests are stack-aware shell commands — Node gets `node --version` + `npm install` + conditional `npm test` / `npm run lint`; Python gets `python --version` + `pip install -r requirements.txt` (or `poetry install`) + `pytest --collect-only`; Firebase projects get `firebase projects:list`; GCP projects get `gcloud auth list`; everyone gets `git status --porcelain`. Idempotent: a plan that already has a HU titled `PREFLIGHT-000` / "verificar entorno" is left untouched. Opt out per-invocation with `--no-preflight-hu`. The point: Karajan owns the plumbing, the user no longer has to remember to add a 'verify env' step to every task file.
|
|
367
|
+
>
|
|
368
|
+
> KJC-TSK-0395 (PR #802): **`kj init` learns a config scope wizard plus `--global` / `--local` flags**. The wizard now asks where the config should land — `~/.karajan/kj.config.yml` (global) or `./.karajan/kj.config.yml` (local override). `loadConfig` refuses to load a project that has a local config without a global counterpart with an actionable message — the override-on-top-of-base invariant. Use case: a repo with `coder=claude`, another with `coder=opencode`, without editing YAML by hand.
|
|
369
|
+
>
|
|
370
|
+
> KJC-TSK-0396 (PG sync, originally PRs #702 + #703): **HU Board `⏹ Stop` button** aborts every `kj run` associated with a plan via SIGTERM → SIGKILL escalation, resets running HUs to pending. Already shipped in v2.10.x; today's release closes the PG card with the canonical commits as evidence.
|
|
371
|
+
>
|
|
372
|
+
> KJC-TSK-0377 (PG sync, originally PR #683): **auto-cleanup ampliado** to also catch `s_*`, `plan-*`, `auto-tmp_*`, `auto-test_*` prefixes during the boot ephemeral-project sweep. Already shipped in v2.12.x.
|
|
373
|
+
>
|
|
374
|
+
> KJC-TSK-0385 (PR #800): **`docs/task-templates/spec-conventions.md` gains Section 8 (`spec_section` REQUIRED when numbered SPEC headings present) and Section 9 (`acceptance_tests` shape, 2-4 tests, gherkin + shell mix)**. Plus `plan-generate.md` switches two stale `~/.kj/plans/` paths to `~/.karajan/plans/`.
|
|
375
|
+
>
|
|
348
376
|
> **v2.19.4 released** — Patch. **`kj resume` now continues from where it stopped** + **autoInit no longer commits zombies on the user's main**. Two bugs closed in one release.
|
|
349
377
|
>
|
|
350
378
|
> KJC-BUG-0058 (PR #798, reported by [@aitormf](https://github.com/aitormf)): a session that stopped during Sonar would re-run the full pre-loop on `kj resume <id>` — HU-reviewer, intent, discover, triage, domainCurator, **researcher, architect, planner** all from scratch — doubling token cost and breaking the value-prop of the command. Root cause: `resumeFlow` (flow-runner.js:280) called `runFlow` without rehydrating stage state, and the session never persisted stage outputs in the first place. **Fix**: two new mutators in `src/session/mutators.js` (`setStageResult` mirrors into `stage_results[name]` + `stages_completed[]`; `setStageBundle` adds `stage_bundles[name]` for cross-stage context like `researchContext`, `architectContext`, `plannedTask`). Two closures inside `runPreLoopStages` (`persistStage` + `resumeSkip`) wrap every cacheable stage. `init-context.js` rehydrates `ctx.stageResults` from the loaded session before invoking the pre-loop. Triage is *not* skipped on resume — it produces `roleOverrides` the Brain decisor depends on and is cheap to re-run.
|
package/package.json
CHANGED
package/src/cli/register-meta.js
CHANGED
|
@@ -2,6 +2,7 @@ import { discoverCommand } from "../commands/discover.js";
|
|
|
2
2
|
import { triageCommand } from "../commands/triage.js";
|
|
3
3
|
import { researcherCommand } from "../commands/researcher.js";
|
|
4
4
|
import { architectCommand } from "../commands/architect.js";
|
|
5
|
+
import { onboardCommand } from "../commands/onboard.js";
|
|
5
6
|
import { auditCommand } from "../commands/audit.js";
|
|
6
7
|
import { resumeCommand } from "../commands/resume.js";
|
|
7
8
|
import { boardCommand } from "../commands/board.js";
|
|
@@ -86,6 +87,17 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
86
87
|
});
|
|
87
88
|
});
|
|
88
89
|
|
|
90
|
+
program
|
|
91
|
+
.command("onboard")
|
|
92
|
+
.description("Analyze an existing codebase (brownfield) and produce an Architecture Brief")
|
|
93
|
+
.option("--no-synth", "Skip the LLM synthesis step, dump raw collectors only")
|
|
94
|
+
.option("--output <path>", "Override the default ~/.karajan/onboarding/<slug>.md target")
|
|
95
|
+
.action(async (flags) => {
|
|
96
|
+
await withConfig(pkgVersion, "onboard", flags, async ({ config, logger }) => {
|
|
97
|
+
await onboardCommand({ config, logger, flags });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
89
101
|
program
|
|
90
102
|
.command("audit")
|
|
91
103
|
.description("Analyze codebase health (read-only)")
|
|
@@ -23,6 +23,8 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
23
23
|
.description("Initialize config, review rules and SonarQube")
|
|
24
24
|
.option("--no-interactive", "Skip wizard, use defaults (for CI/scripts)")
|
|
25
25
|
.option("--scaffold-ci", "Scaffold Karajan CI Gateway workflow files")
|
|
26
|
+
.option("--global", "Save config to ~/.karajan/kj.config.yml (skip the scope wizard)")
|
|
27
|
+
.option("--local", "Save config to ./.karajan/kj.config.yml (skip the scope wizard)")
|
|
26
28
|
.action(async (flags) => {
|
|
27
29
|
await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
|
|
28
30
|
await initCommand({ logger, flags });
|
package/src/cli/register-plan.js
CHANGED
|
@@ -42,6 +42,8 @@ export function registerPlan(program, { pkgVersion }) {
|
|
|
42
42
|
.option("--no-plan-review", "Skip the high-level plan reviewer pass (gaps / deps / overlap / order)")
|
|
43
43
|
.option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
|
|
44
44
|
.option("--quick", "Sketch mode — skip every quality pass after the initial planner call")
|
|
45
|
+
.option("--no-preflight-hu", "Skip auto-injection of [PREFLIGHT-000] HU at the top of the generated plan")
|
|
46
|
+
.option("--use-onboarding", "Inject the cached Architecture Brief (~/.karajan/onboarding/<slug>.md) into the planner context")
|
|
45
47
|
.option("-y, --yes", "Skip the project-name prompt (use the auto-derived default).")
|
|
46
48
|
.option("--no-interactive", "Force non-interactive mode (no prompts, use defaults).")
|
|
47
49
|
.action(async (task, flags) => {
|
|
@@ -100,6 +102,8 @@ export function registerPlan(program, { pkgVersion }) {
|
|
|
100
102
|
quick: Boolean(flags.quick),
|
|
101
103
|
yes: Boolean(flags.yes),
|
|
102
104
|
interactive: flags.interactive,
|
|
105
|
+
preflightHu: flags.preflightHu !== false, // KJC-TSK-0397
|
|
106
|
+
useOnboarding: Boolean(flags.useOnboarding), // KJC-TSK-0384 PR 3
|
|
103
107
|
},
|
|
104
108
|
});
|
|
105
109
|
});
|
package/src/commands/init.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { getConfigPath, loadConfig, writeConfig } from "../config.js";
|
|
4
|
+
import { getConfigPath, getProjectConfigPath, loadConfig, writeConfig } from "../config.js";
|
|
5
5
|
import { sonarUp, checkVmMaxMapCount } from "../sonar/manager.js";
|
|
6
6
|
import { exists, ensureDir } from "../utils/fs.js";
|
|
7
7
|
import { getKarajanHome } from "../utils/paths.js";
|
|
@@ -579,19 +579,50 @@ async function ensureGitignoreEntries(projectDir, logger) {
|
|
|
579
579
|
logger.info(`Added to .gitignore: ${missing.map(e => e.pattern).join(", ")}`);
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
+
/**
|
|
583
|
+
* KJC-TSK-0395: decide where the config is written based on the flags
|
|
584
|
+
* and (when interactive) a wizard prompt. Returns { configPath, scope }.
|
|
585
|
+
* Throws when both --global and --local are passed (mutually exclusive).
|
|
586
|
+
*/
|
|
587
|
+
export async function resolveConfigScope({ flags, interactive }) {
|
|
588
|
+
if (flags?.global && flags?.local) {
|
|
589
|
+
throw new Error("Cannot pass both --global and --local — pick one scope for the config.");
|
|
590
|
+
}
|
|
591
|
+
if (flags?.global) return { configPath: getConfigPath(), scope: "global" };
|
|
592
|
+
if (flags?.local) return { configPath: getProjectConfigPath(process.cwd()), scope: "local" };
|
|
593
|
+
if (interactive) {
|
|
594
|
+
const wizard = createWizard();
|
|
595
|
+
try {
|
|
596
|
+
const choice = await wizard.select(
|
|
597
|
+
"Where do you want to save the config? (default: global)",
|
|
598
|
+
[
|
|
599
|
+
{ value: "global", label: `Global (${getConfigPath()} — applies to all projects)` },
|
|
600
|
+
{ value: "local", label: `Local (${getProjectConfigPath(process.cwd())} — overrides global for this project only)` },
|
|
601
|
+
]
|
|
602
|
+
);
|
|
603
|
+
return { configPath: choice === "local" ? getProjectConfigPath(process.cwd()) : getConfigPath(), scope: choice };
|
|
604
|
+
} finally {
|
|
605
|
+
wizard.close();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Non-interactive + no flags → legacy behaviour: global. Keeps CI scripts working.
|
|
609
|
+
return { configPath: getConfigPath(), scope: "global" };
|
|
610
|
+
}
|
|
611
|
+
|
|
582
612
|
export async function initCommand({ logger, flags = {} }) {
|
|
583
613
|
const karajanHome = getKarajanHome();
|
|
584
614
|
await ensureDir(karajanHome);
|
|
585
615
|
logger.info(`Ensured ${karajanHome} exists`);
|
|
586
616
|
|
|
587
|
-
const
|
|
617
|
+
const interactive = flags.noInteractive !== true && isTTY();
|
|
618
|
+
const { configPath, scope } = await resolveConfigScope({ flags, interactive });
|
|
619
|
+
logger.info(`Config scope: ${scope} → ${configPath}`);
|
|
588
620
|
const karajanDir = path.join(process.cwd(), ".karajan");
|
|
589
621
|
await ensureDir(karajanDir);
|
|
590
622
|
const reviewRulesPath = path.resolve(karajanDir, "review-rules.md");
|
|
591
623
|
const coderRulesPath = path.resolve(karajanDir, "coder-rules.md");
|
|
592
624
|
|
|
593
625
|
const { config, exists: configExists } = await loadConfig();
|
|
594
|
-
const interactive = flags.noInteractive !== true && isTTY();
|
|
595
626
|
|
|
596
627
|
// Auto-detect language from OS locale for non-interactive mode
|
|
597
628
|
if (!interactive && !configExists) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// KJC-TSK-0384 PR 2 — `kj onboard` runs collectors + optional LLM synth.
|
|
2
|
+
import { writeFile, mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { collectAll } from "../onboarder/collectors/index.js";
|
|
6
|
+
import { OnboarderRole } from "../roles/onboarder-role.js";
|
|
7
|
+
import { resolveRole } from "../config.js";
|
|
8
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
9
|
+
|
|
10
|
+
export function briefPath(projectDir) {
|
|
11
|
+
const slug = path.basename(projectDir).replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
|
|
12
|
+
return path.join(getKarajanHome(), "onboarding", `${slug}.md`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function onboardCommand({ config, logger, flags = {} }) {
|
|
16
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
17
|
+
const out = flags.output || briefPath(projectDir);
|
|
18
|
+
logger.info(`[onboard] collecting from ${projectDir}`);
|
|
19
|
+
const bundle = await collectAll(projectDir);
|
|
20
|
+
await mkdir(path.dirname(out), { recursive: true });
|
|
21
|
+
if (flags.noSynth) {
|
|
22
|
+
await writeFile(out, `# Onboarding bundle — ${projectDir}\n\n\`\`\`json\n${JSON.stringify(bundle, null, 2)}\n\`\`\`\n`, "utf8");
|
|
23
|
+
logger.info(`[onboard] wrote raw bundle to ${out} (--no-synth)`);
|
|
24
|
+
return { path: out, bundle, brief: null };
|
|
25
|
+
}
|
|
26
|
+
const roleCfg = resolveRole(config, "onboarder") || resolveRole(config, "architect");
|
|
27
|
+
const templatePath = new URL("../../templates/roles/onboarder.md", import.meta.url).pathname;
|
|
28
|
+
const instructions = existsSync(templatePath) ? await readFile(templatePath, "utf8") : "";
|
|
29
|
+
const role = new OnboarderRole({ instructions, config, ...roleCfg });
|
|
30
|
+
const result = await role.run({ bundle });
|
|
31
|
+
const brief = result?.result?.brief || "# Project is greenfield\n\nNo synth output.\n";
|
|
32
|
+
await writeFile(out, brief, "utf8");
|
|
33
|
+
logger.info(`[onboard] wrote Architecture Brief to ${out}`);
|
|
34
|
+
return { path: out, bundle, brief };
|
|
35
|
+
}
|
|
@@ -14,6 +14,8 @@ import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
|
|
|
14
14
|
import { buildStandbyState } from "../../brain/standby-store.js";
|
|
15
15
|
import { printResumeHint } from "../../utils/display/resume-hint.js";
|
|
16
16
|
import { runSpecReview } from "../../spec-review/run-spec-review.js";
|
|
17
|
+
import { prependPreflightHu } from "../../plan/preflight-hu.js";
|
|
18
|
+
import { readCachedBrief } from "../../onboarder/cache.js";
|
|
17
19
|
import { formatPlan, formatHuTable } from "./_shared.js";
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -55,12 +57,29 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
55
57
|
task = reviewResult.finalSpec; // downstream runs the refined spec
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
// KJC-TSK-0384 PR 3: --use-onboarding folds the cached Architecture Brief
|
|
61
|
+
// from `kj onboard` into the planner's context. Silent when no brief is
|
|
62
|
+
// cached so users without one don't see noise; loud warn when they passed
|
|
63
|
+
// the flag explicitly but the cache is empty so the missed wiring surfaces.
|
|
64
|
+
let resolvedContext = context;
|
|
65
|
+
if (flags?.useOnboarding) {
|
|
66
|
+
const brief = await readCachedBrief(config?.projectDir || process.cwd());
|
|
67
|
+
if (brief.found) {
|
|
68
|
+
resolvedContext = [
|
|
69
|
+
resolvedContext, brief.content ? `## Architecture Brief (from kj onboard)\n\n${brief.content}` : null,
|
|
70
|
+
].filter(Boolean).join("\n\n");
|
|
71
|
+
runLog.logText(`[planner] onboarding brief injected from ${brief.path}`);
|
|
72
|
+
} else {
|
|
73
|
+
logger?.warn?.(`--use-onboarding passed but no brief at ${brief.path}. Run 'kj onboard' first.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
const plannerRole = resolveRole(config, "planner");
|
|
59
78
|
await assertAgentsAvailable([plannerRole.provider]);
|
|
60
79
|
runLog.logText(`[planner] provider=${plannerRole.provider}`);
|
|
61
80
|
|
|
62
81
|
const planner = createAgent(plannerRole.provider, config, logger);
|
|
63
|
-
const prompt = buildPlannerPrompt({ task, context });
|
|
82
|
+
const prompt = buildPlannerPrompt({ task, context: resolvedContext });
|
|
64
83
|
const silenceTimeoutMs = Number(config?.session?.max_agent_silence_minutes) > 0
|
|
65
84
|
? Math.round(Number(config.session.max_agent_silence_minutes) * 60 * 1000)
|
|
66
85
|
: undefined;
|
|
@@ -414,6 +433,15 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
414
433
|
}
|
|
415
434
|
}
|
|
416
435
|
|
|
436
|
+
// KJC-TSK-0397: inject a [PREFLIGHT-000] HU at the top of the plan so
|
|
437
|
+
// every functional HU gates on a clean environment (deps + build + tests
|
|
438
|
+
// + lint + git clean, plus cloud auth when applicable). Idempotent: a
|
|
439
|
+
// plan that already has a preflight HU is left untouched. Disable with
|
|
440
|
+
// `--no-preflight-hu`.
|
|
441
|
+
if (flags.preflightHu !== false) {
|
|
442
|
+
await prependPreflightHu(plan, projectDir);
|
|
443
|
+
}
|
|
444
|
+
|
|
417
445
|
const planId = await savePlan(projectDir, plan);
|
|
418
446
|
const { plansDir } = await import("../../plan/plan-store.js");
|
|
419
447
|
const path = await import("node:path");
|
package/src/config/loader.js
CHANGED
|
@@ -114,6 +114,18 @@ export async function loadConfig(projectDir) {
|
|
|
114
114
|
// Load project config (.karajan/kj.config.yml)
|
|
115
115
|
const projectConfig = await loadProjectConfig(projectDir);
|
|
116
116
|
|
|
117
|
+
// KJC-TSK-0395: enforce the local-overrides-global hierarchy. A project
|
|
118
|
+
// config with no global counterpart is almost always user error (copied
|
|
119
|
+
// a .karajan dir from another repo, edited the wrong file, etc.) and
|
|
120
|
+
// would silently behave like a global config without the defaults
|
|
121
|
+
// people expect. Refuse with an actionable message.
|
|
122
|
+
if (projectConfig && !globalExists) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Local config found at ${getProjectConfigPath(projectDir)} but no global config exists.\n` +
|
|
125
|
+
"Local configs are merged ON TOP of the global one — run `kj init --global` first to create the base."
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
// Merge: DEFAULTS < global < project
|
|
118
130
|
let merged = mergeDeep(DEFAULTS, globalConfig);
|
|
119
131
|
if (projectConfig) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// KJC-TSK-0384 PR 3 — read the cached Architecture Brief deterministically.
|
|
2
|
+
// briefPath() is exported by src/commands/onboard.js so the writer and
|
|
3
|
+
// reader share the same slug rule.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
import { briefPath } from "../commands/onboard.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read the cached Architecture Brief for `projectDir`. Returns:
|
|
11
|
+
* { found: true, path, content } — when the file exists
|
|
12
|
+
* { found: false, path } — when it does not
|
|
13
|
+
* Never throws.
|
|
14
|
+
*/
|
|
15
|
+
export async function readCachedBrief(projectDir) {
|
|
16
|
+
const p = briefPath(projectDir);
|
|
17
|
+
if (!existsSync(p)) return { found: false, path: p };
|
|
18
|
+
try {
|
|
19
|
+
const content = await readFile(p, "utf8");
|
|
20
|
+
return { found: true, path: p, content };
|
|
21
|
+
} catch {
|
|
22
|
+
return { found: false, path: p };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Onboarder collectors — deterministic, zero-LLM extractors. Each pure,
|
|
2
|
+
// JSON-serialisable, fail-soft (returns null on the slot, never throws).
|
|
3
|
+
// Output bundle is consumed by PR 2 synthesis or dumped via `--no-synth`.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { readdir } from "node:fs/promises";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { detectProjectStack } from "../../utils/stack-detect.js";
|
|
10
|
+
|
|
11
|
+
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", "__pycache__"]);
|
|
12
|
+
|
|
13
|
+
function safeRun(cmd, args, opts = {}) {
|
|
14
|
+
try { return execFileSync(cmd, args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...opts }).trim(); }
|
|
15
|
+
catch { return null; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Tree summary: immediate dirs with rough byte count. Ignores noise dirs.
|
|
19
|
+
export async function collectTree(projectDir, { maxDepth = 2 } = {}) {
|
|
20
|
+
async function walk(dir, depth) {
|
|
21
|
+
if (depth > maxDepth) return [];
|
|
22
|
+
let entries;
|
|
23
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const ent of entries) {
|
|
26
|
+
if (ent.name.startsWith(".") && ent.name !== ".github") continue;
|
|
27
|
+
if (IGNORED_DIRS.has(ent.name)) continue;
|
|
28
|
+
const sub = join(dir, ent.name);
|
|
29
|
+
if (ent.isDirectory()) {
|
|
30
|
+
const children = await walk(sub, depth + 1);
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
for (const c of children) bytes += c.bytes || 0;
|
|
33
|
+
out.push({ path: relative(projectDir, sub), kind: "dir", bytes, children });
|
|
34
|
+
} else if (ent.isFile()) {
|
|
35
|
+
try { out.push({ path: relative(projectDir, sub), kind: "file", bytes: statSync(sub).size }); } catch { /* race */ }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
return walk(projectDir, 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Git log + branches + hot files. Returns null on non-git (greenfield).
|
|
44
|
+
export function collectGitHistory(projectDir, { maxHotFiles = 10 } = {}) {
|
|
45
|
+
const inside = safeRun("git", ["rev-parse", "--is-inside-work-tree"], { cwd: projectDir });
|
|
46
|
+
if (inside !== "true") return null;
|
|
47
|
+
const commitsRaw = safeRun("git", ["log", "--oneline", "-n", "100"], { cwd: projectDir }) || "";
|
|
48
|
+
const branchesRaw = safeRun("git", ["branch", "--list", "--all", "--format=%(refname:short)"], { cwd: projectDir }) || "";
|
|
49
|
+
const hotRaw = safeRun("git", ["log", "--name-only", "--pretty=format:", "-n", "200"], { cwd: projectDir }) || "";
|
|
50
|
+
const counts = {};
|
|
51
|
+
for (const line of hotRaw.split("\n").map((s) => s.trim()).filter(Boolean)) {
|
|
52
|
+
counts[line] = (counts[line] || 0) + 1;
|
|
53
|
+
}
|
|
54
|
+
const hotFiles = Object.entries(counts)
|
|
55
|
+
.sort((a, b) => b[1] - a[1]).slice(0, maxHotFiles)
|
|
56
|
+
.map(([file, touches]) => ({ file, touches }));
|
|
57
|
+
return {
|
|
58
|
+
commitCount: commitsRaw ? commitsRaw.split("\n").length : 0,
|
|
59
|
+
branches: branchesRaw.split("\n").filter(Boolean),
|
|
60
|
+
hotFiles,
|
|
61
|
+
headSha: safeRun("git", ["rev-parse", "HEAD"], { cwd: projectDir }) || null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Presence-check of known config files + package.json scripts.
|
|
66
|
+
export function collectConfigs(projectDir) {
|
|
67
|
+
const configFiles = [
|
|
68
|
+
"package.json", "pyproject.toml", "Cargo.toml", "go.mod",
|
|
69
|
+
"tsconfig.json", "jsconfig.json",
|
|
70
|
+
".eslintrc.js", ".eslintrc.json", "eslint.config.js",
|
|
71
|
+
"vitest.config.js", "vitest.config.ts", "jest.config.js", "jest.config.ts",
|
|
72
|
+
"firebase.json", ".gcloudignore", "Dockerfile", "docker-compose.yml",
|
|
73
|
+
".github/workflows", ".gitlab-ci.yml", "Makefile",
|
|
74
|
+
];
|
|
75
|
+
const present = configFiles.filter((f) => existsSync(join(projectDir, f)));
|
|
76
|
+
let scripts = null;
|
|
77
|
+
try {
|
|
78
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
|
|
79
|
+
scripts = pkg.scripts || null;
|
|
80
|
+
} catch { /* not a node project */ }
|
|
81
|
+
return { present, scripts };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Discover ADR files under docs/adr/, docs/adrs/, docs/architecture/, root.
|
|
85
|
+
export async function collectAdrs(projectDir) {
|
|
86
|
+
const dirs = ["docs/adr", "docs/adrs", "docs/architecture", "."];
|
|
87
|
+
const found = [];
|
|
88
|
+
for (const d of dirs) {
|
|
89
|
+
const abs = join(projectDir, d);
|
|
90
|
+
if (!existsSync(abs)) continue;
|
|
91
|
+
try {
|
|
92
|
+
const entries = await readdir(abs);
|
|
93
|
+
for (const f of entries) {
|
|
94
|
+
if (/^adr-?\d|^\d{4}-.+\.md$/i.test(f) || /architecture.*\.md$/i.test(f)) {
|
|
95
|
+
found.push(relative(projectDir, join(abs, f)));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch { /* unreadable */ }
|
|
99
|
+
}
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One-shot bundle: every slot is independent; missing slots → null/[].
|
|
104
|
+
export async function collectAll(projectDir) {
|
|
105
|
+
const [stack, tree, adrs] = await Promise.all([
|
|
106
|
+
detectProjectStack(projectDir).catch(() => null),
|
|
107
|
+
collectTree(projectDir).catch(() => []),
|
|
108
|
+
collectAdrs(projectDir).catch(() => []),
|
|
109
|
+
]);
|
|
110
|
+
return {
|
|
111
|
+
projectDir,
|
|
112
|
+
stack,
|
|
113
|
+
tree,
|
|
114
|
+
git: collectGitHistory(projectDir),
|
|
115
|
+
configs: collectConfigs(projectDir),
|
|
116
|
+
adrs,
|
|
117
|
+
collectedAt: new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0397: auto-inject a [PREFLIGHT-000] HU at the top of every
|
|
3
|
+
* plan generated by `kj plan generate` so the sub-pipeline gates on a
|
|
4
|
+
* clean environment before burning tokens on functional HUs.
|
|
5
|
+
*
|
|
6
|
+
* Design points:
|
|
7
|
+
* - Pure shell tests — no Karajan-specific runtime, just commands
|
|
8
|
+
* that exit 0 when the environment is ready.
|
|
9
|
+
* - Stack-aware: Node, Python and Firebase signals come straight
|
|
10
|
+
* from detectProjectStack() + on-disk file probes. Adding a new
|
|
11
|
+
* stack is one entry in `composePreflightTests`.
|
|
12
|
+
* - Idempotent: re-running over a plan that already has a HU with
|
|
13
|
+
* id `PREFLIGHT-000` or a title containing 'preflight' / 'verificar
|
|
14
|
+
* entorno' returns the plan unchanged. Required because the same
|
|
15
|
+
* planner output may flow through structural-pass + plan-fixer
|
|
16
|
+
* before reaching savePlan.
|
|
17
|
+
* - All other HUs' `blocked_by` get the PREFLIGHT-000 id appended
|
|
18
|
+
* (unless they already have it, again idempotent). Their own
|
|
19
|
+
* dependencies are preserved.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
25
|
+
|
|
26
|
+
const PREFLIGHT_ID = "PREFLIGHT-000";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compose stack-aware shell acceptance tests. Always includes a
|
|
30
|
+
* `git status --porcelain` clean-tree check; everything else is
|
|
31
|
+
* additive based on what the project actually uses.
|
|
32
|
+
*/
|
|
33
|
+
export function composePreflightTests(stack, projectDir = ".") {
|
|
34
|
+
const tests = [
|
|
35
|
+
{ type: "shell", content: "git status --porcelain | (! grep -q .)" },
|
|
36
|
+
];
|
|
37
|
+
const lang = stack?.language;
|
|
38
|
+
if (lang === "javascript" || lang === "typescript") {
|
|
39
|
+
tests.push({ type: "shell", content: "node --version | grep -qE '^v(2[0-9]|[3-9][0-9])'" });
|
|
40
|
+
if (existsSync(join(projectDir, "package.json"))) {
|
|
41
|
+
tests.push({ type: "shell", content: "npm install --no-audit --no-fund" });
|
|
42
|
+
tests.push({ type: "shell", content: "test -z \"$(npm run | grep -E '^ test$')\" || npm test" });
|
|
43
|
+
tests.push({ type: "shell", content: "test -z \"$(npm run | grep -E '^ lint$')\" || npm run lint" });
|
|
44
|
+
}
|
|
45
|
+
} else if (lang === "python") {
|
|
46
|
+
tests.push({ type: "shell", content: "python --version | grep -qE 'Python 3\\.(1[0-9]|[2-9][0-9])'" });
|
|
47
|
+
if (existsSync(join(projectDir, "requirements.txt"))) {
|
|
48
|
+
tests.push({ type: "shell", content: "pip install -r requirements.txt" });
|
|
49
|
+
} else if (existsSync(join(projectDir, "pyproject.toml"))) {
|
|
50
|
+
tests.push({ type: "shell", content: "test -f poetry.lock && poetry install || pip install -e ." });
|
|
51
|
+
}
|
|
52
|
+
tests.push({ type: "shell", content: "command -v pytest && pytest --collect-only || true" });
|
|
53
|
+
}
|
|
54
|
+
if (existsSync(join(projectDir, "firebase.json"))) {
|
|
55
|
+
tests.push({ type: "shell", content: "firebase projects:list" });
|
|
56
|
+
}
|
|
57
|
+
if (existsSync(join(projectDir, ".gcloudignore"))) {
|
|
58
|
+
tests.push({ type: "shell", content: "gcloud auth list --filter=status:ACTIVE --format='value(account)' | (! test -z \"$(cat)\")" });
|
|
59
|
+
}
|
|
60
|
+
return tests;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Return true when the plan already has a preflight-style HU (by id or
|
|
65
|
+
* title). Pattern-matches conservatively to avoid duplicate injection.
|
|
66
|
+
*/
|
|
67
|
+
export function hasPreflightHu(plan) {
|
|
68
|
+
if (!plan?.hus?.length) return false;
|
|
69
|
+
return plan.hus.some((hu) => {
|
|
70
|
+
if (hu.id === PREFLIGHT_ID) return true;
|
|
71
|
+
const title = (hu.title || hu.description || "").toLowerCase();
|
|
72
|
+
return title.includes("preflight-000") || title.includes("verificar entorno") || title.includes("preflight check");
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Prepend a [PREFLIGHT-000] HU to the plan and append its id to the
|
|
78
|
+
* `blocked_by` of every other HU. Returns the mutated plan reference
|
|
79
|
+
* for convenience; mutation is in place so callers don't need to
|
|
80
|
+
* reassign. No-op when the plan already has a preflight HU.
|
|
81
|
+
*/
|
|
82
|
+
export async function prependPreflightHu(plan, projectDir) {
|
|
83
|
+
if (!plan?.hus) return plan;
|
|
84
|
+
if (hasPreflightHu(plan)) return plan;
|
|
85
|
+
const stack = await detectProjectStack(projectDir);
|
|
86
|
+
const preflight = {
|
|
87
|
+
id: PREFLIGHT_ID,
|
|
88
|
+
title: "[PREFLIGHT-000] Verificar entorno de desarrollo listo",
|
|
89
|
+
description: "[PREFLIGHT-000] Block functional HUs until the project's environment is reproducible: deps installed, build/test/lint passing, git tree clean, cloud auth ready when applicable.",
|
|
90
|
+
task_type: "infra",
|
|
91
|
+
blocked_by: [],
|
|
92
|
+
reuse: [],
|
|
93
|
+
acceptance_tests: composePreflightTests(stack, projectDir),
|
|
94
|
+
spec_section: null,
|
|
95
|
+
};
|
|
96
|
+
for (const hu of plan.hus) {
|
|
97
|
+
if (!Array.isArray(hu.blocked_by)) hu.blocked_by = [];
|
|
98
|
+
if (!hu.blocked_by.includes(PREFLIGHT_ID)) hu.blocked_by.push(PREFLIGHT_ID);
|
|
99
|
+
}
|
|
100
|
+
plan.hus.unshift(preflight);
|
|
101
|
+
return plan;
|
|
102
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// KJC-TSK-0384 PR 2 — Onboarder role. Wraps collectors bundle + LLM
|
|
2
|
+
// synthesis into a Markdown Architecture Brief.
|
|
3
|
+
import { AgentRole } from "./agent-role.js";
|
|
4
|
+
|
|
5
|
+
const FENCE_RE = /^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/;
|
|
6
|
+
|
|
7
|
+
export class OnboarderRole extends AgentRole {
|
|
8
|
+
constructor(opts) {
|
|
9
|
+
super({ ...opts, name: "onboarder" });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
extractInput(input) {
|
|
13
|
+
if (typeof input === "string") return { bundle: { projectDir: input } };
|
|
14
|
+
return { bundle: input?.bundle || input || { projectDir: "" } };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async buildPrompt({ bundle }) {
|
|
18
|
+
const instructions = this.instructions || "";
|
|
19
|
+
const prompt = [
|
|
20
|
+
instructions,
|
|
21
|
+
"",
|
|
22
|
+
"## Bundle (deterministic collectors output)",
|
|
23
|
+
"",
|
|
24
|
+
"```json",
|
|
25
|
+
JSON.stringify(bundle, null, 2),
|
|
26
|
+
"```",
|
|
27
|
+
"",
|
|
28
|
+
"Produce the Architecture Brief now.",
|
|
29
|
+
].join("\n");
|
|
30
|
+
return { prompt };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
parseOutput(raw) {
|
|
34
|
+
if (!raw || typeof raw !== "string") return { brief: "" };
|
|
35
|
+
const trimmed = raw.trim();
|
|
36
|
+
const fenced = trimmed.match(FENCE_RE);
|
|
37
|
+
return { brief: fenced ? fenced[1].trim() : trimmed };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
buildSuccessResult(parsed, provider) {
|
|
41
|
+
return { brief: parsed.brief, provider };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
buildSummary(parsed) {
|
|
45
|
+
const lines = (parsed.brief || "").split("\n").length;
|
|
46
|
+
return `Architecture brief composed (${lines} line${lines === 1 ? "" : "s"})`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
handleParseNull(agentResult, provider) {
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
result: { brief: agentResult.output || "", provider },
|
|
53
|
+
summary: "Architecture brief composed (raw)",
|
|
54
|
+
usage: agentResult.usage,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
handleParseError(_err, agentResult, provider) {
|
|
59
|
+
return this.handleParseNull(agentResult, provider);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Onboarder
|
|
2
|
+
|
|
3
|
+
Onboarder role of Karajan. Take a deterministic collectors bundle from a
|
|
4
|
+
brownfield project and emit a concise Markdown **Architecture Brief** for
|
|
5
|
+
downstream roles (planner, architect, researcher, coder).
|
|
6
|
+
|
|
7
|
+
## Mode
|
|
8
|
+
|
|
9
|
+
Read-only. NO Bash/Write/Edit/MultiEdit/NotebookEdit. Output the Markdown
|
|
10
|
+
brief as plain text in your response, NOT via tools.
|
|
11
|
+
|
|
12
|
+
If the project is greenfield (no git, no configs, empty tree) emit
|
|
13
|
+
`# Project is greenfield` plus whatever signal exists. NEVER error out.
|
|
14
|
+
|
|
15
|
+
## Input
|
|
16
|
+
|
|
17
|
+
JSON bundle with `projectDir`, `stack`, `tree`, `git` (or null), `configs`,
|
|
18
|
+
`adrs`. Slots may be null/empty.
|
|
19
|
+
|
|
20
|
+
## Output
|
|
21
|
+
|
|
22
|
+
Markdown only — skip any section without data:
|
|
23
|
+
|
|
24
|
+
```markdown
|
|
25
|
+
# Architecture Brief — <projectDir>
|
|
26
|
+
## Stack
|
|
27
|
+
## Layout
|
|
28
|
+
## Configs
|
|
29
|
+
## Git history
|
|
30
|
+
## ADRs
|
|
31
|
+
## Conventions inferred
|
|
32
|
+
## Recommendations for new work
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Be conservative: only assert what the bundle shows. "Likely" / "appears to"
|
|
36
|
+
are fine; invention is not. Aim for ~40–80 lines total.
|