kanban-system 1.0.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 (48) hide show
  1. package/.env.example +76 -0
  2. package/CLAUDE.md +108 -0
  3. package/README.md +272 -0
  4. package/agents/_TEMPLATE.md +42 -0
  5. package/agents/backend-agent.md +81 -0
  6. package/agents/deploy-gate-agent.md +73 -0
  7. package/agents/frontend-agent.md +73 -0
  8. package/agents/monitor-agent.md +65 -0
  9. package/agents/orchestrator.md +91 -0
  10. package/agents/reviewer-codex.md +51 -0
  11. package/bin/cli.js +171 -0
  12. package/config.example.js +99 -0
  13. package/docs/adapting-to-your-project.md +155 -0
  14. package/docs/example-apex.md +86 -0
  15. package/docs/the-pattern.md +92 -0
  16. package/hooks/launchd.plist.template +66 -0
  17. package/hooks/pre-push.sample +61 -0
  18. package/lib/config.cjs +138 -0
  19. package/lib/detect/_template.cjs +63 -0
  20. package/lib/detect/rules.json +28 -0
  21. package/lib/detect/sentry.cjs +86 -0
  22. package/lib/detect/vercel.cjs +62 -0
  23. package/lib/gate/index.cjs +182 -0
  24. package/lib/runner/adapters/both.cjs +33 -0
  25. package/lib/runner/adapters/claude.cjs +119 -0
  26. package/lib/runner/adapters/codex.cjs +43 -0
  27. package/lib/runner/adapters/reviewer.cjs +91 -0
  28. package/lib/runner/budget.cjs +75 -0
  29. package/lib/runner/index.cjs +93 -0
  30. package/lib/runner/result-merger.cjs +58 -0
  31. package/lib/runner/worktree-manager.cjs +64 -0
  32. package/lib/watch/scheduler.cjs +164 -0
  33. package/package.json +59 -0
  34. package/playbooks/_TEMPLATE.html +54 -0
  35. package/playbooks/build-fail.html +57 -0
  36. package/playbooks/deploy-rollback.html +53 -0
  37. package/playbooks/e2e-regression.html +58 -0
  38. package/playbooks/playbook.css +26 -0
  39. package/playbooks/sentry-spike.html +53 -0
  40. package/server/kanban.cjs +1152 -0
  41. package/skills/archive.md +18 -0
  42. package/skills/gate.md +22 -0
  43. package/skills/standup.md +24 -0
  44. package/skills/triage.md +24 -0
  45. package/ui/kanban.html +628 -0
  46. package/ui/styles/kanban.css +436 -0
  47. package/ui/styles/progress.css +315 -0
  48. package/ui/styles/tokens.css +291 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Second-model budget enforcement + model fallback chain.
3
+ *
4
+ * Env-configurable:
5
+ * DAILY_CODEX_BUDGET=200 — max second-model (Codex/GPT) invocations per UTC day
6
+ * CROSS_VALIDATION_THRESHOLD=medium — severity ≥ this auto-promotes a single-model task to `both`
7
+ * MODEL_FALLBACK_CHAIN=opus,sonnet,haiku — Claude tiers to step down through under load
8
+ *
9
+ * On budget exhaustion:
10
+ * runner=codex → claude (single-model fallback)
11
+ * runner=both → claude (cross-validation unavailable)
12
+ * runner=reviewer:codex → claude (review skipped)
13
+ *
14
+ * Tracks invocation counts in data/runs/budget.json with a daily reset.
15
+ */
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const config = require("../config.cjs");
19
+
20
+ const BUDGET_FILE = path.join(config.repoRoot, "data", "runs", "budget.json");
21
+ const DAILY_CAP = parseInt(process.env.DAILY_CODEX_BUDGET || "200", 10);
22
+ const THRESHOLD = (process.env.CROSS_VALIDATION_THRESHOLD || "medium").toLowerCase();
23
+ const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
24
+ const FALLBACK_CHAIN = (process.env.MODEL_FALLBACK_CHAIN || "claude-opus-4-7,claude-sonnet-4-6,claude-haiku-4-5").split(",").map((s) => s.trim()).filter(Boolean);
25
+
26
+ function todayKey() { return new Date().toISOString().slice(0, 10); }
27
+ function loadBudget() {
28
+ const fresh = { day: todayKey(), codex_calls: 0, claude_calls: 0, fallbacks: 0 };
29
+ if (!fs.existsSync(BUDGET_FILE)) return fresh;
30
+ try { const b = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf-8")); return b.day !== todayKey() ? fresh : b; } catch { return fresh; }
31
+ }
32
+ function saveBudget(b) {
33
+ if (!fs.existsSync(path.dirname(BUDGET_FILE))) fs.mkdirSync(path.dirname(BUDGET_FILE), { recursive: true });
34
+ fs.writeFileSync(BUDGET_FILE, JSON.stringify(b, null, 2));
35
+ }
36
+ function codexCallsBudgeted(runner) {
37
+ if (runner === "codex") return 1;
38
+ if (runner === "both") return 1; // 1 codex + 1 claude
39
+ if (runner.startsWith("reviewer:codex")) return 1; // claude executes, codex reviews
40
+ if (runner.startsWith("reviewer:claude")) return 1; // codex executes, claude reviews
41
+ return 0;
42
+ }
43
+
44
+ async function resolveRunner(requested, task) {
45
+ const budget = loadBudget();
46
+ const required = codexCallsBudgeted(requested);
47
+ if (required === 0) { budget.claude_calls += 1; saveBudget(budget); return requested; }
48
+
49
+ // Auto-promote single-model → both when severity warrants it (and budget allows).
50
+ const severity = (task.metadata && task.metadata.severity) || task.priority || "medium";
51
+ if ((requested === "claude" || requested === "codex") && SEVERITY_RANK[severity] >= SEVERITY_RANK[THRESHOLD]) {
52
+ if (budget.codex_calls + 1 <= DAILY_CAP) { budget.codex_calls += 1; budget.claude_calls += 1; saveBudget(budget); return "both"; }
53
+ }
54
+ // Budget check.
55
+ if (budget.codex_calls + required > DAILY_CAP) { budget.fallbacks += 1; budget.claude_calls += 1; saveBudget(budget); return "claude"; }
56
+ budget.codex_calls += required;
57
+ budget.claude_calls += requested === "both" ? 1 : 0;
58
+ saveBudget(budget);
59
+ return requested;
60
+ }
61
+
62
+ function getStatus() {
63
+ const b = loadBudget();
64
+ return { day: b.day, codex_used: b.codex_calls, codex_cap: DAILY_CAP, codex_remaining: Math.max(0, DAILY_CAP - b.codex_calls), claude_calls: b.claude_calls, fallbacks: b.fallbacks, threshold: THRESHOLD, fallback_chain: FALLBACK_CHAIN };
65
+ }
66
+ function pickClaudeModel() {
67
+ const b = loadBudget();
68
+ if (b.claude_calls < 200) return FALLBACK_CHAIN[0] || "claude-opus-4-7";
69
+ if (b.claude_calls < 1000) return FALLBACK_CHAIN[1] || FALLBACK_CHAIN[0];
70
+ return FALLBACK_CHAIN[FALLBACK_CHAIN.length - 1] || "claude-haiku-4-5";
71
+ }
72
+
73
+ module.exports = { resolveRunner, getStatus, pickClaudeModel, DAILY_CAP, THRESHOLD, FALLBACK_CHAIN };
74
+
75
+ if (require.main === module) console.log(JSON.stringify(getStatus(), null, 2));
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Runner — top-level dispatcher.
3
+ *
4
+ * Reads a task's metadata.runner and dispatches to the right adapter:
5
+ * - claude / codex → single-model adapter (own git worktree)
6
+ * - both → parallel cross-validation (two worktrees)
7
+ * - reviewer:codex / :claude → executor + reviewer pipeline
8
+ *
9
+ * Then updates the task in the kanban (PUT /api/tasks/:id):
10
+ * - status: completed | in_review (on disagreement / needs-human)
11
+ * - reportSummary, reportPath
12
+ * - metadata.crossValidation: { agreement, verdict, confidence, ... }
13
+ *
14
+ * The adapters shell out to the `claude` / `codex` CLIs (which manage their own
15
+ * auth). If a CLI isn't on PATH the adapter falls back to a deterministic stub
16
+ * verdict, so this is safe to wire in before the CLIs are installed.
17
+ *
18
+ * CLI: node lib/runner/index.cjs <taskId>
19
+ */
20
+ const config = require("../config.cjs");
21
+ const claude = require("./adapters/claude.cjs");
22
+ const codex = require("./adapters/codex.cjs");
23
+ const both = require("./adapters/both.cjs");
24
+ const reviewer = require("./adapters/reviewer.cjs");
25
+ const wtm = require("./worktree-manager.cjs");
26
+ const budget = require("./budget.cjs");
27
+
28
+ const KANBAN_BASE = process.env.KANBAN_BASE || `http://localhost:${config.port}`;
29
+
30
+ async function runTask(task, opts = {}) {
31
+ const requested = (task.metadata && task.metadata.runner) || opts.runner || "claude";
32
+ const effective = await budget.resolveRunner(requested, task);
33
+
34
+ let result;
35
+ if (effective === "claude") {
36
+ const wt = wtm.createWorktree(task.id, "claude");
37
+ try { result = await claude.run(task, { ...opts, worktree: wt }); } finally { wtm.removeWorktree(wt); }
38
+ } else if (effective === "codex") {
39
+ const wt = wtm.createWorktree(task.id, "codex");
40
+ try { result = await codex.run(task, { ...opts, worktree: wt }); } finally { wtm.removeWorktree(wt); }
41
+ } else if (effective === "both") {
42
+ result = await both.run(task, opts);
43
+ } else if (effective.startsWith("reviewer:")) {
44
+ result = await reviewer.run(task, { ...opts, runner: effective });
45
+ } else {
46
+ throw new Error(`Unknown runner: ${effective}`);
47
+ }
48
+
49
+ if (effective !== requested) result.budgetFallback = { requested, effective };
50
+ await pushResultToKanban(task.id, result);
51
+ return result;
52
+ }
53
+
54
+ async function pushResultToKanban(taskId, result) {
55
+ const status = result.needsHuman || result.agreement === "disagreed" ? "in_review" : "completed";
56
+ const summary =
57
+ result.verdict === "pass"
58
+ ? `pass · ${result.runner} (conf ${result.confidence != null ? result.confidence.toFixed(2) : "—"})`
59
+ : `${result.verdict === "needs_human" ? "needs human review" : result.verdict}${result.runner ? " [" + result.runner + "]" : ""}`;
60
+ const payload = {
61
+ status,
62
+ reportSummary: summary,
63
+ reportPath: result.reportPath || result.diffPath,
64
+ metadata: {
65
+ crossValidation: {
66
+ agreement: result.agreement || "agreed",
67
+ verdict: result.verdict,
68
+ confidence: result.confidence,
69
+ ...(result.budgetFallback ? { budgetFallback: result.budgetFallback } : {}),
70
+ },
71
+ },
72
+ };
73
+ try {
74
+ await fetch(`${KANBAN_BASE}/api/tasks/${taskId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
75
+ } catch (e) {
76
+ console.warn("[runner] kanban update failed:", e.message);
77
+ }
78
+ }
79
+
80
+ module.exports = { runTask };
81
+
82
+ if (require.main === module) {
83
+ const taskId = process.argv[2];
84
+ if (!taskId) { console.error("usage: node lib/runner/index.cjs <taskId>"); process.exit(1); }
85
+ fetch(`${KANBAN_BASE}/api/tasks`)
86
+ .then((r) => r.json())
87
+ .then(async (tasks) => {
88
+ const t = tasks.find((x) => String(x.id) === String(taskId));
89
+ if (!t) { console.error(`task #${taskId} not found`); process.exit(1); }
90
+ const r = await runTask(t);
91
+ console.log(JSON.stringify({ taskId, runner: r.runner, verdict: r.verdict, agreement: r.agreement }, null, 2));
92
+ });
93
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Result merger — compares two runner results, classifies agreement, and writes a
3
+ * diff file for the "needs human" column to link to.
4
+ */
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const config = require("../config.cjs");
8
+
9
+ const RUNS_DIR = path.join(config.repoRoot, "data", "runs");
10
+
11
+ function compare(r1, r2) {
12
+ // Recover the task id from either report path: data/runs/task-<id>/...
13
+ const taskDir = [r1.reportPath, r2.reportPath].map((p) => (p || "").split(path.sep).find((seg) => seg.startsWith("task-"))).find(Boolean) || "task-unknown";
14
+ const dir = path.join(RUNS_DIR, taskDir);
15
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
16
+ const diffPath = path.join(dir, "diff.md");
17
+
18
+ let agreement, verdict, confidence;
19
+ if (r1.verdict === r2.verdict) {
20
+ agreement = "agreed"; verdict = r1.verdict; confidence = ((r1.confidence || 0) + (r2.confidence || 0)) / 2;
21
+ } else if (r1.verdict === "needs_human" || r2.verdict === "needs_human" || r1.verdict === "fail" || r2.verdict === "fail") {
22
+ agreement = "disagreed"; verdict = "needs_human"; confidence = 0;
23
+ } else {
24
+ agreement = "partial";
25
+ verdict = (r1.confidence || 0) < (r2.confidence || 0) ? r1.verdict : r2.verdict; // conservative: lower-confidence verdict wins
26
+ confidence = Math.min(r1.confidence || 0, r2.confidence || 0);
27
+ }
28
+
29
+ const md = [
30
+ "# Cross-validation diff",
31
+ "",
32
+ "| field | claude | codex |",
33
+ "|---|---|---|",
34
+ `| verdict | ${r1.verdict} | ${r2.verdict} |`,
35
+ `| confidence | ${r1.confidence} | ${r2.confidence} |`,
36
+ `| duration_ms | ${r1.duration_ms} | ${r2.duration_ms} |`,
37
+ `| mode | ${r1.mode || "live"} | ${r2.mode || "live"} |`,
38
+ "",
39
+ `**Agreement**: ${agreement}`,
40
+ `**Final verdict**: ${verdict}`,
41
+ `**Final confidence**: ${confidence.toFixed(2)}`,
42
+ "",
43
+ "## Claude summary",
44
+ r1.summary || "—",
45
+ "",
46
+ "## Codex summary",
47
+ r2.summary || "—",
48
+ "",
49
+ "## Action",
50
+ agreement === "agreed" ? "Auto-merge — both models concur." :
51
+ agreement === "partial" ? "Lower-confidence verdict adopted; review optional." :
52
+ "**Disagreed → moved to the \"needs human\" column. A human decides which verdict to take.**",
53
+ ].join("\n");
54
+ fs.writeFileSync(diffPath, md);
55
+ return { agreement, verdict, confidence, diffPath };
56
+ }
57
+
58
+ module.exports = { compare };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Git worktree manager — gives each parallel agent run its own working copy.
3
+ *
4
+ * Worktrees live at <repo-root>/data/worktrees/task-<id>-<runner>-<ts>/
5
+ * Branches are named kanban/task-<id>-<runner>-<ts>
6
+ * Cleanup happens after the task completes (success or failure).
7
+ *
8
+ * The git repo the worktrees branch off is config.repoPath — the application
9
+ * repo this harness drives — not the harness checkout itself.
10
+ */
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const { execSync } = require("child_process");
14
+ const config = require("../config.cjs");
15
+
16
+ const REPO = config.repoPath; // the app repo
17
+ const WT_DIR = path.join(config.repoRoot, "data", "worktrees"); // worktrees stored here
18
+
19
+ function ts() { return Date.now().toString(36); }
20
+ function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
21
+ function q(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
22
+ function git(cmd, opts = {}) { return execSync(cmd, { cwd: REPO, encoding: "utf-8", ...opts }); }
23
+
24
+ function createWorktree(taskId, runner) {
25
+ ensureDir(WT_DIR);
26
+ const tag = `${taskId}-${runner}-${ts()}`;
27
+ const wtPath = path.join(WT_DIR, `task-${tag}`);
28
+ const branch = `kanban/task-${tag}`;
29
+ if (fs.existsSync(wtPath)) {
30
+ try { git(`git worktree remove --force ${q(wtPath)}`); } catch {}
31
+ try { fs.rmSync(wtPath, { recursive: true, force: true }); } catch {}
32
+ }
33
+ git(`git worktree add -b ${q(branch)} ${q(wtPath)} HEAD`);
34
+ return { branch, path: wtPath, taskId, runner, createdAt: new Date().toISOString() };
35
+ }
36
+ function removeWorktree(wt) {
37
+ if (!wt || !wt.path) return;
38
+ try { git(`git worktree remove --force ${q(wt.path)}`); } catch {}
39
+ // Branch cleanup is optional — leave it for git gc.
40
+ }
41
+ function listWorktrees() {
42
+ let out = "";
43
+ try { out = git("git worktree list --porcelain"); } catch { return []; }
44
+ return out.split("\n\n").filter(Boolean).map((b) => {
45
+ const wt = {};
46
+ for (const l of b.split("\n")) {
47
+ const [k, ...rest] = l.split(" ");
48
+ if (k === "worktree") wt.path = rest.join(" ");
49
+ else if (k === "branch") wt.branch = rest.join(" ").replace("refs/heads/", "");
50
+ else if (k === "HEAD") wt.head = rest.join(" ");
51
+ }
52
+ return wt;
53
+ }).filter((wt) => wt.path && wt.path.startsWith(WT_DIR));
54
+ }
55
+ function cleanupOrphans(maxAgeHours = 24) {
56
+ const cutoff = Date.now() - maxAgeHours * 3600 * 1000;
57
+ let removed = 0;
58
+ for (const wt of listWorktrees()) {
59
+ try { if (fs.statSync(wt.path).mtimeMs < cutoff) { removeWorktree(wt); removed++; } } catch {}
60
+ }
61
+ return removed;
62
+ }
63
+
64
+ module.exports = { createWorktree, removeWorktree, listWorktrees, cleanupOrphans, WT_DIR };
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * kanban-system — 24h watch scheduler.
4
+ *
5
+ * Reads detection rules from lib/detect/rules.json, runs the detectors enabled
6
+ * in config.js → detectors (or WATCH_ENABLED), and converts findings into kanban
7
+ * tasks via the local API.
8
+ *
9
+ * No npm dependencies — only fetch / fs / setInterval — so it also runs under a
10
+ * Deno scheduled function or a cron line. Loads <repo-root>/.env via lib/config.cjs
11
+ * (launchd / cron don't source your shell).
12
+ *
13
+ * Env:
14
+ * WATCH_INTERVAL_MS — base poll interval (default 300000 = 5 min)
15
+ * KANBAN_BASE — http://localhost:8080 (default; or config.js port)
16
+ * WATCH_DRY_RUN — "1" → log alerts, don't create tasks
17
+ * WATCH_ENABLED — comma-separated detector names; overrides config.js
18
+ *
19
+ * Usage:
20
+ * node lib/watch/scheduler.cjs # daemon
21
+ * node lib/watch/scheduler.cjs --once # single sweep, then exit
22
+ */
23
+ const fs = require("fs");
24
+ const path = require("path");
25
+ const config = require("../config.cjs");
26
+
27
+ const REPO_ROOT = config.repoRoot;
28
+ const RULES_FILE = path.join(REPO_ROOT, "lib", "detect", "rules.json");
29
+ const STATE_FILE = path.join(REPO_ROOT, "data", "runs", "watch-state.json");
30
+ const FINDINGS_DIR = path.join(REPO_ROOT, "data", "runs", "watch-findings");
31
+ const KANBAN_BASE = process.env.KANBAN_BASE || `http://localhost:${config.port}`;
32
+ const INTERVAL = parseInt(process.env.WATCH_INTERVAL_MS || "300000", 10);
33
+ const DRY_RUN = process.env.WATCH_DRY_RUN === "1";
34
+
35
+ // Built-in detectors. Add yours here (and a row in rules.json + config.js → detectors).
36
+ const detectors = {
37
+ sentry: require("../detect/sentry.cjs"),
38
+ vercel: require("../detect/vercel.cjs"),
39
+ // _template: require("../detect/_template.cjs"), // copy & rename to wire up a new one
40
+ };
41
+
42
+ // Which detectors are on? WATCH_ENABLED wins; else config.js → detectors; else rules.json.
43
+ function enabledDetectorNames() {
44
+ const fromEnv = (process.env.WATCH_ENABLED || "").split(",").map((s) => s.trim()).filter(Boolean);
45
+ if (fromEnv.length) return new Set(fromEnv);
46
+ const fromConfig = (config.detectors || []).filter((d) => d.enabled !== false).map((d) => d.detector);
47
+ if (fromConfig.length) return new Set(fromConfig);
48
+ return null; // null ⇒ "use rules.json `enabled` flags as-is"
49
+ }
50
+
51
+ function loadRules() {
52
+ if (!fs.existsSync(RULES_FILE)) return { rules: [] };
53
+ return JSON.parse(fs.readFileSync(RULES_FILE, "utf-8"));
54
+ }
55
+ function loadState() {
56
+ if (!fs.existsSync(STATE_FILE)) return { lastSweep: null, alerts: {} };
57
+ try { return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")); } catch { return { lastSweep: null, alerts: {} }; }
58
+ }
59
+ function saveState(s) {
60
+ if (!fs.existsSync(path.dirname(STATE_FILE))) fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
61
+ fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2));
62
+ }
63
+ function deduplicate(state, alert) {
64
+ // Same source+signal+severity within 1h → skip (avoid task spam)
65
+ const key = `${alert.source}:${alert.signal}:${alert.severity}`;
66
+ const last = state.alerts[key];
67
+ const now = Date.now();
68
+ if (last && now - last < 60 * 60 * 1000) return true;
69
+ state.alerts[key] = now;
70
+ return false;
71
+ }
72
+
73
+ // Heartbeats, transient API errors, and missing-config notices are operational
74
+ // telemetry — they belong in sweep logs, never in the kanban backlog. Likewise any
75
+ // severity:low signal. Only medium/high actionable findings become tasks.
76
+ const INFORMATIONAL_SIGNALS = new Set(["heartbeat", "api-error", "config-missing"]);
77
+ function isInformationalOnly(alert) {
78
+ if (alert.severity === "low") return true;
79
+ if (INFORMATIONAL_SIGNALS.has(alert.signal)) return true;
80
+ return false;
81
+ }
82
+
83
+ async function postTaskFromAlert(alert) {
84
+ const body = {
85
+ subject: `[${String(alert.severity || "medium").toUpperCase()}] ${alert.source}: ${alert.signal}`,
86
+ description:
87
+ `${alert.message || ""}\n\n` +
88
+ `**Source**: ${alert.source}\n**Signal**: ${alert.signal}\n**Severity**: ${alert.severity}\n` +
89
+ `**Threshold**: ${alert.threshold || "n/a"}\n**Value**: ${alert.value || "n/a"}\n` +
90
+ `**Routes to**: ${alert.routesTo || "orchestrator"}\n\n` +
91
+ (alert.evidence ? "## Evidence\n```\n" + JSON.stringify(alert.evidence, null, 2) + "\n```" : ""),
92
+ priority: alert.severity === "high" ? "high" : alert.severity === "low" ? "low" : "medium",
93
+ agent: alert.routesTo || "orchestrator",
94
+ metadata: {
95
+ source: "watch", detector: alert.source, signal: alert.signal, severity: alert.severity,
96
+ runner: alert.severity === "high" ? "both" : "claude", tag: "alert",
97
+ },
98
+ };
99
+ if (DRY_RUN) { console.log("[watch][dry-run]", JSON.stringify(body, null, 2)); return { id: "dry-run" }; }
100
+ const r = await fetch(`${KANBAN_BASE}/api/tasks`, {
101
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
102
+ });
103
+ if (!r.ok) throw new Error(`POST /api/tasks failed: ${r.status}`);
104
+ return await r.json();
105
+ }
106
+
107
+ async function sweepOnce() {
108
+ const sweepId = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
109
+ const start = Date.now();
110
+ const rules = loadRules();
111
+ const state = loadState();
112
+ const enabled = enabledDetectorNames();
113
+ if (!fs.existsSync(FINDINGS_DIR)) fs.mkdirSync(FINDINGS_DIR, { recursive: true });
114
+
115
+ const findings = [];
116
+ const errors = [];
117
+
118
+ for (const ruleSet of rules.rules || []) {
119
+ if (enabled && !enabled.has(ruleSet.detector)) continue;
120
+ if (!enabled && ruleSet.enabled === false) continue;
121
+ const detector = detectors[ruleSet.detector];
122
+ if (!detector) { errors.push({ detector: ruleSet.detector, error: "no detector module registered in scheduler.cjs" }); continue; }
123
+ try {
124
+ const alerts = await detector.run(ruleSet, state);
125
+ for (const alert of alerts || []) {
126
+ if (isInformationalOnly(alert)) { findings.push({ ...alert, suppressed: "informational — logged, not ticketed" }); continue; }
127
+ if (deduplicate(state, alert)) { findings.push({ ...alert, deduped: true }); continue; }
128
+ try { const task = await postTaskFromAlert(alert); findings.push({ ...alert, taskId: task.id }); }
129
+ catch (e) { errors.push({ detector: ruleSet.detector, alert, error: e.message }); }
130
+ }
131
+ } catch (e) {
132
+ errors.push({ detector: ruleSet.detector, error: e.message });
133
+ }
134
+ }
135
+
136
+ state.lastSweep = new Date().toISOString();
137
+ saveState(state);
138
+
139
+ const summary = {
140
+ sweepId, duration_ms: Date.now() - start,
141
+ findings_count: findings.length,
142
+ suppressed: findings.filter((f) => f.suppressed).length,
143
+ deduped: findings.filter((f) => f.deduped).length,
144
+ posted: findings.filter((f) => f.taskId).length,
145
+ errors: errors.length,
146
+ };
147
+ fs.writeFileSync(
148
+ path.join(FINDINGS_DIR, `sweep-${sweepId}.md`),
149
+ `# Watch sweep ${sweepId}\n\n${JSON.stringify(summary, null, 2)}\n\n## Findings\n${JSON.stringify(findings, null, 2)}\n\n## Errors\n${JSON.stringify(errors, null, 2)}`,
150
+ );
151
+ console.log(`[watch] sweep ${sweepId} — ${summary.posted} posted / ${summary.suppressed} suppressed / ${summary.deduped} deduped / ${summary.errors} errors / ${summary.duration_ms}ms`);
152
+ return summary;
153
+ }
154
+
155
+ async function main() {
156
+ const isOnce = process.argv.includes("--once");
157
+ console.log(`[watch] kanban-system watch — interval=${INTERVAL}ms dryRun=${DRY_RUN} kanban=${KANBAN_BASE}`);
158
+ if (isOnce) { const r = await sweepOnce(); process.exit(r.errors > 0 ? 1 : 0); }
159
+ await sweepOnce().catch((e) => console.error("[watch] sweep error:", e));
160
+ setInterval(() => { sweepOnce().catch((e) => console.error("[watch] sweep error:", e)); }, INTERVAL);
161
+ }
162
+
163
+ if (require.main === module) main();
164
+ module.exports = { sweepOnce };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "kanban-system",
3
+ "version": "1.0.0",
4
+ "description": "A kanban board + multi-agent (Claude + Codex) ops/dev harness: routing, pre-deploy gates, incident playbooks, 24h monitoring, Telegram Ops Thread mirror.",
5
+ "keywords": [
6
+ "kanban",
7
+ "multi-agent",
8
+ "claude",
9
+ "codex",
10
+ "ops",
11
+ "harness",
12
+ "telegram",
13
+ "incident-response"
14
+ ],
15
+ "homepage": "https://github.com/Zakedu/kanban-system",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/Zakedu/kanban-system.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/Zakedu/kanban-system/issues"
22
+ },
23
+ "license": "MIT",
24
+ "main": "server/kanban.cjs",
25
+ "bin": {
26
+ "kanban-system": "./bin/cli.js"
27
+ },
28
+ "files": [
29
+ "bin/",
30
+ "server/",
31
+ "ui/",
32
+ "lib/",
33
+ "agents/",
34
+ "playbooks/",
35
+ "hooks/",
36
+ "skills/",
37
+ "docs/",
38
+ "config.example.js",
39
+ ".env.example",
40
+ "CLAUDE.md",
41
+ "README.md"
42
+ ],
43
+ "scripts": {
44
+ "start": "node server/kanban.cjs",
45
+ "dev": "node server/kanban.cjs",
46
+ "gate": "node lib/gate/index.cjs",
47
+ "watch": "node lib/watch/scheduler.cjs",
48
+ "watch:once": "node lib/watch/scheduler.cjs --once"
49
+ },
50
+ "dependencies": {
51
+ "@slack/bolt": "^4.1.0"
52
+ },
53
+ "optionalDependencies": {
54
+ "dotenv": "^16.4.5"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ }
59
+ }
@@ -0,0 +1,54 @@
1
+ <!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Playbook: TEMPLATE</title><link rel="stylesheet" href="./playbook.css"></head><body>
2
+ <!-- Copy this file to playbooks/<incident-name>.html and fill in every section.
3
+ A playbook is a short, scannable runbook: when it fires, how to diagnose,
4
+ the decision tree, when to escalate, and what to do afterwards. Keep it on
5
+ one page. The orchestrator / agents link to it from tasks; humans read it
6
+ under pressure. -->
7
+ <h1>Playbook · &lt;incident-name&gt;</h1>
8
+ <div class="meta">
9
+ <span class="tag high">severity: &lt;low|medium|high&gt;</span>
10
+ <span class="tag group">&lt;owning area&gt;</span>
11
+ <span class="tag agent">&lt;owning agent&gt;</span>
12
+ <span class="tag low">SLA: &lt;target time to mitigate&gt;</span>
13
+ </div>
14
+ <div class="card danger">
15
+ <strong>Summary</strong> — One or two sentences: what state the system is in when this
16
+ fires, and the single most important thing to check first.
17
+ </div>
18
+
19
+ <h2>Trigger</h2>
20
+ <ul>
21
+ <li>What detector / hook / observation creates a task that points here.</li>
22
+ <li>Any threshold that escalates an existing task to this playbook.</li>
23
+ </ul>
24
+
25
+ <h2>Diagnosis</h2>
26
+ <ol>
27
+ <li>First thing to look at (a log path, a dashboard, a command).</li>
28
+ <li>How to localize the cause (correlate with deploys, with other signals).</li>
29
+ <li>How to confirm the hypothesis cheaply (reproduce locally, narrow the blast radius).</li>
30
+ </ol>
31
+
32
+ <h2>Decision tree</h2>
33
+ <div class="tree">cause
34
+ ├─ &lt;case A&gt; → &lt;action&gt; → re-run the gate / verify
35
+ ├─ &lt;case B&gt; → &lt;route to which agent / which fix&gt;
36
+ └─ &lt;case C&gt; → &lt;rollback / escalate&gt;
37
+ </div>
38
+
39
+ <h2>Escalation</h2>
40
+ <table>
41
+ <tr><th>condition</th><th>action</th></tr>
42
+ <tr><td>not mitigated within the SLA</td><td>page the on-call / post in the incident channel</td></tr>
43
+ <tr><td>hotfix needed but the gate blocks it</td><td><code>KANBAN_GATE_BYPASS=1 git push</code> (audit-logged)</td></tr>
44
+ </table>
45
+
46
+ <h2>Aftermath</h2>
47
+ <ul>
48
+ <li>Link the fix PR to the gate/run report.</li>
49
+ <li>If this pattern recurs (3×): file a task to add a check that catches it earlier.</li>
50
+ <li>Write a short postmortem if it had user impact.</li>
51
+ </ul>
52
+
53
+ <div class="footer">trigger: &lt;…&gt; · owner: &lt;agent&gt; · last reviewed: YYYY-MM-DD</div>
54
+ </body></html>
@@ -0,0 +1,57 @@
1
+ <!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Playbook: build-fail</title><link rel="stylesheet" href="./playbook.css"></head><body>
2
+ <h1>Playbook · build-fail</h1>
3
+ <div class="meta">
4
+ <span class="tag high">severity: high</span>
5
+ <span class="tag group">deploy</span>
6
+ <span class="tag agent">deploy-gate-agent</span>
7
+ <span class="tag low">SLA: unblock the push within 30 min</span>
8
+ </div>
9
+ <div class="card danger">
10
+ <strong>Summary</strong> — A <code>deployCommands</code> stage (type-check / build / test)
11
+ failed, so the pre-push gate blocked the push. If a deploy was imminent, fix it now.
12
+ </div>
13
+
14
+ <h2>Trigger</h2>
15
+ <ul>
16
+ <li>The pre-push hook failed on a <code>deployCommands</code> stage.</li>
17
+ <li>A local <code>npm run gate</code> failed.</li>
18
+ <li>monitor-agent saw the hosting provider report a deploy in an ERROR state.</li>
19
+ </ul>
20
+
21
+ <h2>Diagnosis</h2>
22
+ <ol>
23
+ <li>Open the latest gate log: <code>data/runs/gate-&lt;ts&gt;/&lt;stage&gt;.log</code>.</li>
24
+ <li>The first error line usually has <code>file:line</code> — open it, check recent history (<code>git log -p &lt;file&gt;</code>).</li>
25
+ <li>Dependency issue? <code>npm ls</code> / check whether <code>package.json</code> changed; confirm runtime deps aren't in <code>devDependencies</code>.</li>
26
+ <li>Import path casing — CI is usually case-sensitive even if your dev machine isn't.</li>
27
+ <li>New <code>process.env.X</code> read that isn't in <code>.env.example</code>?</li>
28
+ </ol>
29
+
30
+ <h2>Decision tree</h2>
31
+ <div class="tree">type-check error
32
+ ├─ single type error → fix directly → re-run the gate
33
+ ├─ many files affected → delegate a task to frontend-agent / backend-agent
34
+ └─ shared types changed → cross-check across the agents that depend on them
35
+
36
+ build error
37
+ ├─ import not found → check path / casing / extension
38
+ ├─ chunk too large → file a code-splitting task to frontend-agent
39
+ └─ env var missing → sync .env.example + add it in the hosting console
40
+ </div>
41
+
42
+ <h2>Escalation</h2>
43
+ <table>
44
+ <tr><th>condition</th><th>action</th></tr>
45
+ <tr><td>not unblocked within 30 min</td><td>post in the infra channel, pull in a lead</td></tr>
46
+ <tr><td>hotfix needed but the gate blocks it</td><td><code>KANBAN_GATE_BYPASS=1 git push</code> (auto-logged to <code>data/runs/overrides.jsonl</code>)</td></tr>
47
+ <tr><td>shared-type cascade failure</td><td>orchestrator dispatches the dependent agents in parallel</td></tr>
48
+ </table>
49
+
50
+ <h2>Aftermath</h2>
51
+ <ul>
52
+ <li>Attach the gate report link to the fix PR.</li>
53
+ <li>If the same failure pattern recurs (3×) → file a task to add a lint/CI rule that catches it.</li>
54
+ </ul>
55
+
56
+ <div class="footer">trigger: pre-push hook · owner: deploy-gate-agent</div>
57
+ </body></html>