karajan-code 4.1.6 → 4.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.1.6",
3
+ "version": "4.1.8",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karajan/hu-board",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "HU Story Board dashboard for Karajan Code",
5
5
  "type": "module",
6
6
  "private": true,
@@ -32,7 +32,6 @@ import {
32
32
  getSessionsByProject,
33
33
  getSessionDetail,
34
34
  deleteProject,
35
- deleteStory,
36
35
  deleteSession,
37
36
  getKjHome,
38
37
  getHuBoardRunsDir,
@@ -462,19 +461,15 @@ router.delete('/prompts/:promptId', async (req, res) => {
462
461
  * it. Pre-tombstones this endpoint was a DB-only soft hide that the next
463
462
  * chokidar event undid silently.
464
463
  */
465
- router.delete('/stories/:id', async (req, res) => {
466
- try {
467
- const id = req.params.id;
468
- const dir = path.join(huStoriesDir(), id);
469
- const ok = deleteStory(id);
470
- if (!ok) return res.status(404).json({ error: 'Story not found' });
471
- addTombstone('story', id, { source: 'api', fsPaths: [dir] });
472
- let dirRemoved = false;
473
- try { await fsp.rm(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
474
- res.json({ deleted: true, dirRemoved });
475
- } catch (err) {
476
- res.status(500).json({ error: err.message });
477
- }
464
+ router.delete('/stories/:id', (req, res) => {
465
+ // KJC-TSK-0671 (absolute product rule): stories are NEVER deleted — the
466
+ // delete-and-recreate "fix" agents improvise loses history and produced
467
+ // the field saga this closes. Discard goes through the lifecycle instead.
468
+ res.status(405).json({
469
+ error: 'Stories are never deleted. Discard one with: kj hu move <id> skipped '
470
+ + '(archived, history kept). Dashboard warnings are fixed with the command '
471
+ + 'they name — see kj brief board.',
472
+ });
478
473
  });
479
474
 
480
475
  /**
@@ -358,7 +358,19 @@ export function syncPlanFile(filePath) {
358
358
  try {
359
359
  const raw = readFileSync(filePath, 'utf-8');
360
360
  const data = JSON.parse(raw);
361
- if (data.version !== 2 || !Array.isArray(data.hus) || data.hus.length === 0) return;
361
+ // KJC-BUG-0124 (issue #1277): a strict `version !== 2` silently hid
362
+ // every plan whose version drifted — the board showed 0 stories with no
363
+ // trace, and users concluded their work was lost. Tolerant now: any
364
+ // v2+ plan syncs, with a LOUD warn on version drift (fired before the
365
+ // empty check so drift is visible even on empty plans). Pre-v2 plans
366
+ // keep their historical silent skip (they never synced and carry no
367
+ // hus[]), and an empty v2 plan has nothing to sync — skipping it
368
+ // loses nothing.
369
+ if (typeof data.version !== 'number' || data.version < 2) return;
370
+ if (data.version > 2) {
371
+ console.warn(`[sync] ${filePath}: plan version ${data.version} (expected 2) — syncing anyway; re-save with current kj to normalize`);
372
+ }
373
+ if (!Array.isArray(data.hus) || data.hus.length === 0) return;
362
374
 
363
375
  // Bug fix: pre-patch, `projectId = data.planId` — every plan was a separate
364
376
  // "project" on the board, so 2 464 plans across the same repo produced
@@ -1,50 +1,158 @@
1
1
  <#
2
2
  .SYNOPSIS
3
- Karajan Code — standalone binary installer for Windows (no Node required).
3
+ Karajan Code installer for Windows — guarantees a COMPLETE install (KJC-TSK-0667).
4
4
 
5
5
  .DESCRIPTION
6
- Downloads the prebuilt kj.exe for Windows x64 from the GitHub release,
7
- verifies its SHA256 checksum, installs it to %LOCALAPPDATA%\Karajan, and
8
- adds that folder to the user PATH (idempotently). Re-running updates the
9
- binary in place without duplicating PATH entries.
10
-
11
6
  Run it with:
12
7
  irm https://karajancode.com/install.ps1 | iex
13
8
 
14
- Override with env vars: $env:KJ_VERSION (e.g. v3.7.2), $env:KJ_INSTALL_DIR.
9
+ Default route: npm — the full product (CLI + RAG + HU Board + MCP).
10
+ 1. Node >= 22.12 present -> npm install -g karajan-code
11
+ 2. No usable Node -> auto-provision the official Node LTS zip into
12
+ ~\.karajan\node (checksum-verified, nothing system-wide touched),
13
+ install karajan-code with it, drop kj/karajan-mcp wrappers into the
14
+ install dir and add it to the user PATH.
15
+ Standalone route (CLI only, no native-module features — RAG/board/MCP
16
+ unavailable): opt-in with $env:KJ_STANDALONE = "1".
15
17
 
16
- KJC-TSK-0594.
18
+ Env overrides (irm|iex cannot take parameters): KJ_VERSION, KJ_INSTALL_DIR,
19
+ KJ_STANDALONE. Windows PowerShell 5.1 compatible.
17
20
  #>
18
21
  $ErrorActionPreference = "Stop"
19
22
 
20
23
  $repo = "manufosela/karajan-code"
21
24
  $version = if ($env:KJ_VERSION) { $env:KJ_VERSION } else { "latest" }
22
25
  $installDir = if ($env:KJ_INSTALL_DIR) { $env:KJ_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "Karajan" }
23
- $asset = "kj-win-x64.exe"
26
+ $mode = if ($env:KJ_STANDALONE -eq "1") { "standalone" } else { "full" }
27
+ $nodeMajor = 22
28
+ $nodeMinMinor = 12
29
+
30
+ function Get-NpmPkg {
31
+ if ($version -eq "latest") { "karajan-code" } else { "karajan-code@" + $version.TrimStart("v") }
32
+ }
24
33
 
25
- if ($version -eq "latest") {
26
- $base = "https://github.com/$repo/releases/latest/download"
27
- } else {
28
- $base = "https://github.com/$repo/releases/download/$version"
34
+ function Test-NodeOk {
35
+ $node = Get-Command node -ErrorAction SilentlyContinue
36
+ if (-not $node) { return $false }
37
+ $v = (& node --version) 2>$null
38
+ if (-not $v) { return $false }
39
+ $parts = $v.TrimStart("v").Split(".")
40
+ $major = [int]$parts[0]; $minor = [int]$parts[1]
41
+ return ($major -gt $nodeMajor) -or ($major -eq $nodeMajor -and $minor -ge $nodeMinMinor)
42
+ }
43
+
44
+ function Add-UserPath([string]$dir) {
45
+ $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
46
+ $parts = @()
47
+ if ($userPath) { $parts = $userPath.Split(";") | Where-Object { $_ -ne "" } }
48
+ $already = $parts | Where-Object { $_.TrimEnd("\") -ieq $dir.TrimEnd("\") }
49
+ if (-not $already) {
50
+ $newPath = if ($userPath) { "$userPath;$dir" } else { $dir }
51
+ [Environment]::SetEnvironmentVariable("Path", $newPath, "User")
52
+ Write-Host "kj-install: added '$dir' to your user PATH. Open a NEW terminal to use 'kj'."
53
+ }
29
54
  }
30
55
 
31
- # Download to a temp folder; only install once the checksum matches, so a
32
- # failure never leaves a half-installed binary behind.
33
56
  $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("kj-install-" + [System.Guid]::NewGuid().ToString("N"))
34
57
  New-Item -ItemType Directory -Path $tmp -Force | Out-Null
58
+
35
59
  try {
60
+ if ($mode -eq "full") {
61
+ if (Test-NodeOk) {
62
+ Write-Host "kj-install: Node $(& node --version) found — installing via npm (full product)..."
63
+ & npm install -g (Get-NpmPkg)
64
+ if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE)." }
65
+ Write-Host "kj-install: installed. Run 'kj doctor' next, then 'kj install-tools' to complete the whole stack."
66
+ return
67
+ }
68
+
69
+ Write-Host "kj-install: no usable Node (need >= $nodeMajor.$nodeMinMinor) — provisioning official Node LTS into ~\.karajan\node (nothing system-wide)..."
70
+ $dist = "https://nodejs.org/dist/latest-v$nodeMajor.x"
71
+ $shaFile = Join-Path $tmp "SHASUMS256.txt"
72
+ Invoke-WebRequest -Uri "$dist/SHASUMS256.txt" -OutFile $shaFile -UseBasicParsing
73
+ $shaLines = Get-Content $shaFile
74
+ $assetLine = $shaLines | Where-Object { $_ -match "node-v[0-9.]+-win-x64\.zip" } | Select-Object -First 1
75
+ if (-not $assetLine) { throw "no official Node build for win-x64 in $dist" }
76
+ $nodeAsset = ($assetLine -split "\s+")[1]
77
+ $expected = ($assetLine -split "\s+")[0].ToLower()
78
+
79
+ Write-Host "kj-install: downloading $nodeAsset..."
80
+ $zip = Join-Path $tmp $nodeAsset
81
+ Invoke-WebRequest -Uri "$dist/$nodeAsset" -OutFile $zip -UseBasicParsing
82
+ $actual = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash.ToLower()
83
+ if ($expected -ne $actual) { throw "Node checksum mismatch — aborting, nothing installed." }
84
+
85
+ # Stage everything (extract + npm install) and only swap into place once
86
+ # EVERYTHING succeeded — a failure must never destroy a previous working
87
+ # ~\.karajan\node.
88
+ $nodeHome = Join-Path $env:USERPROFILE ".karajan\node"
89
+ $staging = "$nodeHome.staging.$PID"
90
+ $extract = Join-Path $tmp "extract"
91
+ if (Test-Path $staging) { Remove-Item -Recurse -Force $staging }
92
+ Expand-Archive -Path $zip -DestinationPath $extract -Force
93
+ # The zip wraps everything in a node-vX.Y.Z-win-x64\ top folder — unwrap it.
94
+ $inner = Get-ChildItem -Directory $extract | Select-Object -First 1
95
+ New-Item -ItemType Directory -Path (Split-Path $staging) -Force | Out-Null
96
+ Move-Item -Path $inner.FullName -Destination $staging
97
+
98
+ Write-Host "kj-install: installing karajan-code with the provisioned Node..."
99
+ $env:Path = "$staging;$env:Path"
100
+ # Explicit --prefix: the official Windows npm ships a builtin prefix of
101
+ # %AppData%\npm — without this the global shims would land OUTSIDE the
102
+ # self-contained ~\.karajan\node home (on Windows, prefix root IS the
103
+ # global bin dir, so shims land at $staging\kj.cmd).
104
+ & (Join-Path $staging "npm.cmd") install -g --prefix "$staging" (Get-NpmPkg)
105
+ if ($LASTEXITCODE -ne 0) { throw "npm install failed with the provisioned Node (exit $LASTEXITCODE)." }
106
+ foreach ($bin in @("kj", "karajan-mcp")) {
107
+ if (-not (Test-Path (Join-Path $staging "$bin.cmd"))) {
108
+ throw "npm reported success but the $bin shim is missing from the staged prefix — the full product needs both kj and karajan-mcp. Aborting, nothing swapped."
109
+ }
110
+ }
111
+
112
+ # Swap keeping the previous install recoverable: park it as a backup,
113
+ # move the staged one in, restore the backup if that move fails.
114
+ $backup = "$nodeHome.old.$PID"
115
+ if (Test-Path $nodeHome) { Move-Item -Path $nodeHome -Destination $backup -Force }
116
+ try {
117
+ Move-Item -Path $staging -Destination $nodeHome
118
+ } catch {
119
+ if (Test-Path $backup) { Move-Item -Path $backup -Destination $nodeHome -Force }
120
+ throw "could not move the staged install into place (previous install restored). $_"
121
+ }
122
+ if (Test-Path $backup) { Remove-Item -Recurse -Force $backup }
123
+
124
+ # Wrappers, not copies: npm's own .cmd shims live inside nodeHome and
125
+ # resolve node via their folder; these put nodeHome on PATH for children
126
+ # (kj spawns node tooling) and forward every argument.
127
+ New-Item -ItemType Directory -Path $installDir -Force | Out-Null
128
+ foreach ($bin in @("kj", "karajan-mcp")) {
129
+ $shim = Join-Path $nodeHome "$bin.cmd"
130
+ $wrapper = @(
131
+ "@echo off",
132
+ "set `"PATH=$nodeHome;%PATH%`"",
133
+ "`"$shim`" %*"
134
+ )
135
+ Set-Content -Path (Join-Path $installDir "$bin.cmd") -Value $wrapper -Encoding ASCII
136
+ }
137
+ Add-UserPath $installDir
138
+ $installed = (& (Join-Path $installDir "kj.cmd") --version) 2>$null
139
+ Write-Host "kj-install: installed kj $installed (full product) — kj at $(Join-Path $installDir 'kj.cmd')"
140
+ Write-Host "kj-install: next — run 'kj doctor', then 'kj install-tools' to complete the whole stack."
141
+ return
142
+ }
143
+
144
+ # --- Standalone route: prebuilt single binary (CLI only). ---
145
+ Write-Host "kj-install: standalone mode — single binary, NO native-module features (RAG, HU Board and MCP need the npm install)."
146
+ $asset = "kj-win-x64.exe"
147
+ $base = if ($version -eq "latest") { "https://github.com/$repo/releases/latest/download" }
148
+ else { "https://github.com/$repo/releases/download/$version" }
149
+
36
150
  $binTmp = Join-Path $tmp "kj.exe"
37
151
  $shaTmp = Join-Path $tmp "kj.exe.sha256"
38
-
39
152
  Write-Host "kj-install: downloading $asset ($version)..."
40
- try {
41
- Invoke-WebRequest -Uri "$base/$asset" -OutFile $binTmp -UseBasicParsing
42
- Invoke-WebRequest -Uri "$base/$asset.sha256" -OutFile $shaTmp -UseBasicParsing
43
- } catch {
44
- throw "could not download $base/$asset — does that version/asset exist, and is the network reachable? ($_)"
45
- }
153
+ Invoke-WebRequest -Uri "$base/$asset" -OutFile $binTmp -UseBasicParsing
154
+ Invoke-WebRequest -Uri "$base/$asset.sha256" -OutFile $shaTmp -UseBasicParsing
46
155
 
47
- # certutil writes a multi-line file; keep only the hex of the hash line.
48
156
  $shaLine = (Get-Content $shaTmp | Where-Object { $_ -notmatch "SHA256|CertUtil" } | Select-Object -First 1)
49
157
  $expected = ($shaLine -replace "[^0-9A-Fa-f]", "").ToLower()
50
158
  $actual = (Get-FileHash -Algorithm SHA256 -Path $binTmp).Hash.ToLower()
@@ -52,38 +160,15 @@ try {
52
160
  throw "checksum mismatch (expected '$expected', got '$actual'). Aborting, nothing installed."
53
161
  }
54
162
 
55
- # Install: move into place, replacing any previous install (idempotent).
56
163
  New-Item -ItemType Directory -Path $installDir -Force | Out-Null
57
164
  $dest = Join-Path $installDir "kj.exe"
58
165
  Move-Item -Path $binTmp -Destination $dest -Force
59
-
60
- # The download carries a "mark of the web" (Zone.Identifier) that makes
61
- # SmartScreen warn on first run. The binary is not code-signed with a paid
62
- # certificate, so clear the mark on the copy we just verified ourselves.
166
+ # Clear the mark-of-the-web on the copy we just checksum-verified ourselves.
63
167
  Unblock-File -Path $dest -ErrorAction SilentlyContinue
64
-
168
+ Add-UserPath $installDir
65
169
  $installed = (& $dest --version) 2>$null
66
- Write-Host "kj-install: installed kj $installed to $dest"
67
-
68
- # Add to the user PATH only if it is not already there (no duplicates).
69
- $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
70
- $parts = @()
71
- if ($userPath) { $parts = $userPath.Split(";") | Where-Object { $_ -ne "" } }
72
- $already = $parts | Where-Object { $_.TrimEnd("\") -ieq $installDir.TrimEnd("\") }
73
- if (-not $already) {
74
- $newPath = if ($userPath) { "$userPath;$installDir" } else { $installDir }
75
- [Environment]::SetEnvironmentVariable("Path", $newPath, "User")
76
- Write-Host "kj-install: added '$installDir' to your user PATH. Open a new terminal to use 'kj'."
77
- } else {
78
- Write-Host "kj-install: '$installDir' is already on your user PATH — run 'kj --help' to get started."
79
- }
80
-
81
- # The binary bundles no toolchain. kj orchestrates external tools, so name
82
- # the hard requirements and let `kj doctor` check them for this machine.
83
- Write-Host ""
84
- Write-Host "kj-install: next step — run 'kj doctor' to check prerequisites."
85
- Write-Host " Required: git, plus at least one agent CLI (Claude Code, Codex or Gemini)."
86
- Write-Host " Optional: Docker (local models, SonarQube) and Node/npm (helper tools: Squeezr, qmd)."
170
+ Write-Host "kj-install: installed standalone kj $installed to $dest"
171
+ Write-Host "kj-install: next — run 'kj doctor'. For the full product later: re-run without KJ_STANDALONE."
87
172
  } finally {
88
173
  Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
89
174
  }
@@ -35,6 +35,11 @@ const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "MODERATE"];
35
35
  * @returns {Promise<{available: boolean, reason?: string, total?: number, vulnerabilities?: object[]}>}
36
36
  */
37
37
  export async function collectOsvFindings(projectDir, logger = null) {
38
+ // KJC-BUG-0122: same kill-switch as semgrep — external scanners never
39
+ // fire from the unit suite; e2e opts in with KJ_ALLOW_REAL_SCANS=1.
40
+ if (process.env.VITEST && process.env.KJ_ALLOW_REAL_SCANS !== "1") {
41
+ return { available: false, reason: "real scans disabled under the test runner (set KJ_ALLOW_REAL_SCANS=1 to opt in)" };
42
+ }
38
43
  let stdout;
39
44
  try {
40
45
  const { stdout: out } = await execFileAsync(
@@ -34,6 +34,14 @@ const SEVERITY_ORDER = ["ERROR", "WARNING", "INFO"];
34
34
  * @returns {Promise<{available: boolean, reason?: string, total?: number, findings?: object[]}>}
35
35
  */
36
36
  export async function collectSemgrepFindings(projectDir, logger = null) {
37
+ // KJC-BUG-0122: a full `--config auto` scan must NEVER fire from the unit
38
+ // suite — on machines with the complete stack installed (the product
39
+ // default since v4.1.2) parallel vitest workers each launched a real
40
+ // whole-repo scan: load average 50, orphaned semgrep-core processes.
41
+ // E2E tests that genuinely want the real scan opt in explicitly.
42
+ if (process.env.VITEST && process.env.KJ_ALLOW_REAL_SCANS !== "1") {
43
+ return { available: false, reason: "real scans disabled under the test runner (set KJ_ALLOW_REAL_SCANS=1 to opt in)" };
44
+ }
37
45
  let stdout;
38
46
  try {
39
47
  const { stdout: out } = await execFileAsync(
@@ -8,6 +8,14 @@
8
8
  import { addHu, updateHuStatus } from "../plan/plan-hu-ops.js";
9
9
  import { generatePlanId } from "../plan/plan-id.js";
10
10
  import { savePlan, listPlans, loadPlan } from "../plan/plan-store.js";
11
+ import { detectHostAgent } from "../utils/agent-detect.js";
12
+
13
+ // KJC-TSK-0661 (Jorge's friction): every HU records WHO created it — the
14
+ // board must never show work nobody can trace. Host agent env → that agent;
15
+ // a human typing in a terminal → "human"; anything else headless → "agent".
16
+ export function creatorLabel() {
17
+ return detectHostAgent() || (process.stdin.isTTY ? "human" : "agent");
18
+ }
11
19
 
12
20
  export const HU_STATUSES = ["pending", "running", "done", "failed", "skipped"];
13
21
  const BACKLOG_NAME = "brain-backlog";
@@ -35,12 +43,22 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
35
43
  for (const meta of plans) {
36
44
  const plan = await loadPlan(projectDir, meta.planId);
37
45
  for (const h of plan.hus || []) {
38
- rows.push({ id: h.id, short_id: h.short_id, title: h.title, status: h.status, plan: plan.alias || plan.planId });
46
+ rows.push({
47
+ id: h.id, short_id: h.short_id, title: h.title, status: h.status,
48
+ plan: plan.alias || plan.planId,
49
+ // KJC-TSK-0661: provenance — where a card came from is part of
50
+ // the card. Older HUs predate the stamp: shown as "?".
51
+ created_by: h.created_by || null, created_at: h.createdAt || null,
52
+ });
39
53
  }
40
54
  }
41
55
  if (flags.json) { console.log(JSON.stringify(rows)); return rows; }
42
- for (const r of rows) console.log(`${(r.short_id || r.id).padEnd(28)} ${r.status.padEnd(8)} ${r.title}`);
56
+ for (const r of rows) {
57
+ const origin = `${r.plan} · by ${r.created_by || "?"}${r.created_at ? ` · ${r.created_at.slice(0, 10)}` : ""}`;
58
+ console.log(`${(r.short_id || r.id).padEnd(28)} ${r.status.padEnd(8)} ${r.title} [${origin}]`);
59
+ }
43
60
  if (rows.length === 0) console.log("no HUs yet — create one with: kj hu add \"<story>\"");
61
+ else console.log("→ work one: ask your agent, or kj run --plan <plan> · discard one: kj hu move <id> skipped");
44
62
  return rows;
45
63
  }
46
64
 
@@ -48,14 +66,28 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
48
66
  const title = args[0];
49
67
  if (!title || !title.trim()) throw new Error("kj hu add requires a title: kj hu add \"<story>\"");
50
68
  const plan = await backlogPlan(projectDir);
69
+ // KJC-TSK-0669 (absolute rule): cards are permanent. The delete-and-
70
+ // recreate "fix" an agent improvises loses history and duplicates ids —
71
+ // refuse it with the norm spelled out.
72
+ if (flags.id) {
73
+ const dup = (plan.hus || []).find((h) => h.short_id === flags.id);
74
+ if (dup) {
75
+ throw new Error(
76
+ `HU "${flags.id}" already exists (status: ${dup.status}). HUs are never deleted or recreated — `
77
+ + `update its state with: kj hu move ${flags.id} <pending|running|done|failed|skipped>, `
78
+ + `or discard it with: kj hu move ${flags.id} skipped`
79
+ );
80
+ }
81
+ }
51
82
  const hu = addHu(plan, {
52
83
  title,
53
84
  short_id: flags.id || null,
54
85
  acceptance_criteria: flags.criteria ? [flags.criteria] : [],
55
86
  });
87
+ hu.created_by = creatorLabel();
56
88
  await savePlan(projectDir, plan);
57
- return emit({ id: hu.id, short_id: hu.short_id, status: hu.status },
58
- `✓ HU created: ${hu.short_id || hu.id} (pending) — it shows up in \`kj board\``);
89
+ return emit({ id: hu.id, short_id: hu.short_id, status: hu.status, created_by: hu.created_by },
90
+ `✓ HU created: ${hu.short_id || hu.id} (pending, by ${hu.created_by}) — it shows up in \`kj board\``);
59
91
  }
60
92
 
61
93
  if (action === "move") {
@@ -6,6 +6,7 @@ import { sonarUp, checkVmMaxMapCount } from "../sonar/manager.js";
6
6
  import { ollamaUp, waitForOllamaReady, normalizeOllamaConfig } from "../rag/ollama-manager.js";
7
7
  import { checkOllamaCapability, pullOllamaModel } from "../rag/ollama-capability.js";
8
8
  import { exists, ensureDir } from "../utils/fs.js";
9
+ import { ensureContractBlockPresent } from "../review/gate-gitignore.js";
9
10
  import { getKarajanHome } from "../utils/paths.js";
10
11
  import { getTemplatesRoot } from "../utils/templates-root.js";
11
12
  import { detectAvailableAgents } from "../utils/agent-detect.js";
@@ -672,6 +673,7 @@ const GITIGNORE_ENTRIES = [
672
673
  { pattern: ".kj/", comment: "Karajan runtime (logs, worktrees)" },
673
674
  { pattern: ".agent/", comment: "Agent skills (OpenSkills)" },
674
675
  { pattern: ".scannerwork/", comment: "SonarQube scanner temp" },
676
+ { pattern: ".reviews/", comment: "Legacy review artifacts" },
675
677
  ];
676
678
 
677
679
  async function ensureGitignoreEntries(projectDir, logger) {
@@ -685,16 +687,22 @@ async function ensureGitignoreEntries(projectDir, logger) {
685
687
  if (!content) content = "";
686
688
 
687
689
  const missing = GITIGNORE_ENTRIES.filter(e => !content.includes(e.pattern));
688
- if (missing.length === 0) return;
689
-
690
- const block = [
691
- "",
692
- "# Karajan Code",
693
- ...missing.map(e => e.pattern)
694
- ].join("\n") + "\n";
695
-
696
- await fs.appendFile(gitignorePath, block, "utf8");
697
- logger.info(`Added to .gitignore: ${missing.map(e => e.pattern).join(", ")}`);
690
+ if (missing.length > 0) {
691
+ const block = [
692
+ "",
693
+ "# Karajan Code",
694
+ ...missing.map(e => e.pattern)
695
+ ].join("\n") + "\n";
696
+ await fs.appendFile(gitignorePath, block, "utf8");
697
+ logger.info(`Added to .gitignore: ${missing.map(e => e.pattern).join(", ")}`);
698
+ }
699
+
700
+ // KJC-BUG-0123 (issue #1268): the .karajan entries were missing entirely —
701
+ // and they must land as the CONTRACT BLOCK (`.karajan/*` + re-includes),
702
+ // never a bare `.karajan/` dir-exclude, or git cannot re-include the
703
+ // review-gate/hooks/adrs the whole team inherits (KJC-TSK-0646).
704
+ const res = await ensureContractBlockPresent(projectDir);
705
+ if (res.changed) logger.info("Added the .karajan contract block to .gitignore (verdicts stay local; gate/hooks/adrs tracked)");
698
706
  }
699
707
 
700
708
  /**
@@ -15,6 +15,8 @@ export async function planAddHuCommand({ config, planId, title, type, deps, scop
15
15
  scope: scope || null,
16
16
  blocked_by: deps ? deps.split(",").map(d => d.trim()) : []
17
17
  });
18
+ const { creatorLabel } = await import("../hu.js");
19
+ hu.created_by = creatorLabel(); // KJC-TSK-0661: provenance stamp
18
20
 
19
21
  await savePlan(projectDir, plan);
20
22
  console.log(`Added: ${hu.id} — ${hu.title}`);
@@ -60,7 +60,7 @@ export async function planFixCommand({ config, planId, prompt, logger, json }) {
60
60
  });
61
61
  if (!fix.ok) { fixProgress.finish("failed"); break; }
62
62
  const ops = applyFixerPatch(plan, fix.patch);
63
- if (ops.added + ops.depsAdded + ops.deleted === 0) { fixProgress.finish("done"); break; }
63
+ if (ops.added + ops.depsAdded + ops.archived === 0) { fixProgress.finish("done"); break; }
64
64
  const review2 = await reviewPlan({
65
65
  agent: planner, task: enrichedTask, hus: plan.hus,
66
66
  onOutput: fixProgress.onOutput, silenceTimeoutMs, timeoutMs,
@@ -229,6 +229,9 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
229
229
  reviewer_model: models.reviewer_model,
230
230
  reviewer_provider: models.reviewer_provider,
231
231
  });
232
+ // KJC-TSK-0661: provenance stamp (addHu builds its object explicitly,
233
+ // so this goes on the returned reference, not in huData).
234
+ hu.created_by = "planner";
232
235
  if (symbolicId) symbolicToHuId.set(symbolicId, hu.id);
233
236
  const deps = typeof step === "object" && Array.isArray(step.dependencies) ? step.dependencies : [];
234
237
  stepDeps.push(deps);
@@ -367,8 +370,8 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
367
370
  break;
368
371
  }
369
372
  const ops = applyFixerPatch(plan, fix.patch);
370
- runLog.logText(`[planner] self-fix iter ${i} applied: +${ops.added} HU, +${ops.depsAdded} dep, -${ops.deleted} HU`);
371
- if (ops.added + ops.depsAdded + ops.deleted === 0) {
373
+ runLog.logText(`[planner] self-fix iter ${i} applied: +${ops.added} HU, +${ops.depsAdded} dep, ${ops.archived} archived`);
374
+ if (ops.added + ops.depsAdded + ops.archived === 0) {
372
375
  fixProgress.finish("done");
373
376
  runLog.logText(`[planner] self-fix iter ${i} empty patch — stopping`);
374
377
  break;
@@ -82,6 +82,18 @@ const B = {
82
82
  ],
83
83
  "findings with severity, category and file:line — or an explicit 'no security surface touched'."),
84
84
  },
85
+ board: {
86
+ purpose: "What the HU Board is and how to act on it without breaking it",
87
+ render: () => brief("Board",
88
+ "the HU Board is the permanent record of work: every card traces to who created it and what happened to it — a dashboard warning is fixed with the command it names, never by rebuilding the board.",
89
+ [
90
+ "Cards are NEVER deleted or recreated — not to fix warnings, not to tidy up. Discard = `kj hu move <id> skipped` (archived, history kept).",
91
+ "States move only via `kj hu move <id> <pending|running|done|failed|skipped>`.",
92
+ "A duplicate short_id means the card already exists: update that card, never add a twin.",
93
+ "A warning is information, not damage: run the command it names; if it looks like a kj bug, `kj report-issue` — never work around it by destroying state.",
94
+ ],
95
+ "the board reflects reality: the same cards before and after your action, with truthful states and provenance intact."),
96
+ },
85
97
  audit: {
86
98
  purpose: "Final look before shipping — does the whole thing hold?",
87
99
  render: () => brief("Audit",
@@ -29,7 +29,7 @@ const TARGET_FILES = {
29
29
  // ENV-D1 (KJC-TSK-0642): the tracking invariant names the CHOSEN state
30
30
  // backend — a playbook that says "board or PG" makes the host guess.
31
31
  const BACKEND_TRACKING = {
32
- "hu-board": "Every piece of work has a tracked story/bug in the HU Board (`kj hu add` / `kj board`) before it starts.",
32
+ "hu-board": "Every piece of work has a tracked story/bug in the HU Board (`kj hu add` / `kj board`) before it starts. Cards are permanent — never delete or recreate one; discard with `kj hu move <id> skipped`. `kj brief board` explains the board.",
33
33
  "planning-game": "Every piece of work has a tracked card in the Planning Game MCP before it starts (In Progress while you work it).",
34
34
  };
35
35
 
@@ -58,7 +58,7 @@ Invariants (the git gates enforce these — they are not suggestions):
58
58
  through an atomic PR (~150 net lines, Conventional Commits).
59
59
 
60
60
  Commands: \`kj rag query\` · \`kj brief <role>\` (triage, planner, researcher,
61
- architect, tester, security, audit) · \`kj hu add|move|list\` · \`kj adr add|list\` ·
61
+ architect, tester, security, audit, board) · \`kj hu add|move|list\` · \`kj adr add|list\` ·
62
62
  \`kj review --staged\` · \`kj review --check\` · \`kj solomon --position\` ·
63
63
  \`kj agent run <agent>\` · \`kj report\` · \`kj check\`
64
64
 
@@ -5,6 +5,7 @@
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { computeBaseRef, setSnapshot } from "../review/diff-generator.js";
8
+ import { ensureContractBlockPresent } from "../review/gate-gitignore.js";
8
9
  import { revParse } from "../utils/git.js";
9
10
  import { buildCoderPrompt } from "../prompts/coder.js";
10
11
  import { buildReviewerPrompt } from "../prompts/reviewer.js";
@@ -55,8 +56,11 @@ export async function autoInit(projectDir, logger) {
55
56
  }
56
57
 
57
58
  // Ensure .gitignore exists with universal entries only (stack-specific added after planner)
59
+ // KJC-BUG-0123: `.karajan/` is NOT in this list on purpose — a bare
60
+ // dir-exclude breaks the gate contract (git cannot re-include children);
61
+ // the canonical block is appended below from its single source.
58
62
  const gitignorePath = path.join(projectDir, ".gitignore");
59
- const universalIgnores = [".env", "*.log", ".DS_Store", ".karajan/", ".reviews/"];
63
+ const universalIgnores = [".env", "*.log", ".DS_Store", ".reviews/"];
60
64
  try {
61
65
  let content = "";
62
66
  if (await exists(gitignorePath)) {
@@ -68,6 +72,7 @@ export async function autoInit(projectDir, logger) {
68
72
  await fs.appendFile(gitignorePath, append, "utf8");
69
73
  logger.info(`Created .gitignore with universal entries`);
70
74
  }
75
+ await ensureContractBlockPresent(projectDir);
71
76
  } catch (err) {
72
77
  logger.warn(`Failed to create .gitignore: ${err.message}`);
73
78
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { extractFirstJson } from "../utils/json-extract.js";
13
- import { addHu, removeHu } from "./plan-hu-ops.js";
13
+ import { addHu } from "./plan-hu-ops.js";
14
14
  import { normaliseAcceptanceTests } from "./plan-schema.js";
15
15
  import { withBrainRecovery } from "../brain/with-brain-recovery.js";
16
16
 
@@ -111,9 +111,31 @@ export async function applyReviewerFeedback({ agent, task, hus, findings, onOutp
111
111
  }
112
112
 
113
113
  export function applyFixerPatch(plan, patch) {
114
- const counts = { added: 0, depsAdded: 0, deleted: 0 };
114
+ const counts = { added: 0, depsAdded: 0, archived: 0 };
115
+ // KJC-TSK-0669 (absolute rule): HUs are NEVER deleted — not even by the
116
+ // fix loop. Overlap resolutions ARCHIVE in place (status skipped +
117
+ // provenance), so history survives and ids stay referenceable. The
118
+ // references are cleaned exactly like removeHu used to, because a
119
+ // skipped dependency never satisfies the scheduler (deadlock otherwise).
115
120
  for (const huId of patch.deletions || []) {
116
- if (removeHu(plan, huId)) counts.deleted += 1;
121
+ const hu = plan.hus.find(h => h.id === huId);
122
+ if (!hu) continue;
123
+ if (hu.status !== "skipped") {
124
+ hu.status = "skipped";
125
+ hu.archived_by = "plan-reviewer";
126
+ hu.archive_reason = "scope overlap resolved by the plan-review fix loop";
127
+ hu.updatedAt = new Date().toISOString();
128
+ counts.archived += 1;
129
+ }
130
+ for (const other of plan.hus) {
131
+ if (other.id === huId) continue;
132
+ if (Array.isArray(other.blocked_by) && other.blocked_by.includes(huId)) {
133
+ other.blocked_by = other.blocked_by.filter(d => d !== huId);
134
+ }
135
+ if (Array.isArray(other.reuse) && other.reuse.includes(huId)) {
136
+ other.reuse = other.reuse.filter(d => d !== huId);
137
+ }
138
+ }
117
139
  }
118
140
  // KJC-BUG-0053: las HUs añadidas por el fixer dejaban `short_id` y
119
141
  // `blocked_by` vacíos porque addHu no recibía esos campos. Resultado:
@@ -140,6 +162,7 @@ export function applyFixerPatch(plan, patch) {
140
162
  acceptance_tests: a.acceptance_tests,
141
163
  reuse: a.reuse || [],
142
164
  });
165
+ hu.created_by = "plan-reviewer"; // KJC-TSK-0661: provenance stamp
143
166
  if (symbolicId) symbolicToHuId.set(symbolicId, hu.id);
144
167
  const depsSymbolic = Array.isArray(a.dependencies) ? a.dependencies : [];
145
168
  addedHus.push({ hu, depsSymbolic });
@@ -162,7 +185,7 @@ export function applyFixerPatch(plan, patch) {
162
185
  hu.blocked_by = [...blocked, on];
163
186
  counts.depsAdded += 1;
164
187
  }
165
- if (counts.added + counts.depsAdded + counts.deleted > 0) {
188
+ if (counts.added + counts.depsAdded + counts.archived > 0) {
166
189
  plan.updatedAt = new Date().toISOString();
167
190
  }
168
191
  return counts;
@@ -12,7 +12,10 @@
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
 
15
- const CONTRACT_BLOCK = [
15
+ // KJC-BUG-0123 (issue #1268): exported as the SINGLE source of truth — kj
16
+ // init and the orchestrator's autoInit write this same block instead of a
17
+ // bare `.karajan/` exclude (which git cannot re-include children of).
18
+ export const CONTRACT_BLOCK = [
16
19
  ".karajan/*",
17
20
  "# …except the v4 environment contract, which the whole team inherits:",
18
21
  "!.karajan/review-gate",
@@ -25,6 +28,22 @@ const CONTRACT_BLOCK = [
25
28
  "!.karajan/adrs/",
26
29
  ];
27
30
 
31
+ /**
32
+ * KJC-BUG-0123: append the canonical contract block to .gitignore when the
33
+ * file has no `.karajan` mention at all (fresh projects — `kj init` path).
34
+ * Files that already mention .karajan are left to ensureGateTrackable,
35
+ * which rewrites legacy root excludes without touching anything else.
36
+ */
37
+ export async function ensureContractBlockPresent(projectDir) {
38
+ const file = path.join(projectDir, ".gitignore");
39
+ let text = "";
40
+ try { text = (await fs.readFile(file, "utf8")) || ""; } catch { /* no .gitignore yet */ }
41
+ if (text.includes(".karajan")) return { changed: false };
42
+ const sep = text && !text.endsWith("\n") ? "\n" : "";
43
+ await fs.writeFile(file, `${text}${sep}# Karajan environment (verdicts stay local; the contract is tracked)\n${CONTRACT_BLOCK.join("\n")}\n`);
44
+ return { changed: true };
45
+ }
46
+
28
47
  export async function ensureGateTrackable(projectDir) {
29
48
  const file = path.join(projectDir, ".gitignore");
30
49
  let text;