karajan-code 2.7.4 → 2.8.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.
Files changed (165) hide show
  1. package/README.md +1 -1
  2. package/package.json +24 -4
  3. package/scripts/diet-baseline.mjs +170 -0
  4. package/scripts/regen-arch-stats.sh +71 -0
  5. package/src/agents/availability.js +15 -9
  6. package/src/agents/claude-agent.js +143 -45
  7. package/src/agents/model-registry.js +1 -1
  8. package/src/audit/agent-readiness.js +240 -0
  9. package/src/bootstrap.js +26 -1
  10. package/src/brain/decision-tracker.js +3 -1
  11. package/src/brain/decisor.js +1 -1
  12. package/src/canvas/parse-md.js +238 -0
  13. package/src/canvas/schema.js +101 -0
  14. package/src/canvas/to-flat.js +85 -0
  15. package/src/canvas/validate.js +163 -0
  16. package/src/checks/binaries.js +1 -1
  17. package/src/checks/ports.js +85 -31
  18. package/src/checks/sonar.js +1 -1
  19. package/src/checks/system.js +1 -1
  20. package/src/cli/_shared.js +30 -0
  21. package/src/cli/register-meta.js +204 -0
  22. package/src/cli/register-pipeline.js +212 -0
  23. package/src/cli/register-plan.js +152 -0
  24. package/src/cli/register-roles-skills.js +38 -0
  25. package/src/cli/register-sonar.js +28 -0
  26. package/src/cli.js +83 -468
  27. package/src/commands/agents.js +18 -6
  28. package/src/commands/architect.js +29 -2
  29. package/src/commands/audit.js +27 -3
  30. package/src/commands/board.js +160 -30
  31. package/src/commands/clean.js +83 -0
  32. package/src/commands/code.js +7 -2
  33. package/src/commands/discover.js +7 -2
  34. package/src/commands/init.js +2 -2
  35. package/src/commands/plan/_shared.js +40 -0
  36. package/src/commands/plan/add-hu.js +21 -0
  37. package/src/commands/plan/delete.js +9 -0
  38. package/src/commands/plan/generate.js +284 -0
  39. package/src/commands/plan/index.js +14 -0
  40. package/src/commands/plan/list.js +21 -0
  41. package/src/commands/plan/ready.js +46 -0
  42. package/src/commands/plan/remove-hu.js +17 -0
  43. package/src/commands/plan/show.js +81 -0
  44. package/src/commands/plan/validate.js +20 -0
  45. package/src/commands/plan.js +14 -291
  46. package/src/commands/researcher.js +7 -2
  47. package/src/commands/resume.js +24 -1
  48. package/src/commands/review.js +7 -2
  49. package/src/commands/run.js +52 -1
  50. package/src/commands/status.js +1 -1
  51. package/src/commands/sync.js +75 -0
  52. package/src/commands/triage.js +7 -2
  53. package/src/commands/undo.js +1 -1
  54. package/src/config/defaults.js +213 -0
  55. package/src/config/loader.js +137 -0
  56. package/src/config/overrides.js +199 -0
  57. package/src/config/role-resolver.js +159 -0
  58. package/src/config/test-harness.js +79 -0
  59. package/src/config.js +30 -632
  60. package/src/git/automation.js +2 -2
  61. package/src/guards/output-guard.js +0 -2
  62. package/src/hu/acceptance-runner.js +52 -10
  63. package/src/hu/store.js +11 -0
  64. package/src/mcp/handlers/direct-handlers.js +2 -5
  65. package/src/mcp/handlers/management-handlers.js +48 -4
  66. package/src/mcp/handlers/run-handler.js +9 -8
  67. package/src/mcp/preflight.js +32 -13
  68. package/src/mcp/server-handlers.js +4 -3
  69. package/src/mcp/suggest-handler.js +3 -2
  70. package/src/mcp/tools.js +15 -0
  71. package/src/orchestrator/agent-fallback.js +1 -1
  72. package/src/orchestrator/ci-integration.js +10 -10
  73. package/src/orchestrator/config-init.js +25 -7
  74. package/src/orchestrator/direct-actions.js +6 -2
  75. package/src/orchestrator/drivers/error-recovery.js +287 -0
  76. package/src/orchestrator/drivers/init-context.js +222 -0
  77. package/src/orchestrator/drivers/iteration-loop.js +311 -0
  78. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
  79. package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
  80. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
  81. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
  82. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
  83. package/src/orchestrator/drivers/post-loop.js +370 -0
  84. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
  85. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
  86. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
  87. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
  88. package/src/orchestrator/drivers/pre-loop.js +434 -0
  89. package/src/orchestrator/drivers/run-hu-batch.js +367 -0
  90. package/src/orchestrator/flow-control.js +4 -3
  91. package/src/orchestrator/flow-runner.js +121 -2077
  92. package/src/orchestrator/fs-leak-detector.js +156 -0
  93. package/src/orchestrator/hu-sub-pipeline.js +197 -9
  94. package/src/orchestrator/integrations.js +88 -0
  95. package/src/orchestrator/post-loop-stages.js +35 -8
  96. package/src/orchestrator/preflight-checks.js +20 -7
  97. package/src/orchestrator/repair/repair-detector.js +117 -0
  98. package/src/orchestrator/repair/repair-runner.js +142 -0
  99. package/src/orchestrator/reviewer-fallback.js +1 -1
  100. package/src/orchestrator/solomon-escalation.js +3 -2
  101. package/src/orchestrator/solomon-rules.js +7 -3
  102. package/src/orchestrator/stages/architect-stage.js +12 -11
  103. package/src/orchestrator/stages/coder-stage.js +56 -7
  104. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
  105. package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
  106. package/src/orchestrator/stages/planner-stage.js +1 -1
  107. package/src/orchestrator/stages/research-stage.js +1 -1
  108. package/src/orchestrator/stages/reviewer-stage.js +7 -7
  109. package/src/orchestrator/stages/sonar-stage.js +94 -10
  110. package/src/orchestrator/stages/stage-classes.js +86 -0
  111. package/src/orchestrator/stages/stage-executor.js +1 -1
  112. package/src/orchestrator/stages/triage-stage.js +1 -1
  113. package/src/orchestrator/standby.js +3 -2
  114. package/src/orchestrator/verification-gate.js +20 -12
  115. package/src/plan/adr-generator.js +170 -0
  116. package/src/plan/adr-loader.js +60 -0
  117. package/src/plan/plan-executor.js +19 -1
  118. package/src/plan/plan-hu-ops.js +170 -1
  119. package/src/plan/plan-reviewer.js +199 -0
  120. package/src/plan/plan-schema.js +69 -1
  121. package/src/plan/plan-store.js +40 -3
  122. package/src/plan/spec-detector.js +61 -0
  123. package/src/plan/tests-synthesizer.js +116 -0
  124. package/src/planning-game/client.js +3 -14
  125. package/src/planning-game/decomposition.js +1 -1
  126. package/src/planning-game/pipeline-adapter.js +36 -5
  127. package/src/prompts/coder.js +172 -1
  128. package/src/prompts/hu-reviewer.js +0 -1
  129. package/src/prompts/planner.js +81 -4
  130. package/src/review/diff-generator.js +35 -22
  131. package/src/review/snapshot-diff.js +1 -1
  132. package/src/roles/coder-role.js +25 -3
  133. package/src/roles/planner-role.js +42 -1
  134. package/src/roles/repairer-role.js +181 -0
  135. package/src/roles/tester-role.js +70 -4
  136. package/src/session/mutators.js +304 -0
  137. package/src/session/runtime-overrides.js +59 -0
  138. package/src/{session-store.js → session/store.js} +2 -2
  139. package/src/skills/semantic-detector.js +10 -5
  140. package/src/skills/skill-detector.js +1 -1
  141. package/src/sonar/cloud-scanner.js +0 -1
  142. package/src/sonar/credentials.js +14 -6
  143. package/src/sonar/discovery.js +123 -0
  144. package/src/sonar/manager.js +18 -0
  145. package/src/sync/detect-drift.js +113 -0
  146. package/src/tracker-bootstrap.js +44 -0
  147. package/src/utils/agent-detect.js +15 -11
  148. package/src/utils/board-prompt-bridge.js +109 -0
  149. package/src/utils/cli-progress.js +161 -0
  150. package/src/utils/cwd-confirm.js +39 -0
  151. package/src/utils/derive-project-name-from-cwd.js +119 -0
  152. package/src/utils/garbage-collector.js +396 -0
  153. package/src/utils/git.js +16 -4
  154. package/src/utils/locale.js +1 -1
  155. package/src/utils/paths.js +21 -1
  156. package/src/utils/process.js +1 -1
  157. package/src/utils/prompt-project-name.js +35 -0
  158. package/src/utils/run-context.js +75 -0
  159. package/src/utils/run-log.js +12 -0
  160. package/src/utils/task-file.js +1 -1
  161. package/src/utils/telemetry.js +13 -1
  162. package/src/webperf/devtools-detect.js +1 -1
  163. package/templates/coder-rules.md +13 -0
  164. package/templates/roles/coder.md +34 -0
  165. package/templates/roles/repairer.md +94 -0
package/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.7.4 released** — Three contract fixes: (1) **Sonar is now intrinsic** to Karajan for code tasks (`sw`/`refactor`/`add-tests`) — `sonarqube.enabled` config and `--no-sonar` flag are ignored with a deprecation warning; a code task without a quality gate is not a job Karajan calls complete, like TDD; Solomon still may skip an iteration at runtime via rules. (2) **Preflight no longer demands API keys Karajan doesn't use** — the bogus `ANTHROPIC_API_KEY not set` check was blocking every Claude Code MCP run; replaced with a real CLI availability check (`claude`, `codex`, `gemini` on PATH). (3) **Orchestrator no longer crashes** with `Cannot read properties of undefined (reading 'push')` on the Solomon init-error path. Adds two architectural invariants (`no-provider-apis`, `sonar-intrinsic`) to CI so the contracts can't silently drift back. New `docs/TESTS.md` test-suite guide with diagrams. See [CHANGELOG.md](./CHANGELOG.md) for full history and [MIGRATION-v2.md](./MIGRATION-v2.md) for v1→v2 breaking changes.
26
+ > **v2.8.0 released** — Audit-driven hardening release. The 2026-04-30 self-audit (`kj audit`) flagged 13 issues across security, code quality, performance, architecture and testing; this release closes all of them in 16 PRs with 0 user-visible API changes. Highlights: (1) **Security**: every `child_process` call in `src/` now uses tokenised arg arrays (no shell expansion) — closed 7 sites that took template strings. (2) **FASE 1 e2e suite**: 7 scenarios + fake-coder/sonar fixtures cover the 5-bug class from the 2026-04-27 demo regression (zombie-HU, saveSession-missing, Repairer unfixable, zombi-status, audit smoke). 23 e2e tests in 6s. (3) **File splits**: `cli.js` 699→113 LOC, `commands/plan.js` 549→14 LOC (shim) + sub-commands, `iteration-loop.js` 513→311 LOC, `pre-loop.js` 626→435 LOC. Each big driver now under the 600-LOC ceiling. (4) **ESLint ratcheted**: 57 warnings closed in `src/`, then `no-unused-vars` / `no-useless-assignment` / `no-useless-escape` / `preserve-caught-error` switched warn→error so regressions fail CI. New rules ban `console.*` outside CLI paths and `globalThis.__KJ_*` outside `test-harness.js`. (5) **BREAKING**: `engines.node` 18→20.10.0 (Node 18 LTS hit EOL 2025-04-30). See [CHANGELOG.md](./CHANGELOG.md) for the full punch list and [MIGRATION-v2.md](./MIGRATION-v2.md) for v1→v2 breaking changes.
27
27
 
28
28
  You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
29
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.7.4",
3
+ "version": "2.8.0",
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",
@@ -24,7 +24,13 @@
24
24
  "cli"
25
25
  ],
26
26
  "engines": {
27
- "node": ">=18.0.0"
27
+ "node": ">=20.10.0"
28
+ },
29
+ "imports": {
30
+ "#utils/*": "./src/utils/*",
31
+ "#session/*": "./src/session/*",
32
+ "#hu/*": "./src/hu/*",
33
+ "#skills/*": "./src/skills/*"
28
34
  },
29
35
  "files": [
30
36
  "src/",
@@ -46,11 +52,19 @@
46
52
  "setup": "node scripts/install.js",
47
53
  "test": "vitest run",
48
54
  "test:watch": "vitest",
49
- "lint": "((command -v rg >/dev/null 2>&1 && rg --files src tests | rg '\\.js$') || find src tests -type f -name '*.js') | while IFS= read -r f; do node --check \"$f\"; done",
55
+ "lint": "eslint src/",
56
+ "lint:fix": "eslint src/ --fix",
57
+ "lint:syntax": "((command -v rg >/dev/null 2>&1 && rg --files src tests | rg '\\.js$') || find src tests -type f -name '*.js') | while IFS= read -r f; do node --check \"$f\"; done",
50
58
  "typecheck": "npx -p typescript@5 tsc --noEmit -p tsconfig.json",
51
- "validate": "npm run lint && npm test",
59
+ "validate": "npm run lint && npm run lint:syntax && npm test",
52
60
  "mcp": "node src/mcp/server.js"
53
61
  },
62
+ "simple-git-hooks": {
63
+ "pre-commit": "npx lint-staged"
64
+ },
65
+ "lint-staged": {
66
+ "src/**/*.js": "eslint --max-warnings=999"
67
+ },
54
68
  "dependencies": {
55
69
  "@modelcontextprotocol/sdk": "^1.26.0",
56
70
  "commander": "^14.0.0",
@@ -60,9 +74,15 @@
60
74
  },
61
75
  "devDependencies": {
62
76
  "@changesets/cli": "^2.30.0",
77
+ "@eslint/js": "^10.0.1",
63
78
  "@vitest/coverage-v8": "^4.0.18",
64
79
  "esbuild": "^0.28.0",
80
+ "eslint": "^10.2.1",
81
+ "eslint-plugin-import-x": "^4.16.2",
82
+ "globals": "^17.5.0",
83
+ "lint-staged": "^16.4.0",
65
84
  "postject": "^1.0.0-alpha.6",
85
+ "simple-git-hooks": "^2.13.1",
66
86
  "vitest": "^4.0.0"
67
87
  }
68
88
  }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Test Diet — baseline snapshot generator.
4
+ *
5
+ * Captures the *current* state of the unit test suite so subsequent diet
6
+ * commits (HU plan-20260429094214-0srv_002 .. _008) can prove they did not
7
+ * blow past the guardrails (wall-clock budget and branch-coverage drop).
8
+ *
9
+ * Output file: tests/_diet/baseline.json
10
+ *
11
+ * Schema:
12
+ * {
13
+ * capturedAt: ISO-8601 timestamp,
14
+ * locTotal: total wc -l across every tests/...test.js file,
15
+ * wallClockSec: number, // `time npm test` wall-clock
16
+ * branchCoveragePct: number, // src/ branch coverage % from vitest --coverage
17
+ * fileCount: number, // files.length
18
+ * files: [ { path, loc, architecture } ]
19
+ * }
20
+ *
21
+ * The script is intentionally *measurement-only*: it never edits source
22
+ * or test files. Re-running it overwrites the snapshot — but the diet
23
+ * compares against the committed copy, not the latest run.
24
+ */
25
+
26
+ import { execFileSync, spawnSync } from "node:child_process";
27
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
+ import { dirname, relative, resolve } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..");
32
+ const OUTPUT_PATH = resolve(REPO_ROOT, "tests/_diet/baseline.json");
33
+ const ARCH_PREFIX = "tests/architecture/";
34
+
35
+ /**
36
+ * Enumerate every committed `tests/**\/*.test.js` file via `git ls-files`.
37
+ * Using git rather than fs walk keeps the snapshot reproducible: untracked
38
+ * scratch files in someone's worktree never leak into the baseline.
39
+ *
40
+ * @returns {string[]} repo-relative paths
41
+ */
42
+ function listTestFiles() {
43
+ const out = execFileSync(
44
+ "git",
45
+ ["ls-files", "tests"],
46
+ { cwd: REPO_ROOT, encoding: "utf8" }
47
+ );
48
+ return out
49
+ .split("\n")
50
+ .map((line) => line.trim())
51
+ .filter((line) => line.endsWith(".test.js"));
52
+ }
53
+
54
+ /**
55
+ * Count lines in a file. Uses readFileSync because the files are small and
56
+ * spawning `wc -l` 300+ times is wasteful.
57
+ *
58
+ * @param {string} absPath
59
+ * @returns {number}
60
+ */
61
+ function countLines(absPath) {
62
+ const content = readFileSync(absPath, "utf8");
63
+ if (content.length === 0) return 0;
64
+ // wc -l counts newlines; mimic that so the JSON matches `wc -l` output.
65
+ let lines = 0;
66
+ for (let i = 0; i < content.length; i += 1) {
67
+ if (content.charCodeAt(i) === 10) lines += 1;
68
+ }
69
+ return lines;
70
+ }
71
+
72
+ /**
73
+ * Run `npm test` once and report wall-clock seconds. The suite has a
74
+ * pre-existing architecture-budget failure (tests/architecture/dynamic-imports);
75
+ * its exit code is irrelevant for the wall-clock baseline.
76
+ *
77
+ * @returns {number} seconds, rounded to 2 decimals
78
+ */
79
+ function measureWallClock() {
80
+ const started = process.hrtime.bigint();
81
+ spawnSync("npm", ["test", "--silent"], {
82
+ cwd: REPO_ROOT,
83
+ stdio: "ignore"
84
+ });
85
+ const elapsedNs = process.hrtime.bigint() - started;
86
+ return Math.round(Number(elapsedNs) / 1e7) / 100;
87
+ }
88
+
89
+ /**
90
+ * Run vitest with v8 coverage and parse the `text-summary` reporter output
91
+ * for the `Branches` percentage of the src/ tree.
92
+ *
93
+ * The architecture-budget test fails before the coverage summary is emitted,
94
+ * so we exclude that single file from this measurement run only. It scans
95
+ * source files statically and does not exercise src/ runtime code, so its
96
+ * absence does not move branch coverage in any meaningful way.
97
+ *
98
+ * @returns {number} branch coverage % (e.g. 17.23)
99
+ */
100
+ function measureBranchCoverage() {
101
+ const result = spawnSync(
102
+ "npx",
103
+ [
104
+ "vitest",
105
+ "run",
106
+ "--coverage",
107
+ "--coverage.provider=v8",
108
+ "--coverage.reporter=text-summary",
109
+ "--exclude=tests/architecture/dynamic-imports.test.js"
110
+ ],
111
+ { cwd: REPO_ROOT, encoding: "utf8" }
112
+ );
113
+ const out = `${result.stdout}\n${result.stderr}`;
114
+ const match = out.match(/Branches\s*:\s*([\d.]+)%/);
115
+ if (!match) {
116
+ throw new Error(
117
+ "Could not parse branch coverage from vitest output:\n" +
118
+ out.slice(-2000)
119
+ );
120
+ }
121
+ return Number(match[1]);
122
+ }
123
+
124
+ /**
125
+ * Build the files[] array with metadata for every test file.
126
+ *
127
+ * @param {string[]} relativePaths
128
+ * @returns {Array<{path: string, loc: number, architecture: boolean}>}
129
+ */
130
+ function buildFileEntries(relativePaths) {
131
+ return relativePaths
132
+ .map((rel) => ({
133
+ path: rel,
134
+ loc: countLines(resolve(REPO_ROOT, rel)),
135
+ architecture: rel.startsWith(ARCH_PREFIX)
136
+ }))
137
+ .sort((a, b) => a.path.localeCompare(b.path));
138
+ }
139
+
140
+ function main() {
141
+ const relPaths = listTestFiles();
142
+ const files = buildFileEntries(relPaths);
143
+ const locTotal = files.reduce((sum, f) => sum + f.loc, 0);
144
+
145
+ // Order matters: cheap measurements first, expensive ones last, so a
146
+ // wedge in the slow ones is easy to bisect via Ctrl-C.
147
+ const wallClockSec = measureWallClock();
148
+ const branchCoveragePct = measureBranchCoverage();
149
+
150
+ const baseline = {
151
+ capturedAt: new Date().toISOString(),
152
+ locTotal,
153
+ wallClockSec,
154
+ branchCoveragePct,
155
+ fileCount: files.length,
156
+ files
157
+ };
158
+
159
+ mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
160
+ writeFileSync(OUTPUT_PATH, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
161
+
162
+ process.stdout.write(
163
+ `Wrote ${relative(REPO_ROOT, OUTPUT_PATH)} — ` +
164
+ `${baseline.fileCount} files, ${baseline.locTotal} LOC, ` +
165
+ `${baseline.wallClockSec}s wall-clock, ` +
166
+ `${baseline.branchCoveragePct}% branch coverage.\n`
167
+ );
168
+ }
169
+
170
+ main();
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # Regenerate the numeric stats in docs/ARCHITECTURE.md from the current tree.
3
+ #
4
+ # Motivation: docs/ARCHITECTURE.md accumulates staleness. The v2.7.4 audit
5
+ # found it claimed "28k LOC, 234 files" when reality was 35.9k LOC / 260
6
+ # files (+28% LOC, +11% files), and pointed at `src/orchestrator.js` as the
7
+ # main pipeline (~1400 LOC) when the real orchestrator is
8
+ # `src/orchestrator/flow-runner.js` (2241 LOC) and `src/orchestrator.js` is
9
+ # a 22-line barrel.
10
+ #
11
+ # This script rewrites the specific lines that contain numeric stats
12
+ # in-place; narrative paragraphs are left alone.
13
+ #
14
+ # Usage:
15
+ # scripts/regen-arch-stats.sh # rewrite and exit 0
16
+ # scripts/regen-arch-stats.sh --check # exit 1 if the file would change
17
+ #
18
+ # Add to the release checklist: run without --check, commit any diff.
19
+ set -euo pipefail
20
+
21
+ MODE="${1:-}"
22
+ DOC="docs/ARCHITECTURE.md"
23
+
24
+ if [[ ! -f "$DOC" ]]; then
25
+ echo "ARCHITECTURE.md not found at $DOC — run from the repo root." >&2
26
+ exit 2
27
+ fi
28
+
29
+ # ── Compute current stats ──────────────────────────────────────────────
30
+
31
+ SRC_LOC=$(find src -name "*.js" -not -path "*/node_modules/*" | xargs wc -l 2>/dev/null | tail -1 | awk '{print $1}')
32
+ SRC_FILES=$(find src -name "*.js" -not -path "*/node_modules/*" | wc -l)
33
+ TEST_COUNT=$(find tests -name "*.test.js" -not -path "*/worktrees/*" -not -path "*/.kj/*" | wc -l)
34
+
35
+ # Format LOC with thousands separator ("36038" → "36k" for narrative, raw for table)
36
+ SRC_LOC_SHORT=$(awk -v n="$SRC_LOC" 'BEGIN{ printf "%dk", int(n/1000) }')
37
+
38
+ # Orchestrator pipeline stats
39
+ FLOW_RUNNER_LOC=$(wc -l < src/orchestrator/flow-runner.js 2>/dev/null | awk '{print $1}')
40
+ BARREL_LOC=$(wc -l < src/orchestrator.js 2>/dev/null | awk '{print $1}')
41
+
42
+ # ── Build a sed program ────────────────────────────────────────────────
43
+ # Each line targets a specific claim. Patterns are narrow enough to miss
44
+ # unrelated mentions but wide enough to absorb small formatting drifts.
45
+
46
+ SED_SCRIPT=$(cat <<EOF
47
+ s|Source code ([0-9kKmM.]\\+ LOC, [0-9]\\+ files)|Source code (${SRC_LOC_SHORT} LOC, ${SRC_FILES} files)|g
48
+ s|Test suite ([0-9]\\+ tests)|Test suite (${TEST_COUNT} test files)|g
49
+ s|\`src/orchestrator.js\` (~[0-9]\\+ LOC)|\`src/orchestrator.js\` (${BARREL_LOC}-line barrel) → delegates to \`src/orchestrator/flow-runner.js\` (${FLOW_RUNNER_LOC} LOC)|g
50
+ EOF
51
+ )
52
+
53
+ if [[ "$MODE" == "--check" ]]; then
54
+ if sed "$SED_SCRIPT" "$DOC" | diff -q "$DOC" - >/dev/null; then
55
+ echo "docs/ARCHITECTURE.md stats are up to date."
56
+ exit 0
57
+ fi
58
+ echo "docs/ARCHITECTURE.md stats are STALE. Run scripts/regen-arch-stats.sh to rewrite." >&2
59
+ sed "$SED_SCRIPT" "$DOC" | diff -u "$DOC" - || true
60
+ exit 1
61
+ fi
62
+
63
+ # Real rewrite, in place, with backup we immediately delete to keep the
64
+ # working tree clean.
65
+ sed -i.bak "$SED_SCRIPT" "$DOC"
66
+ rm -f "${DOC}.bak"
67
+
68
+ echo "docs/ARCHITECTURE.md updated with:"
69
+ echo " Source code: ${SRC_LOC_SHORT} LOC, ${SRC_FILES} files"
70
+ echo " Tests: ${TEST_COUNT} test files"
71
+ echo " Orchestrator: src/orchestrator.js ${BARREL_LOC}-line barrel → flow-runner.js ${FLOW_RUNNER_LOC} LOC"
@@ -4,16 +4,22 @@ import { getAgentMeta } from "./index.js";
4
4
 
5
5
  export async function assertAgentsAvailable(agentNames = []) {
6
6
  const unique = [...new Set(agentNames.filter(Boolean))];
7
- const missing = [];
8
7
 
9
- for (const name of unique) {
10
- const meta = getAgentMeta(name);
11
- if (!meta?.bin) continue;
12
- const res = await runCommand(resolveBin(meta.bin), ["--version"]);
13
- if (res.exitCode !== 0) {
14
- missing.push({ name, ...meta });
15
- }
16
- }
8
+ // PR-J (audit quick win): probe every agent CLI in parallel via
9
+ // Promise.all instead of awaiting in a for-of loop. Each probe is
10
+ // an independent fork+exec with no shared state, so serialising
11
+ // them was wasted preflight latency: O(n) shell-outs becomes O(1)
12
+ // (max of all probes). On 4 agents at ~80ms each this drops the
13
+ // wait from ~320ms to ~80ms.
14
+ const probes = await Promise.all(
15
+ unique.map(async (name) => {
16
+ const meta = getAgentMeta(name);
17
+ if (!meta?.bin) return null;
18
+ const res = await runCommand(resolveBin(meta.bin), ["--version"]);
19
+ return res.exitCode !== 0 ? { name, ...meta } : null;
20
+ })
21
+ );
22
+ const missing = probes.filter(Boolean);
17
23
 
18
24
  if (missing.length === 0) return;
19
25
 
@@ -72,10 +72,10 @@ function collectAssistantText(obj) {
72
72
  * Returns an object with tokens_in, tokens_out, cost_usd, model or null if not found.
73
73
  */
74
74
  function extractUsageFromStreamJson(raw) {
75
- const lines = (raw || "").split("\n").filter(Boolean);
76
- for (let i = lines.length - 1; i >= 0; i--) {
77
- const obj = tryParseJson(lines[i]);
78
- if (!obj || obj.type !== "result") continue;
75
+ const events = parseClaudeEvents(raw);
76
+ for (let i = events.length - 1; i >= 0; i--) {
77
+ const obj = events[i];
78
+ if (obj.type !== "result") continue;
79
79
 
80
80
  const tokens_in = obj.usage?.input_tokens ?? 0;
81
81
  const tokens_out = obj.usage?.output_tokens ?? 0;
@@ -89,25 +89,54 @@ function extractUsageFromStreamJson(raw) {
89
89
  }
90
90
 
91
91
  /**
92
- * Extract the final text result from stream-json NDJSON output.
93
- * Each line is a JSON object. We collect assistant text content from
94
- * "result" messages and fall back to accumulating "content_block_delta" text.
92
+ * Normalize Claude's output payload to a flat array of event objects.
93
+ *
94
+ * Claude 2.x supports two on-disk shapes depending on the output-format flag:
95
+ *
96
+ * --output-format stream-json → NDJSON, one event JSON per line
97
+ * --output-format json → a single JSON array with every event
98
+ *
99
+ * The legacy path assumed NDJSON and silently dropped the `json` variant
100
+ * (split("\n") returned one unparseable "line" containing the whole array,
101
+ * extract returned empty, caller fell back to the raw string — which made
102
+ * `kj plan` save the whole stream as `approach` with zero HUs). Handle both
103
+ * shapes here so callers see a uniform list of events regardless of flag.
104
+ */
105
+ function parseClaudeEvents(raw) {
106
+ const trimmed = (raw || "").trim();
107
+ if (!trimmed) return [];
108
+ // `--output-format json` → single JSON array.
109
+ if (trimmed.startsWith("[")) {
110
+ try {
111
+ const arr = JSON.parse(trimmed);
112
+ if (Array.isArray(arr)) return arr.filter((o) => o && typeof o === "object");
113
+ } catch { /* fall through to NDJSON */ }
114
+ }
115
+ // `--output-format stream-json` → NDJSON, one event per line.
116
+ const events = [];
117
+ for (const line of trimmed.split("\n")) {
118
+ const obj = tryParseJson(line);
119
+ if (obj && typeof obj === "object") events.push(obj);
120
+ }
121
+ return events;
122
+ }
123
+
124
+ /**
125
+ * Extract the final text result from Claude's output (either stream-json
126
+ * NDJSON or single-array json — see parseClaudeEvents). We look for the
127
+ * `result` message first (holds the final text); if missing, concatenate
128
+ * every assistant text block encountered.
95
129
  */
96
130
  function extractTextFromStreamJson(raw) {
97
- const lines = (raw || "").split("\n").filter(Boolean);
98
- // Try to find a "result" message with the final text
99
- for (let i = lines.length - 1; i >= 0; i--) {
100
- const obj = tryParseJson(lines[i]);
101
- if (!obj) continue;
102
- const result = extractResultText(obj);
131
+ const events = parseClaudeEvents(raw);
132
+ // Walk from the end — the final `result` event is the last one.
133
+ for (let i = events.length - 1; i >= 0; i--) {
134
+ const result = extractResultText(events[i]);
103
135
  if (result) return result;
104
136
  }
105
- // Fallback: accumulate all assistant text deltas
137
+ // Fallback: accumulate every assistant text block in order.
106
138
  const parts = [];
107
- for (const line of lines) {
108
- const obj = tryParseJson(line);
109
- if (obj) parts.push(...collectAssistantText(obj));
110
- }
139
+ for (const obj of events) parts.push(...collectAssistantText(obj));
111
140
  return parts.join("") || raw;
112
141
  }
113
142
 
@@ -138,36 +167,105 @@ function summarizeToolCall(name, input = {}) {
138
167
  }
139
168
  }
140
169
 
141
- function createStreamJsonFilter(onOutput) {
170
+ /**
171
+ * Translate a Claude stream-json event into a human-readable summary.
172
+ *
173
+ * Returns a `{ line, kind }` object for the cli-progress reporter to
174
+ * display, OR `null` to suppress (the event is structural noise).
175
+ *
176
+ * Whitelist semantics on purpose: pre-v2.7.5 the filter ONLY recognised
177
+ * `assistant` + `result` and forwarded everything else as raw JSON,
178
+ * which dumped 6 KB of `system/init` + `rate_limit_event` to the user
179
+ * on every `kj plan`. Now we recognise every event type Claude can
180
+ * emit and either condense it to one line or drop it. Unknown types
181
+ * are dropped silently unless DEBUG_KJ_AGENT is set.
182
+ */
183
+ export function translateStreamJsonEvent(obj) {
184
+ switch (obj.type) {
185
+ case "system": {
186
+ // The init frame announces session id + model. Useful as a single
187
+ // status line on first connect; the rate_limit subtype on a
188
+ // healthy session is pure noise — suppress.
189
+ if (obj.subtype === "init") {
190
+ const model = typeof obj.model === "string" ? obj.model : "?";
191
+ return { line: `Claude session ready (model: ${model.replace(/\[.*\]/, "")})`, kind: "text" };
192
+ }
193
+ return null;
194
+ }
195
+ case "rate_limit_event": {
196
+ const status = obj.rate_limit_info?.status;
197
+ // Only surface when actually throttled — "allowed" is the happy
198
+ // path and surfaces every few seconds during normal runs.
199
+ if (status && status !== "allowed") {
200
+ return { line: `Rate limit: ${status}`, kind: "text" };
201
+ }
202
+ return null;
203
+ }
204
+ case "user": {
205
+ // Echoes of the previous tool's output coming back into the
206
+ // conversation. Adding a line per echo would double the noise of
207
+ // the corresponding tool_use line, which we already render.
208
+ return null;
209
+ }
210
+ case "assistant": {
211
+ const content = obj.message?.content;
212
+ if (!Array.isArray(content)) return null;
213
+ // Each content block becomes a separate line — we return them
214
+ // through the caller's emit-many path below.
215
+ return { _multi: true, blocks: content };
216
+ }
217
+ case "result": {
218
+ const summary = typeof obj.result === "string"
219
+ ? obj.result.split("\n")[0].slice(0, 120)
220
+ : "result received";
221
+ return { line: `done: ${summary}` };
222
+ }
223
+ default: {
224
+ if (process.env.DEBUG_KJ_AGENT) {
225
+ return { line: `[debug] unknown event: ${obj.type}` };
226
+ }
227
+ return null;
228
+ }
229
+ }
230
+ }
231
+
232
+ export function createStreamJsonFilter(onOutput) {
142
233
  if (!onOutput) return null;
143
234
  return ({ stream, line }) => {
235
+ let obj;
144
236
  try {
145
- const obj = JSON.parse(line);
146
- // Forward assistant content
147
- if (obj.type === "assistant" && obj.message?.content) {
148
- for (const block of obj.message.content) {
149
- if (block.type === "text" && block.text) {
150
- // Only emit first line of text for brevity
151
- const firstLine = block.text.split("\n")[0].trim().slice(0, 120);
152
- if (firstLine) onOutput({ stream, line: firstLine, kind: "text" });
153
- } else if (block.type === "tool_use") {
154
- onOutput({ stream, line: summarizeToolCall(block.name, block.input), kind: "tool" });
155
- } else if (block.type === "thinking" && block.thinking) {
156
- onOutput({ stream, line: `thinking: ${block.thinking.slice(0, 80).replace(/\s+/g, " ")}…`, kind: "thinking" });
157
- }
158
- }
159
- return;
237
+ obj = JSON.parse(line);
238
+ } catch {
239
+ // Not JSON — forward verbatim ONLY if it's short and not a partial
240
+ // JSON chunk (some agents print stderr diagnostics through the
241
+ // same channel). Anything that smells like JSON-debris gets
242
+ // dropped to keep the CLI clean.
243
+ const trimmed = line.trim();
244
+ if (trimmed && !trimmed.startsWith("{") && !trimmed.startsWith("[") && trimmed.length < 200) {
245
+ onOutput({ stream, line: trimmed });
160
246
  }
161
- // Forward result
162
- if (obj.type === "result") {
163
- const summary = typeof obj.result === "string"
164
- ? obj.result.split("\n")[0].slice(0, 120)
165
- : "result received";
166
- onOutput({ stream, line: `done: ${summary}` });
167
- return;
247
+ return;
248
+ }
249
+
250
+ const summary = translateStreamJsonEvent(obj);
251
+ if (!summary) return;
252
+
253
+ // Multi-block assistant payload: one line per content block.
254
+ if (summary._multi) {
255
+ for (const block of summary.blocks) {
256
+ if (block.type === "text" && block.text) {
257
+ const firstLine = block.text.split("\n")[0].trim().slice(0, 120);
258
+ if (firstLine) onOutput({ stream, line: firstLine, kind: "text" });
259
+ } else if (block.type === "tool_use") {
260
+ onOutput({ stream, line: summarizeToolCall(block.name, block.input), kind: "tool" });
261
+ } else if (block.type === "thinking" && block.thinking) {
262
+ onOutput({ stream, line: `thinking: ${block.thinking.slice(0, 80).replace(/\s+/g, " ")}…`, kind: "thinking" });
263
+ }
168
264
  }
169
- } catch { /* not JSON, forward raw */ }
170
- onOutput({ stream, line });
265
+ return;
266
+ }
267
+
268
+ onOutput({ stream, line: summary.line, kind: summary.kind });
171
269
  };
172
270
  }
173
271
 
@@ -190,7 +288,7 @@ function createStreamJsonFilter(onOutput) {
190
288
  * response data.
191
289
  */
192
290
  function cleanExecaOpts(extra = {}) {
193
- const { CLAUDECODE, ...env } = process.env;
291
+ const { CLAUDECODE: _CLAUDECODE, ...env } = process.env;
194
292
  return { env, stdin: "ignore", ...extra };
195
293
  }
196
294
 
@@ -235,7 +333,7 @@ export class ClaudeAgent extends BaseAgent {
235
333
  return result;
236
334
  }
237
335
 
238
- async _runTaskExec(task, model, role) {
336
+ async _runTaskExec(task, model, _role) {
239
337
  const args = ["-p", task.prompt, "--allowedTools", ...ALLOWED_TOOLS];
240
338
  if (model) args.push("--model", model);
241
339
 
@@ -45,7 +45,7 @@ export function getModelInfo(name) {
45
45
  }
46
46
 
47
47
  export function getRegisteredModels() {
48
- return [...modelRegistry.entries()].map(([name, entry]) => ({
48
+ return [...modelRegistry.entries()].map(([_name, entry]) => ({
49
49
  name: entry.name,
50
50
  provider: entry.provider,
51
51
  pricing: { ...entry.pricing },