karajan-code 4.1.6 → 4.1.7

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.7",
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,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
 
@@ -53,9 +71,10 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
53
71
  short_id: flags.id || null,
54
72
  acceptance_criteria: flags.criteria ? [flags.criteria] : [],
55
73
  });
74
+ hu.created_by = creatorLabel();
56
75
  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\``);
76
+ return emit({ id: hu.id, short_id: hu.short_id, status: hu.status, created_by: hu.created_by },
77
+ `✓ HU created: ${hu.short_id || hu.id} (pending, by ${hu.created_by}) — it shows up in \`kj board\``);
59
78
  }
60
79
 
61
80
  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}`);
@@ -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);
@@ -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
  }
@@ -140,6 +140,7 @@ export function applyFixerPatch(plan, patch) {
140
140
  acceptance_tests: a.acceptance_tests,
141
141
  reuse: a.reuse || [],
142
142
  });
143
+ hu.created_by = "plan-reviewer"; // KJC-TSK-0661: provenance stamp
143
144
  if (symbolicId) symbolicToHuId.set(symbolicId, hu.id);
144
145
  const depsSymbolic = Array.isArray(a.dependencies) ? a.dependencies : [];
145
146
  addedHus.push({ hu, depsSymbolic });
@@ -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;