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.
- package/.env.example +76 -0
- package/CLAUDE.md +108 -0
- package/README.md +272 -0
- package/agents/_TEMPLATE.md +42 -0
- package/agents/backend-agent.md +81 -0
- package/agents/deploy-gate-agent.md +73 -0
- package/agents/frontend-agent.md +73 -0
- package/agents/monitor-agent.md +65 -0
- package/agents/orchestrator.md +91 -0
- package/agents/reviewer-codex.md +51 -0
- package/bin/cli.js +171 -0
- package/config.example.js +99 -0
- package/docs/adapting-to-your-project.md +155 -0
- package/docs/example-apex.md +86 -0
- package/docs/the-pattern.md +92 -0
- package/hooks/launchd.plist.template +66 -0
- package/hooks/pre-push.sample +61 -0
- package/lib/config.cjs +138 -0
- package/lib/detect/_template.cjs +63 -0
- package/lib/detect/rules.json +28 -0
- package/lib/detect/sentry.cjs +86 -0
- package/lib/detect/vercel.cjs +62 -0
- package/lib/gate/index.cjs +182 -0
- package/lib/runner/adapters/both.cjs +33 -0
- package/lib/runner/adapters/claude.cjs +119 -0
- package/lib/runner/adapters/codex.cjs +43 -0
- package/lib/runner/adapters/reviewer.cjs +91 -0
- package/lib/runner/budget.cjs +75 -0
- package/lib/runner/index.cjs +93 -0
- package/lib/runner/result-merger.cjs +58 -0
- package/lib/runner/worktree-manager.cjs +64 -0
- package/lib/watch/scheduler.cjs +164 -0
- package/package.json +59 -0
- package/playbooks/_TEMPLATE.html +54 -0
- package/playbooks/build-fail.html +57 -0
- package/playbooks/deploy-rollback.html +53 -0
- package/playbooks/e2e-regression.html +58 -0
- package/playbooks/playbook.css +26 -0
- package/playbooks/sentry-spike.html +53 -0
- package/server/kanban.cjs +1152 -0
- package/skills/archive.md +18 -0
- package/skills/gate.md +22 -0
- package/skills/standup.md +24 -0
- package/skills/triage.md +24 -0
- package/ui/kanban.html +628 -0
- package/ui/styles/kanban.css +436 -0
- package/ui/styles/progress.css +315 -0
- package/ui/styles/tokens.css +291 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": {
|
|
3
|
+
"version": "1.0",
|
|
4
|
+
"doc": "Declarative anomaly rules. Each block maps a detector's signals to a severity + a routing target. The scheduler re-reads this on every sweep, so you can tune thresholds without a restart. Whether a detector actually runs is decided by config.js -> detectors (or the WATCH_ENABLED env var); the `enabled` flag here is only the fallback when neither is set.",
|
|
5
|
+
"_envHints": {
|
|
6
|
+
"sentry": ["SENTRY_AUTH_TOKEN", "SENTRY_ORG_SLUG", "SENTRY_PROJECT_SLUG"],
|
|
7
|
+
"vercel": ["VERCEL_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_TEAM_ID"]
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"rules": [
|
|
11
|
+
{
|
|
12
|
+
"detector": "sentry",
|
|
13
|
+
"enabled": false,
|
|
14
|
+
"signals": [
|
|
15
|
+
{ "id": "error-rate-spike", "window": "1h", "metric": "error_rate", "threshold": "> 3x rolling baseline", "severity": "high", "routesTo": "frontend-agent", "note": "Error rate well above the rolling baseline — usually a regression from the last deploy." },
|
|
16
|
+
{ "id": "new-issue-spike", "window": "1h", "metric": "new_issues", "threshold": "> 5 in window", "severity": "medium", "routesTo": "frontend-agent" },
|
|
17
|
+
{ "id": "heartbeat", "window": "12h", "metric": "—", "threshold": "alive", "severity": "low", "routesTo": "orchestrator" }
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"detector": "vercel",
|
|
22
|
+
"enabled": false,
|
|
23
|
+
"signals": [
|
|
24
|
+
{ "id": "deploy-failure", "window": "any", "metric": "build_state", "threshold": "= ERROR", "severity": "high", "routesTo": "deploy-gate-agent" }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentry detector — polls the Sentry API for error-rate spikes and new issues.
|
|
3
|
+
*
|
|
4
|
+
* Env: SENTRY_AUTH_TOKEN, SENTRY_ORG_SLUG, SENTRY_PROJECT_SLUG
|
|
5
|
+
*
|
|
6
|
+
* Degrades gracefully when env is missing — emits a single low-severity
|
|
7
|
+
* "config-missing" alert (deduped 24h) so the operator sees the gap on the board,
|
|
8
|
+
* instead of crashing the sweep.
|
|
9
|
+
*/
|
|
10
|
+
const TOKEN = process.env.SENTRY_AUTH_TOKEN || "";
|
|
11
|
+
const ORG = process.env.SENTRY_ORG_SLUG || "";
|
|
12
|
+
const PROJECT = process.env.SENTRY_PROJECT_SLUG || "";
|
|
13
|
+
const BASE = "https://sentry.io/api/0";
|
|
14
|
+
|
|
15
|
+
async function sentryFetch(pathname) {
|
|
16
|
+
const r = await fetch(`${BASE}${pathname}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
|
17
|
+
if (!r.ok) throw new Error(`sentry ${pathname}: ${r.status}`);
|
|
18
|
+
return await r.json();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function run(ruleSet, state) {
|
|
22
|
+
if (!TOKEN || !ORG || !PROJECT) {
|
|
23
|
+
const k = "sentry:config-missing";
|
|
24
|
+
if (state.alerts[k] && Date.now() - state.alerts[k] < 24 * 3600 * 1000) return [];
|
|
25
|
+
return [{
|
|
26
|
+
source: "sentry", signal: "config-missing", severity: "low",
|
|
27
|
+
message: "SENTRY_AUTH_TOKEN / SENTRY_ORG_SLUG / SENTRY_PROJECT_SLUG not set — Sentry polling disabled. Add them to .env.",
|
|
28
|
+
threshold: "env present", value: "missing", routesTo: "orchestrator",
|
|
29
|
+
evidence: { needs: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG_SLUG", "SENTRY_PROJECT_SLUG"] },
|
|
30
|
+
}];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const alerts = [];
|
|
34
|
+
const baselineKey = "sentry:baseline:error_rate";
|
|
35
|
+
const baseline = state[baselineKey] || null;
|
|
36
|
+
try {
|
|
37
|
+
// Hourly event stats — legacy endpoint that accepts a project slug.
|
|
38
|
+
// Returns [[unix_ts, count], ...]; the last bucket is the in-progress hour.
|
|
39
|
+
const stats = await sentryFetch(`/projects/${ORG}/${PROJECT}/stats/?stat=received&since=${Math.floor(Date.now() / 1000) - 6 * 3600}`);
|
|
40
|
+
const counts = (Array.isArray(stats) ? stats : []).map((b) => b[1] || 0);
|
|
41
|
+
const recent = counts.slice(-1)[0] || 0;
|
|
42
|
+
const prior = counts.slice(0, -1);
|
|
43
|
+
const avgPriorHours = prior.length ? prior.reduce((a, b) => a + b, 0) / prior.length : 0;
|
|
44
|
+
|
|
45
|
+
if (baseline && recent > baseline * 3 && recent > 5) {
|
|
46
|
+
alerts.push({
|
|
47
|
+
source: "sentry", signal: "error-rate-spike", severity: "high",
|
|
48
|
+
message: `Sentry error rate ${recent}/h — ${(recent / Math.max(1, baseline)).toFixed(1)}× baseline (${baseline.toFixed(1)}/h).`,
|
|
49
|
+
threshold: `${(baseline * 3).toFixed(1)}/h`, value: `${recent}/h`, routesTo: "frontend-agent",
|
|
50
|
+
evidence: { window: "1h current bucket", baseline, recent, hourlyAvg: avgPriorHours },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Rolling EMA baseline (alpha 0.2 for hourly samples)
|
|
54
|
+
state[baselineKey] = baseline ? baseline * 0.8 + recent * 0.2 : Math.max(recent, avgPriorHours);
|
|
55
|
+
|
|
56
|
+
// New issues — Sentry rejects statsPeriod=1h, so fetch 24h and filter client-side.
|
|
57
|
+
const qs = new URLSearchParams({ statsPeriod: "24h", query: "is:unresolved", limit: "100" }).toString();
|
|
58
|
+
const issues24h = await sentryFetch(`/projects/${ORG}/${PROJECT}/issues/?${qs}`);
|
|
59
|
+
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
|
60
|
+
const newIssues1h = (Array.isArray(issues24h) ? issues24h : []).filter((i) => i.firstSeen && new Date(i.firstSeen).getTime() >= oneHourAgo);
|
|
61
|
+
if (newIssues1h.length > 5) {
|
|
62
|
+
alerts.push({
|
|
63
|
+
source: "sentry", signal: "new-issue-spike", severity: "medium",
|
|
64
|
+
message: `${newIssues1h.length} new unresolved issues in the last hour (24h total: ${issues24h.length}).`,
|
|
65
|
+
threshold: "> 5", value: `${newIssues1h.length}`, routesTo: "frontend-agent",
|
|
66
|
+
evidence: { topIssues: newIssues1h.slice(0, 3).map((i) => ({ title: i.title, count: i.count, firstSeen: i.firstSeen, link: i.permalink })) },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Heartbeat — confirm the path is alive (deduped 12h)
|
|
71
|
+
const hbKey = "sentry:heartbeat";
|
|
72
|
+
if (!state.alerts[hbKey] || Date.now() - state.alerts[hbKey] > 12 * 3600 * 1000) {
|
|
73
|
+
alerts.push({
|
|
74
|
+
source: "sentry", signal: "heartbeat", severity: "low",
|
|
75
|
+
message: `Sentry polling OK. Last 1h: ${recent} events; 6h avg ${avgPriorHours.toFixed(1)}/h; baseline ${(state[baselineKey] || 0).toFixed(1)}.`,
|
|
76
|
+
threshold: "alive", value: "ok", routesTo: "orchestrator",
|
|
77
|
+
evidence: { recent, avgPriorHours, baseline: state[baselineKey], org: ORG, project: PROJECT },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return [{ source: "sentry", signal: "api-error", severity: "low", message: `Sentry API error: ${e.message}`, routesTo: "orchestrator", evidence: { error: e.message } }];
|
|
82
|
+
}
|
|
83
|
+
return alerts;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { run };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel detector — polls the Vercel API for deploy state (and is a stub for 5xx
|
|
3
|
+
* rate / bundle delta, which need a log-drain integration to do properly).
|
|
4
|
+
*
|
|
5
|
+
* Env: VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID (only for team accounts)
|
|
6
|
+
*
|
|
7
|
+
* Degrades gracefully when env is missing.
|
|
8
|
+
*/
|
|
9
|
+
const TOKEN = process.env.VERCEL_TOKEN || "";
|
|
10
|
+
const PROJECT = process.env.VERCEL_PROJECT_ID || "";
|
|
11
|
+
const TEAM = process.env.VERCEL_TEAM_ID || "";
|
|
12
|
+
const BASE = "https://api.vercel.com";
|
|
13
|
+
|
|
14
|
+
async function vercelFetch(pathname) {
|
|
15
|
+
const sep = pathname.includes("?") ? "&" : "?";
|
|
16
|
+
const url = `${BASE}${pathname}${TEAM ? `${sep}teamId=${TEAM}` : ""}`;
|
|
17
|
+
const r = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
|
18
|
+
if (!r.ok) throw new Error(`vercel ${pathname}: ${r.status}`);
|
|
19
|
+
return await r.json();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function run(ruleSet, state) {
|
|
23
|
+
if (!TOKEN || !PROJECT) {
|
|
24
|
+
const k = "vercel:config-missing";
|
|
25
|
+
if (state.alerts[k] && Date.now() - state.alerts[k] < 24 * 3600 * 1000) return [];
|
|
26
|
+
return [{
|
|
27
|
+
source: "vercel", signal: "config-missing", severity: "low",
|
|
28
|
+
message: "VERCEL_TOKEN / VERCEL_PROJECT_ID not set — Vercel polling disabled. Add them to .env.",
|
|
29
|
+
threshold: "env present", value: "missing", routesTo: "orchestrator",
|
|
30
|
+
}];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const alerts = [];
|
|
34
|
+
try {
|
|
35
|
+
const deploys = await vercelFetch(`/v6/deployments?projectId=${PROJECT}&limit=5`);
|
|
36
|
+
const list = deploys.deployments || [];
|
|
37
|
+
const failed = list.find((d) => d.state === "ERROR" || d.state === "CANCELED");
|
|
38
|
+
if (failed) {
|
|
39
|
+
const key = `vercel:deploy-fail:${failed.uid}`;
|
|
40
|
+
if (!state.alerts[key]) {
|
|
41
|
+
alerts.push({
|
|
42
|
+
source: "vercel", signal: "deploy-failure", severity: "high",
|
|
43
|
+
message: `Vercel deploy ${failed.uid} state=${failed.state} (${(failed.meta && failed.meta.githubCommitMessage) || failed.name}).`,
|
|
44
|
+
threshold: "state != ERROR/CANCELED", value: failed.state, routesTo: "deploy-gate-agent",
|
|
45
|
+
evidence: { uid: failed.uid, branch: failed.meta && failed.meta.githubCommitRef, commit: failed.meta && (failed.meta.githubCommitSha || "").slice(0, 7), url: failed.url },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Bundle delta / 5xx burst would need a log-drain integration; out of scope here.
|
|
50
|
+
// Note the most recent READY deploy so a future bundle-inspect step has a marker.
|
|
51
|
+
const recent = list.find((d) => d.state === "READY");
|
|
52
|
+
if (recent) {
|
|
53
|
+
const seenKey = `vercel:last-deploy:${recent.uid}`;
|
|
54
|
+
if (!state[seenKey]) state[seenKey] = recent.created;
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return [{ source: "vercel", signal: "api-error", severity: "low", message: `Vercel API error: ${e.message}`, routesTo: "orchestrator" }];
|
|
58
|
+
}
|
|
59
|
+
return alerts;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { run };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Pre-deploy gate.
|
|
4
|
+
*
|
|
5
|
+
* Runs config.js → deployCommands serially (fail-fast) from config.js → repoPath,
|
|
6
|
+
* then an optional bundle-inspection stage (if config.js → buildOutputDir is set).
|
|
7
|
+
* Each stage writes its own log under data/runs/gate-<ts>/, plus a summary report.md.
|
|
8
|
+
* On failure, auto-creates a kanban task in the "needs human" column (disable with
|
|
9
|
+
* GATE_NO_KANBAN=1).
|
|
10
|
+
*
|
|
11
|
+
* Exit codes: 0 = pass; N = the (1-based) index of the failed deployCommands stage;
|
|
12
|
+
* if bundle inspection warns and STRICT_BUNDLE=1, exit = deployCommands.length + 1.
|
|
13
|
+
*
|
|
14
|
+
* Env: GATE_TIMEOUT_MS (per stage, default 600000), STRICT_BUNDLE, GATE_NO_KANBAN.
|
|
15
|
+
*
|
|
16
|
+
* Usage: npm run gate (or node lib/gate/index.cjs)
|
|
17
|
+
*/
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const http = require("http");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
const { spawnSync } = require("child_process");
|
|
22
|
+
const config = require("../config.cjs");
|
|
23
|
+
|
|
24
|
+
const REPO = config.repoPath;
|
|
25
|
+
const HARNESS_ROOT = config.repoRoot;
|
|
26
|
+
const RUNS_DIR = path.join(HARNESS_ROOT, "data", "runs");
|
|
27
|
+
const GATE_TIMEOUT = config.gateTimeoutMs;
|
|
28
|
+
const KANBAN_PORT = config.port;
|
|
29
|
+
|
|
30
|
+
function ts() { return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); }
|
|
31
|
+
function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
|
|
32
|
+
function currentBranch() {
|
|
33
|
+
try { return (spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: REPO, encoding: "utf-8" }).stdout || "").trim() || "unknown"; }
|
|
34
|
+
catch { return "unknown"; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function runStage(name, cmd, args, dir, env = {}) {
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
const logFile = path.join(dir, `${name}.log`);
|
|
40
|
+
process.stdout.write(`[gate] ${name} → ${cmd} ${args.join(" ")} (cwd=${REPO})\n`);
|
|
41
|
+
const result = spawnSync(cmd, args, { cwd: REPO, timeout: GATE_TIMEOUT, encoding: "utf-8", env: { ...process.env, FORCE_COLOR: "0", ...env } });
|
|
42
|
+
const stdout = result.stdout || "", stderr = result.stderr || "";
|
|
43
|
+
const status = result.status === null ? -1 : result.status;
|
|
44
|
+
fs.writeFileSync(logFile, `# ${name}\nexit_code: ${status}\nduration_ms: ${Date.now() - start}\ncwd: ${REPO}\ncmd: ${cmd} ${args.join(" ")}\n\n## stdout\n${stdout}\n\n## stderr\n${stderr}\n`);
|
|
45
|
+
return { name, passed: status === 0, status, duration: Date.now() - start, logFile, stdout, stderr };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function walkSize(root) {
|
|
49
|
+
let total = 0, count = 0; const all = [];
|
|
50
|
+
(function walk(p) {
|
|
51
|
+
for (const e of fs.readdirSync(p, { withFileTypes: true })) {
|
|
52
|
+
const full = path.join(p, e.name);
|
|
53
|
+
if (e.isDirectory()) walk(full);
|
|
54
|
+
else if (e.isFile()) { const s = fs.statSync(full).size; total += s; count++; all.push({ path: path.relative(root, full), size: s }); }
|
|
55
|
+
}
|
|
56
|
+
})(root);
|
|
57
|
+
all.sort((a, b) => b.size - a.size);
|
|
58
|
+
return { total, count, largest: all.slice(0, 5) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function inspectBundle(dir) {
|
|
62
|
+
if (!config.buildOutputDir) return { name: `${String(config.deployCommands.length + 1).padStart(2, "0")}-inspect`, passed: true, status: 0, duration: 0, skipped: true, note: "buildOutputDir not configured" };
|
|
63
|
+
const outDir = path.join(REPO, config.buildOutputDir);
|
|
64
|
+
const stageName = `${String(config.deployCommands.length + 1).padStart(2, "0")}-inspect`;
|
|
65
|
+
if (!fs.existsSync(outDir)) return { name: stageName, passed: true, status: 0, duration: 0, skipped: true, note: `${config.buildOutputDir}/ not found` };
|
|
66
|
+
const sizes = walkSize(outDir);
|
|
67
|
+
const totalKB = Math.round(sizes.total / 1024);
|
|
68
|
+
fs.writeFileSync(path.join(dir, `${stageName}.log`),
|
|
69
|
+
`total: ${totalKB} KB\nfile_count: ${sizes.count}\nlargest:\n` + sizes.largest.map((f) => ` ${(f.size / 1024).toFixed(1)} KB ${f.path}`).join("\n"));
|
|
70
|
+
let baseline = null;
|
|
71
|
+
const lastGateFile = path.join(RUNS_DIR, "last-gate.json");
|
|
72
|
+
if (fs.existsSync(lastGateFile)) { try { baseline = JSON.parse(fs.readFileSync(lastGateFile, "utf-8")); } catch {} }
|
|
73
|
+
let warning = null;
|
|
74
|
+
if (baseline && baseline.totalKB) {
|
|
75
|
+
const delta = ((totalKB - baseline.totalKB) / baseline.totalKB) * 100;
|
|
76
|
+
if (delta > 10) warning = `bundle +${delta.toFixed(1)}% (was ${baseline.totalKB} KB, now ${totalKB} KB)`;
|
|
77
|
+
}
|
|
78
|
+
return { name: stageName, passed: !warning, status: warning ? 1 : 0, duration: 0, totalKB, fileCount: sizes.count, warning };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function finalize(dir, tag, stages, exitCode) {
|
|
82
|
+
const reportPath = path.join(dir, "report.md");
|
|
83
|
+
const totalDuration = stages.reduce((n, s) => n + (s.duration || 0), 0);
|
|
84
|
+
const passed = exitCode === 0;
|
|
85
|
+
const branch = currentBranch();
|
|
86
|
+
let md = `# Gate Run · ${tag}\n\n**Branch**: ${branch}\n**Verdict**: ${passed ? "✓ PASS" : "✗ FAIL"} (exit ${exitCode})\n**Total duration**: ${(totalDuration / 1000).toFixed(1)}s\n\n## Stages\n\n| # | stage | status | duration | note |\n|---|---|---|---|---|\n`;
|
|
87
|
+
stages.forEach((s, i) => {
|
|
88
|
+
const status = s.skipped ? "⊘ skipped" : s.passed ? "✓ pass" : "✗ fail";
|
|
89
|
+
md += `| ${i + 1} | ${s.name} | ${status} | ${s.duration ? (s.duration / 1000).toFixed(1) + "s" : "—"} | ${s.warning || s.note || ""} |\n`;
|
|
90
|
+
});
|
|
91
|
+
md += `\n## Logs\n\n`;
|
|
92
|
+
stages.forEach((s) => { if (s.logFile) md += `- ${path.relative(HARNESS_ROOT, s.logFile)}\n`; });
|
|
93
|
+
fs.writeFileSync(reportPath, md);
|
|
94
|
+
|
|
95
|
+
if (passed) {
|
|
96
|
+
const insp = stages.find((s) => s.name.endsWith("-inspect"));
|
|
97
|
+
if (insp && insp.totalKB) { ensureDir(RUNS_DIR); fs.writeFileSync(path.join(RUNS_DIR, "last-gate.json"), JSON.stringify({ tag, totalKB: insp.totalKB, fileCount: insp.fileCount, completedAt: new Date().toISOString() }, null, 2)); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let pending = null;
|
|
101
|
+
if (!passed && process.env.GATE_NO_KANBAN !== "1") {
|
|
102
|
+
pending = notifyFailure({ tag, dir, branch, stages, exitCode, reportPath }).catch((err) => process.stderr.write(`[gate] kanban notify failed: ${err.message}\n`));
|
|
103
|
+
}
|
|
104
|
+
process.stdout.write(`\n[gate] ${passed ? "✓ PASS" : "✗ FAIL"} (exit ${exitCode}) — report: ${reportPath}\n`);
|
|
105
|
+
return { passed, exitCode, stages, reportPath, dir, pending };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runGate(opts = {}) {
|
|
109
|
+
const tag = opts.tag || ts();
|
|
110
|
+
const dir = path.join(RUNS_DIR, `gate-${tag}`);
|
|
111
|
+
ensureDir(dir);
|
|
112
|
+
const stages = [];
|
|
113
|
+
|
|
114
|
+
if (!config.deployCommands.length) {
|
|
115
|
+
process.stdout.write("[gate] config.js → deployCommands is empty. Set your build/test commands. Treating as pass.\n");
|
|
116
|
+
return finalize(dir, tag, [{ name: "00-noop", passed: true, status: 0, duration: 0, skipped: true, note: "no deployCommands configured" }], 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let i = 0;
|
|
120
|
+
for (const dc of config.deployCommands) {
|
|
121
|
+
i++;
|
|
122
|
+
const name = dc.name || `${String(i).padStart(2, "0")}-${(dc.cmd || "stage").replace(/[^a-z0-9]+/gi, "")}`;
|
|
123
|
+
const s = runStage(name, dc.cmd, dc.args || [], dir, dc.env || {});
|
|
124
|
+
stages.push(s);
|
|
125
|
+
if (!s.passed) return finalize(dir, tag, stages, i);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const insp = inspectBundle(dir);
|
|
129
|
+
stages.push(insp);
|
|
130
|
+
let exitCode = 0;
|
|
131
|
+
if (!insp.passed && process.env.STRICT_BUNDLE === "1") exitCode = config.deployCommands.length + 1;
|
|
132
|
+
return finalize(dir, tag, stages, exitCode);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function notifyFailure({ tag, dir, branch, stages, exitCode, reportPath }) {
|
|
136
|
+
const failed = stages.find((s) => !s.passed && !s.skipped);
|
|
137
|
+
const stageName = failed ? failed.name : `exit-${exitCode}`;
|
|
138
|
+
const reportRel = path.relative(HARNESS_ROOT, reportPath);
|
|
139
|
+
const stageRows = stages.map((s, i) => `${i + 1}. ${s.name} — ${s.skipped ? "skipped" : s.passed ? "pass" : "FAIL"}${s.warning ? ` (${s.warning})` : ""}${s.note ? ` (${s.note})` : ""}`).join("\n");
|
|
140
|
+
const description = [
|
|
141
|
+
`Gate run **${tag}** blocked the push on branch \`${branch}\`.`,
|
|
142
|
+
"",
|
|
143
|
+
`**Failed stage**: ${stageName} (exit ${exitCode})`,
|
|
144
|
+
`**Report**: ${reportRel}`,
|
|
145
|
+
`**Run dir**: ${path.relative(HARNESS_ROOT, dir)}`,
|
|
146
|
+
"", "## Stages", stageRows,
|
|
147
|
+
"", "Resolve the failed stage, then re-push. To bypass (audited): `KANBAN_GATE_BYPASS=1 git push`.",
|
|
148
|
+
].join("\n");
|
|
149
|
+
const payload = {
|
|
150
|
+
subject: `[BUILD-FAIL] ${branch}: ${stageName} (gate ${tag})`,
|
|
151
|
+
description, status: "in_review", priority: "high", agent: "deploy-gate-agent",
|
|
152
|
+
reportPath: reportRel, reportSummary: `${stageName} failed (exit ${exitCode})`,
|
|
153
|
+
};
|
|
154
|
+
return postKanban("/api/tasks", payload).then((task) => {
|
|
155
|
+
if (task && task.id) {
|
|
156
|
+
postKanban(`/api/tasks/${task.id}/slack`, { text: `[BLOCKED] deploy-gate-agent: ${payload.subject}. Stage ${stageName} failed. Report: ${reportRel}` }).catch(() => {});
|
|
157
|
+
process.stdout.write(`[gate] kanban task #${task.id} created (status=in_review, priority=high)\n`);
|
|
158
|
+
}
|
|
159
|
+
return task;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function postKanban(pathname, body) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const data = JSON.stringify(body);
|
|
166
|
+
const req = http.request({ host: "127.0.0.1", port: KANBAN_PORT, path: pathname, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, timeout: 4000 }, (res) => {
|
|
167
|
+
let chunks = "";
|
|
168
|
+
res.on("data", (c) => (chunks += c));
|
|
169
|
+
res.on("end", () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { resolve(JSON.parse(chunks)); } catch { resolve(null); } } else reject(new Error(`HTTP ${res.statusCode}: ${chunks.slice(0, 200)}`)); });
|
|
170
|
+
});
|
|
171
|
+
req.on("error", reject);
|
|
172
|
+
req.on("timeout", () => req.destroy(new Error("timeout")));
|
|
173
|
+
req.write(data); req.end();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { runGate, inspectBundle };
|
|
178
|
+
|
|
179
|
+
if (require.main === module) {
|
|
180
|
+
const result = runGate();
|
|
181
|
+
Promise.resolve(result.pending).finally(() => process.exit(result.exitCode));
|
|
182
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "both" adapter — runs Claude and Codex in parallel on independent worktrees,
|
|
3
|
+
* compares their verdicts, returns a combined result. Disagreement → needs_human.
|
|
4
|
+
*/
|
|
5
|
+
const claude = require("./claude.cjs");
|
|
6
|
+
const codex = require("./codex.cjs");
|
|
7
|
+
const wtm = require("../worktree-manager.cjs");
|
|
8
|
+
const merger = require("../result-merger.cjs");
|
|
9
|
+
|
|
10
|
+
async function run(task, opts = {}) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
const wt1 = wtm.createWorktree(task.id, "claude");
|
|
13
|
+
const wt2 = wtm.createWorktree(task.id, "codex");
|
|
14
|
+
let r1, r2;
|
|
15
|
+
try {
|
|
16
|
+
[r1, r2] = await Promise.all([
|
|
17
|
+
claude.run(task, { ...opts, worktree: wt1 }),
|
|
18
|
+
codex.run(task, { ...opts, worktree: wt2 }),
|
|
19
|
+
]);
|
|
20
|
+
} finally {
|
|
21
|
+
wtm.removeWorktree(wt1);
|
|
22
|
+
wtm.removeWorktree(wt2);
|
|
23
|
+
}
|
|
24
|
+
const merged = merger.compare(r1, r2);
|
|
25
|
+
return {
|
|
26
|
+
runner: "both", duration_ms: Date.now() - start,
|
|
27
|
+
claude: r1, codex: r2,
|
|
28
|
+
agreement: merged.agreement, verdict: merged.verdict, confidence: merged.confidence,
|
|
29
|
+
diffPath: merged.diffPath, needsHuman: merged.agreement === "disagreed",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { run };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude CLI adapter — spawns `claude` to execute a task inside the isolated git
|
|
3
|
+
* worktree it's handed. Captures output under data/runs/task-<id>/claude/.
|
|
4
|
+
*
|
|
5
|
+
* Falls back to a deterministic stub verdict if the `claude` CLI isn't on PATH
|
|
6
|
+
* (useful in CI / before the CLI is installed).
|
|
7
|
+
*
|
|
8
|
+
* The agent definition for the task is pulled from <repo-root>/agents/<task.agent>.md.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const { spawnSync, execSync } = require("child_process");
|
|
13
|
+
const config = require("../../config.cjs");
|
|
14
|
+
|
|
15
|
+
const RUNS_DIR = path.join(config.repoRoot, "data", "runs");
|
|
16
|
+
const AGENTS_DIR = path.join(config.repoRoot, "agents");
|
|
17
|
+
|
|
18
|
+
function isClaudeAvailable() { try { execSync("which claude", { stdio: "ignore" }); return true; } catch { return false; } }
|
|
19
|
+
function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
|
|
20
|
+
|
|
21
|
+
function loadAgentDef(name) {
|
|
22
|
+
if (!name) return null;
|
|
23
|
+
const fp = path.join(AGENTS_DIR, name + ".md");
|
|
24
|
+
return fs.existsSync(fp) ? fs.readFileSync(fp, "utf-8") : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildPrompt(task, agentDef) {
|
|
28
|
+
return [
|
|
29
|
+
"# Task Execution",
|
|
30
|
+
"",
|
|
31
|
+
`**Task ID**: ${task.id}`,
|
|
32
|
+
`**Subject**: ${task.subject}`,
|
|
33
|
+
`**Agent**: ${task.agent || "unassigned"}`,
|
|
34
|
+
`**Status**: ${task.status}`,
|
|
35
|
+
`**Application repo**: ${config.repoPath}`,
|
|
36
|
+
"",
|
|
37
|
+
"## Agent Definition",
|
|
38
|
+
"",
|
|
39
|
+
agentDef || "(no agent definition found for this agent)",
|
|
40
|
+
"",
|
|
41
|
+
"## Task Description",
|
|
42
|
+
"",
|
|
43
|
+
task.description || "(no description)",
|
|
44
|
+
"",
|
|
45
|
+
"## Required Output",
|
|
46
|
+
"",
|
|
47
|
+
"Produce a single markdown report with this structure:",
|
|
48
|
+
"",
|
|
49
|
+
"```markdown",
|
|
50
|
+
"---",
|
|
51
|
+
"verdict: pass | fail | flag | needs_human",
|
|
52
|
+
"confidence: 0.0-1.0",
|
|
53
|
+
"---",
|
|
54
|
+
"",
|
|
55
|
+
"## Summary",
|
|
56
|
+
"<one paragraph>",
|
|
57
|
+
"",
|
|
58
|
+
"## Findings",
|
|
59
|
+
"- <bullet, with file:line where applicable>",
|
|
60
|
+
"",
|
|
61
|
+
"## Recommended action",
|
|
62
|
+
"<one of: merge | regenerate | escalate | hold>",
|
|
63
|
+
"```",
|
|
64
|
+
"",
|
|
65
|
+
"Execute the task per your agent definition. Do not ask clarifying questions — make best-effort decisions and document them.",
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseVerdict(md) {
|
|
70
|
+
const fm = md.match(/^---\n([\s\S]*?)\n---/);
|
|
71
|
+
let verdict = "needs_human", confidence = 0;
|
|
72
|
+
if (fm) {
|
|
73
|
+
const v = fm[1].match(/^verdict:\s*(\w+)/m);
|
|
74
|
+
const c = fm[1].match(/^confidence:\s*([\d.]+)/m);
|
|
75
|
+
if (v) verdict = v[1];
|
|
76
|
+
if (c) confidence = parseFloat(c[1]);
|
|
77
|
+
}
|
|
78
|
+
const sm = md.match(/## Summary\s*\n([\s\S]*?)(?=\n##|$)/);
|
|
79
|
+
return { verdict, confidence, summary: sm ? sm[1].trim() : "" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stubVerdict(task, runner) {
|
|
83
|
+
return [
|
|
84
|
+
"---", "verdict: needs_human", "confidence: 0.5", "---", "",
|
|
85
|
+
"## Summary",
|
|
86
|
+
`[stub:${runner}] CLI not on PATH. Task #${task.id} needs manual execution (or install the ${runner} CLI).`,
|
|
87
|
+
"", "## Findings",
|
|
88
|
+
`- ${runner} CLI not found — install it or add it to PATH for the runner`,
|
|
89
|
+
"", "## Recommended action", "escalate",
|
|
90
|
+
].join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function run(task, opts = {}) {
|
|
94
|
+
const wt = opts.worktree;
|
|
95
|
+
const runDir = path.join(RUNS_DIR, `task-${task.id}`, "claude");
|
|
96
|
+
ensureDir(runDir);
|
|
97
|
+
const start = Date.now();
|
|
98
|
+
const prompt = buildPrompt(task, loadAgentDef(task.agent));
|
|
99
|
+
fs.writeFileSync(path.join(runDir, "prompt.md"), prompt);
|
|
100
|
+
|
|
101
|
+
let stdout = "", stderr = "", status = -1, mode = "live";
|
|
102
|
+
if (!isClaudeAvailable()) {
|
|
103
|
+
mode = "stub"; stdout = stubVerdict(task, "claude");
|
|
104
|
+
} else {
|
|
105
|
+
const r = spawnSync("claude", ["--print", "--model", "opus", prompt], {
|
|
106
|
+
cwd: wt ? wt.path : config.repoPath, encoding: "utf-8", timeout: opts.timeout || 600000,
|
|
107
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
108
|
+
});
|
|
109
|
+
stdout = r.stdout || ""; stderr = r.stderr || ""; status = r.status === null ? -1 : r.status;
|
|
110
|
+
}
|
|
111
|
+
fs.writeFileSync(path.join(runDir, "stdout.md"), stdout);
|
|
112
|
+
if (stderr) fs.writeFileSync(path.join(runDir, "stderr.log"), stderr);
|
|
113
|
+
const parsed = parseVerdict(stdout);
|
|
114
|
+
const reportPath = path.join(runDir, "report.md");
|
|
115
|
+
fs.writeFileSync(reportPath, stdout);
|
|
116
|
+
return { runner: "claude", mode, duration_ms: Date.now() - start, status, verdict: parsed.verdict, confidence: parsed.confidence, reportPath, summary: parsed.summary };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { run, isClaudeAvailable, parseVerdict, stubVerdict, buildPrompt, loadAgentDef };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI adapter — same shape as claude.cjs, but spawns the `codex` CLI.
|
|
3
|
+
* Falls back to a stub verdict if `codex` isn't on PATH.
|
|
4
|
+
*/
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const { spawnSync, execSync } = require("child_process");
|
|
8
|
+
const config = require("../../config.cjs");
|
|
9
|
+
const claude = require("./claude.cjs");
|
|
10
|
+
|
|
11
|
+
const RUNS_DIR = path.join(config.repoRoot, "data", "runs");
|
|
12
|
+
|
|
13
|
+
function isCodexAvailable() { try { execSync("which codex", { stdio: "ignore" }); return true; } catch { return false; } }
|
|
14
|
+
function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
|
|
15
|
+
|
|
16
|
+
async function run(task, opts = {}) {
|
|
17
|
+
const wt = opts.worktree;
|
|
18
|
+
const runDir = path.join(RUNS_DIR, `task-${task.id}`, "codex");
|
|
19
|
+
ensureDir(runDir);
|
|
20
|
+
const start = Date.now();
|
|
21
|
+
// Reuse the same prompt builder + agent loader as the Claude adapter, for parity.
|
|
22
|
+
const prompt = claude.buildPrompt(task, claude.loadAgentDef(task.agent));
|
|
23
|
+
fs.writeFileSync(path.join(runDir, "prompt.md"), prompt);
|
|
24
|
+
|
|
25
|
+
let stdout = "", stderr = "", status = -1, mode = "live";
|
|
26
|
+
if (!isCodexAvailable()) {
|
|
27
|
+
mode = "stub"; stdout = claude.stubVerdict(task, "codex");
|
|
28
|
+
} else {
|
|
29
|
+
const r = spawnSync("codex", ["exec", "--quiet", prompt], {
|
|
30
|
+
cwd: wt ? wt.path : config.repoPath, encoding: "utf-8", timeout: opts.timeout || 600000,
|
|
31
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
32
|
+
});
|
|
33
|
+
stdout = r.stdout || ""; stderr = r.stderr || ""; status = r.status === null ? -1 : r.status;
|
|
34
|
+
}
|
|
35
|
+
fs.writeFileSync(path.join(runDir, "stdout.md"), stdout);
|
|
36
|
+
if (stderr) fs.writeFileSync(path.join(runDir, "stderr.log"), stderr);
|
|
37
|
+
const parsed = claude.parseVerdict(stdout);
|
|
38
|
+
const reportPath = path.join(runDir, "report.md");
|
|
39
|
+
fs.writeFileSync(reportPath, stdout);
|
|
40
|
+
return { runner: "codex", mode, duration_ms: Date.now() - start, status, verdict: parsed.verdict, confidence: parsed.confidence, reportPath, summary: parsed.summary };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { run, isCodexAvailable };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reviewer adapter — one model executes, the other reviews the result.
|
|
3
|
+
*
|
|
4
|
+
* reviewer:codex — Claude does the work, Codex reviews
|
|
5
|
+
* reviewer:claude — Codex does the work, Claude reviews
|
|
6
|
+
*
|
|
7
|
+
* The reviewer gets the executor's full report (no worktree, no code change) and is
|
|
8
|
+
* asked to flag concerns. If it flags a needs_human/fail issue, the final verdict is
|
|
9
|
+
* downgraded to needs_human and the task moves to the "needs human" column.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const claude = require("./claude.cjs");
|
|
14
|
+
const codex = require("./codex.cjs");
|
|
15
|
+
const wtm = require("../worktree-manager.cjs");
|
|
16
|
+
const config = require("../../config.cjs");
|
|
17
|
+
|
|
18
|
+
const RUNS_DIR = path.join(config.repoRoot, "data", "runs");
|
|
19
|
+
|
|
20
|
+
async function run(task, opts = {}) {
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
const mode = opts.runner || "reviewer:codex";
|
|
23
|
+
const reviewerName = mode.split(":")[1];
|
|
24
|
+
const executorName = reviewerName === "codex" ? "claude" : "codex";
|
|
25
|
+
|
|
26
|
+
// Stage 1 — executor does the work in an isolated worktree.
|
|
27
|
+
const wt = wtm.createWorktree(task.id, executorName);
|
|
28
|
+
let exec;
|
|
29
|
+
try {
|
|
30
|
+
const adapter = executorName === "claude" ? claude : codex;
|
|
31
|
+
exec = await adapter.run(task, { ...opts, worktree: wt });
|
|
32
|
+
} finally {
|
|
33
|
+
wtm.removeWorktree(wt);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Stage 2 — reviewer inspects the executor's report (no worktree, no code change).
|
|
37
|
+
const reviewerAdapter = reviewerName === "claude" ? claude : codex;
|
|
38
|
+
const reviewTask = {
|
|
39
|
+
id: `${task.id}-review`,
|
|
40
|
+
subject: `Review: ${task.subject}`,
|
|
41
|
+
agent: task.agent,
|
|
42
|
+
status: "in_review",
|
|
43
|
+
description: [
|
|
44
|
+
`## Original task #${task.id}`,
|
|
45
|
+
task.description || "",
|
|
46
|
+
"",
|
|
47
|
+
`## Executor (${executorName}) report`,
|
|
48
|
+
"",
|
|
49
|
+
`verdict: ${exec.verdict} (confidence: ${exec.confidence})`,
|
|
50
|
+
"",
|
|
51
|
+
exec.summary,
|
|
52
|
+
"",
|
|
53
|
+
"## Review request",
|
|
54
|
+
"Inspect the executor's verdict, summary, and findings. Surface any risk the executor missed.",
|
|
55
|
+
"Output the same frontmatter format. If you concur, set verdict = same. If you find blocking issues, set verdict = needs_human and explain in Findings.",
|
|
56
|
+
].join("\n"),
|
|
57
|
+
};
|
|
58
|
+
const review = await reviewerAdapter.run(reviewTask, { ...opts });
|
|
59
|
+
|
|
60
|
+
let finalVerdict = exec.verdict;
|
|
61
|
+
let agreement = "agreed";
|
|
62
|
+
if (review.verdict === "needs_human" || review.verdict === "fail") { finalVerdict = "needs_human"; agreement = "disagreed"; }
|
|
63
|
+
else if (review.verdict !== exec.verdict) agreement = "partial";
|
|
64
|
+
|
|
65
|
+
const dir = path.join(RUNS_DIR, `task-${task.id}`);
|
|
66
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
fs.writeFileSync(path.join(dir, "reviewer-summary.md"), [
|
|
68
|
+
`# Reviewer flow — ${mode}`,
|
|
69
|
+
"",
|
|
70
|
+
`**Executor**: ${executorName} → verdict=${exec.verdict} (conf ${exec.confidence})`,
|
|
71
|
+
`**Reviewer**: ${reviewerName} → verdict=${review.verdict} (conf ${review.confidence})`,
|
|
72
|
+
`**Agreement**: ${agreement}`,
|
|
73
|
+
`**Final verdict**: ${finalVerdict}`,
|
|
74
|
+
"",
|
|
75
|
+
"## Executor summary",
|
|
76
|
+
exec.summary || "—",
|
|
77
|
+
"",
|
|
78
|
+
"## Reviewer summary",
|
|
79
|
+
review.summary || "—",
|
|
80
|
+
].join("\n"));
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
runner: mode, duration_ms: Date.now() - start,
|
|
84
|
+
executor: exec, reviewer: review,
|
|
85
|
+
agreement, verdict: finalVerdict, confidence: Math.min(exec.confidence || 0, review.confidence || 0),
|
|
86
|
+
needsHuman: finalVerdict === "needs_human",
|
|
87
|
+
reportPath: exec.reportPath,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { run };
|