@armstrongnate/april 0.1.4 → 0.2.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/README.md +40 -4
- package/dist/cli.js +56 -5
- package/dist/commands/cancel.js +72 -0
- package/dist/commands/clean.js +118 -0
- package/dist/commands/config.js +47 -0
- package/dist/commands/doctor.js +119 -0
- package/dist/commands/init.js +13 -11
- package/dist/commands/install-skill.js +30 -21
- package/dist/commands/investigate.js +93 -0
- package/dist/commands/kill.js +91 -0
- package/dist/commands/ps.js +70 -0
- package/dist/commands/run-issue.js +46 -0
- package/dist/config.js +17 -7
- package/dist/daemon.js +27 -0
- package/dist/index.js +13 -2
- package/dist/processes.js +8 -0
- package/dist/server.js +13 -1
- package/dist/service/systemd.js +1 -1
- package/dist/skill.js +14 -9
- package/dist/slug.js +10 -10
- package/dist/work.js +144 -0
- package/package.json +2 -1
- package/skills/issue-investigator/SKILL.md +96 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { loadConfig } from "../config.js";
|
|
2
|
+
import { getAgent } from "../agents.js";
|
|
3
|
+
import { getSessionBackend } from "../session/index.js";
|
|
4
|
+
import { slugify } from "../slug.js";
|
|
5
|
+
import { resolveRepo, INVESTIGATE_PREFIX } from "../work.js";
|
|
6
|
+
import { INVESTIGATE_SKILL } from "../skill.js";
|
|
7
|
+
const USAGE = `april investigate "<problem>" [--repo OWNER/NAME] [--auto]
|
|
8
|
+
|
|
9
|
+
Dispatch a research agent in the CURRENT directory to investigate a problem
|
|
10
|
+
and file a GitHub issue. The session may span repos; it is not tied to a
|
|
11
|
+
worktree.
|
|
12
|
+
|
|
13
|
+
"<problem>" free-text description of what to investigate
|
|
14
|
+
--repo suggest the owning repo (the agent still decides where it belongs)
|
|
15
|
+
--auto create the issue already labeled so april picks it up immediately
|
|
16
|
+
--review explicit deferred mode (the default): file for human review, no label
|
|
17
|
+
--dry-run print the session name and the prompt that would be dispatched, then exit`;
|
|
18
|
+
export async function run(args) {
|
|
19
|
+
let repoFlag;
|
|
20
|
+
let auto = false;
|
|
21
|
+
let dryRun = false;
|
|
22
|
+
const words = [];
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
const a = args[i];
|
|
25
|
+
if (a === "--repo") {
|
|
26
|
+
repoFlag = args[++i];
|
|
27
|
+
if (!repoFlag)
|
|
28
|
+
throw new Error("--repo requires a value");
|
|
29
|
+
}
|
|
30
|
+
else if (a === "--auto") {
|
|
31
|
+
auto = true;
|
|
32
|
+
}
|
|
33
|
+
else if (a === "--review") {
|
|
34
|
+
auto = false;
|
|
35
|
+
}
|
|
36
|
+
else if (a === "--dry-run") {
|
|
37
|
+
dryRun = true;
|
|
38
|
+
}
|
|
39
|
+
else if (a.startsWith("--")) {
|
|
40
|
+
console.error(`Unknown option: ${a}\n\n${USAGE}`);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
words.push(a);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const problem = words.join(" ").trim();
|
|
48
|
+
if (!problem) {
|
|
49
|
+
console.error(`Missing problem description.\n\n${USAGE}`);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
const config = loadConfig();
|
|
53
|
+
// Validate the hint early so a typo'd --repo fails before spawning a session.
|
|
54
|
+
const suggested = repoFlag ? resolveRepo(config, repoFlag) : undefined;
|
|
55
|
+
const mode = auto ? "auto" : "deferred";
|
|
56
|
+
const cwd = process.cwd();
|
|
57
|
+
const shortId = Date.now().toString(36).slice(-4);
|
|
58
|
+
const slug = `${INVESTIGATE_PREFIX}${shortId}-${slugify(problem, 30, "investigation")}`;
|
|
59
|
+
const repoList = config.repos
|
|
60
|
+
.map((r) => ` - ${r.owner}/${r.name} — ${r.path}`)
|
|
61
|
+
.join("\n");
|
|
62
|
+
const modeLine = mode === "auto"
|
|
63
|
+
? `create the issue assigned to ${config.assignee} AND apply the "${config.label}" label so april picks it up immediately.`
|
|
64
|
+
: `create the issue assigned to ${config.assignee} WITHOUT the "${config.label}" label, then print the URL for review.`;
|
|
65
|
+
const body = [
|
|
66
|
+
problem,
|
|
67
|
+
"",
|
|
68
|
+
"Context for filing the issue:",
|
|
69
|
+
`- Assignee: ${config.assignee}`,
|
|
70
|
+
`- Trigger label: ${config.label}`,
|
|
71
|
+
`- Mode: ${mode}`,
|
|
72
|
+
...(suggested ? [`- Suggested owning repo: ${suggested.owner}/${suggested.name}`] : []),
|
|
73
|
+
"- Candidate repos:",
|
|
74
|
+
repoList,
|
|
75
|
+
"",
|
|
76
|
+
`Investigate the problem above, then ${modeLine}`,
|
|
77
|
+
].join("\n");
|
|
78
|
+
const agent = getAgent(config.llm);
|
|
79
|
+
const prompt = agent.buildPrompt(INVESTIGATE_SKILL, body);
|
|
80
|
+
const command = agent.buildCommand(config);
|
|
81
|
+
if (dryRun) {
|
|
82
|
+
console.log(`session: ${slug}`);
|
|
83
|
+
console.log(`cwd: ${cwd}`);
|
|
84
|
+
console.log(`mode: ${mode}`);
|
|
85
|
+
console.log(`command: ${command}`);
|
|
86
|
+
console.log(`\nprompt:\n${prompt}`);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
console.log(`Starting investigation "${slug}" in ${cwd} (${mode} mode)…`);
|
|
90
|
+
await getSessionBackend(config).spawn({ name: slug, cwd, command, prompt });
|
|
91
|
+
console.log(`Done. Track it with \`april ps\`; attach to the session "${slug}".`);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { findConfigPath, parseConfigFile } from "../config.js";
|
|
3
|
+
import { getSessionBackend } from "../session/index.js";
|
|
4
|
+
import { listActiveWork } from "../work.js";
|
|
5
|
+
const USAGE = `april kill <slug|issue> [--repo OWNER/NAME] [--worktree]
|
|
6
|
+
|
|
7
|
+
Kill a single session by slug or issue number. Works for investigations
|
|
8
|
+
(inv-…) too. With --worktree, also remove the backing worktree.
|
|
9
|
+
|
|
10
|
+
<slug|issue> a session slug (gh-123-…, inv-…) or an issue number (123)
|
|
11
|
+
--repo disambiguate when an issue number matches more than one repo
|
|
12
|
+
--worktree also remove the worktree (only applies to issue work)`;
|
|
13
|
+
export async function run(args) {
|
|
14
|
+
let target;
|
|
15
|
+
let repoFlag;
|
|
16
|
+
let withWorktree = false;
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
const a = args[i];
|
|
19
|
+
if (a === "--repo") {
|
|
20
|
+
repoFlag = args[++i];
|
|
21
|
+
if (!repoFlag)
|
|
22
|
+
throw new Error("--repo requires a value");
|
|
23
|
+
}
|
|
24
|
+
else if (a === "--worktree") {
|
|
25
|
+
withWorktree = true;
|
|
26
|
+
}
|
|
27
|
+
else if (a.startsWith("--")) {
|
|
28
|
+
console.error(`Unknown option: ${a}\n\n${USAGE}`);
|
|
29
|
+
return 2;
|
|
30
|
+
}
|
|
31
|
+
else if (!target) {
|
|
32
|
+
target = a;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.error(`Unexpected argument: ${a}\n\n${USAGE}`);
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!target) {
|
|
40
|
+
console.error(`Missing slug or issue number.\n\n${USAGE}`);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
const config = parseConfigFile(findConfigPath());
|
|
44
|
+
const items = await listActiveWork(config);
|
|
45
|
+
let slug;
|
|
46
|
+
let item;
|
|
47
|
+
if (/^#?\d+$/.test(target)) {
|
|
48
|
+
const n = parseInt(target.replace(/^#/, ""), 10);
|
|
49
|
+
let matches = items.filter((it) => it.issueNumber === n);
|
|
50
|
+
if (repoFlag)
|
|
51
|
+
matches = matches.filter((it) => it.repo === repoFlag || it.repo?.endsWith(`/${repoFlag}`));
|
|
52
|
+
if (matches.length === 0) {
|
|
53
|
+
console.log(`No active session or worktree for issue #${n}.`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (matches.length > 1) {
|
|
57
|
+
console.error(`#${n} matches multiple repos — pass --repo OWNER/NAME:`);
|
|
58
|
+
for (const m of matches)
|
|
59
|
+
console.error(` ${m.repo} (${m.slug})`);
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
item = matches[0];
|
|
63
|
+
slug = item.slug;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
slug = target;
|
|
67
|
+
item = items.find((it) => it.slug === target);
|
|
68
|
+
}
|
|
69
|
+
console.log(`Killing session "${slug}"…`);
|
|
70
|
+
await getSessionBackend(config).kill(slug);
|
|
71
|
+
if (withWorktree) {
|
|
72
|
+
if (item?.repoPath && item.worktreePath) {
|
|
73
|
+
try {
|
|
74
|
+
execFileSync("wt", ["remove", slug, "-f", "-D"], {
|
|
75
|
+
cwd: item.repoPath,
|
|
76
|
+
timeout: 60_000,
|
|
77
|
+
stdio: "pipe",
|
|
78
|
+
});
|
|
79
|
+
console.log(`Removed worktree ${item.worktreePath}`);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
console.warn(`wt remove ${slug} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
console.log("No worktree associated with this session — nothing to remove.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
console.log("Done.");
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { findConfigPath, parseConfigFile } from "../config.js";
|
|
2
|
+
import { listActiveWork } from "../work.js";
|
|
3
|
+
import { daemonStatus } from "../daemon.js";
|
|
4
|
+
const USAGE = `april ps [--json]
|
|
5
|
+
|
|
6
|
+
List active work — issues in flight and investigations — derived from
|
|
7
|
+
worktrees and live sessions. Works whether or not the daemon is running.`;
|
|
8
|
+
function fmtUptime(seconds) {
|
|
9
|
+
const s = Math.floor(seconds);
|
|
10
|
+
const h = Math.floor(s / 3600);
|
|
11
|
+
const m = Math.floor((s % 3600) / 60);
|
|
12
|
+
if (h > 0)
|
|
13
|
+
return `${h}h${m}m`;
|
|
14
|
+
if (m > 0)
|
|
15
|
+
return `${m}m${s % 60}s`;
|
|
16
|
+
return `${s}s`;
|
|
17
|
+
}
|
|
18
|
+
function renderTable(items) {
|
|
19
|
+
const header = ["KIND", "ISSUE", "SESSION", "REPO", "SLUG"];
|
|
20
|
+
const rows = items.map((it) => [
|
|
21
|
+
it.kind === "investigation" ? "investigate" : "issue",
|
|
22
|
+
it.issueNumber ? `#${it.issueNumber}` : "-",
|
|
23
|
+
it.sessionAlive ? "live" : "stale",
|
|
24
|
+
it.repo ?? "-",
|
|
25
|
+
it.slug,
|
|
26
|
+
]);
|
|
27
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
28
|
+
const fmt = (cols) => cols.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
|
|
29
|
+
console.log(fmt(header));
|
|
30
|
+
for (const r of rows)
|
|
31
|
+
console.log(fmt(r));
|
|
32
|
+
}
|
|
33
|
+
export async function run(args) {
|
|
34
|
+
const wantJson = args.includes("--json");
|
|
35
|
+
for (const a of args) {
|
|
36
|
+
if (a !== "--json") {
|
|
37
|
+
console.error(`Unknown option: ${a}\n\n${USAGE}`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let config;
|
|
42
|
+
try {
|
|
43
|
+
config = parseConfigFile(findConfigPath());
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
const items = await listActiveWork(config);
|
|
50
|
+
const status = await daemonStatus(config.port);
|
|
51
|
+
if (wantJson) {
|
|
52
|
+
console.log(JSON.stringify({ daemon: status, work: items }, null, 2));
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
if (status) {
|
|
56
|
+
const fw = status.forwarders;
|
|
57
|
+
const liveFw = fw.filter((f) => f.alive).length;
|
|
58
|
+
console.log(`daemon: up (${fmtUptime(status.uptime)}), ${liveFw}/${fw.length} forwarder${fw.length === 1 ? "" : "s"} live`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.log(`daemon: not reachable on :${config.port}`);
|
|
62
|
+
}
|
|
63
|
+
console.log("");
|
|
64
|
+
if (items.length === 0) {
|
|
65
|
+
console.log("No active work.");
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
renderTable(items);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { loadConfig } from "../config.js";
|
|
2
|
+
import { handleNewIssue } from "../spawner.js";
|
|
3
|
+
import { parseIssueRef, resolveRepo, fetchIssue } from "../work.js";
|
|
4
|
+
const USAGE = `april run <issue> [--repo OWNER/NAME]
|
|
5
|
+
|
|
6
|
+
Manually start work on an issue — the same path the daemon takes on a
|
|
7
|
+
labeled+assigned webhook, minus the label requirement. Creates the worktree,
|
|
8
|
+
spawns the agent session, and flips labels to agent:wip.
|
|
9
|
+
|
|
10
|
+
<issue> 123, #123, or owner/name#123
|
|
11
|
+
--repo Repo to act on when <issue> is a bare number and multiple are configured.`;
|
|
12
|
+
export async function run(args) {
|
|
13
|
+
let ref;
|
|
14
|
+
let repoFlag;
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const a = args[i];
|
|
17
|
+
if (a === "--repo") {
|
|
18
|
+
repoFlag = args[++i];
|
|
19
|
+
if (!repoFlag)
|
|
20
|
+
throw new Error("--repo requires a value");
|
|
21
|
+
}
|
|
22
|
+
else if (a.startsWith("--")) {
|
|
23
|
+
console.error(`Unknown option: ${a}\n\n${USAGE}`);
|
|
24
|
+
return 2;
|
|
25
|
+
}
|
|
26
|
+
else if (!ref) {
|
|
27
|
+
ref = a;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
console.error(`Unexpected argument: ${a}\n\n${USAGE}`);
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!ref) {
|
|
35
|
+
console.error(`Missing issue reference.\n\n${USAGE}`);
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
const config = loadConfig();
|
|
39
|
+
const { repoRef, number } = parseIssueRef(ref);
|
|
40
|
+
const repo = resolveRepo(config, repoFlag ?? repoRef);
|
|
41
|
+
const issue = fetchIssue(repo, number);
|
|
42
|
+
console.log(`Starting work on ${repo.owner}/${repo.name}#${issue.number} — "${issue.title}"`);
|
|
43
|
+
await handleNewIssue(repo, issue, config);
|
|
44
|
+
console.log(`Done. Track it with \`april ps\`.`);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createLogger } from "./logger.js";
|
|
|
7
7
|
import { isGhWebhookExtensionInstalled, GH_EXTENSION_INSTALL_CMD } from "./precheck.js";
|
|
8
8
|
import { AGENT_KINDS, getAgent } from "./agents.js";
|
|
9
9
|
const log = createLogger("config");
|
|
10
|
-
function findConfigPath() {
|
|
10
|
+
export function findConfigPath() {
|
|
11
11
|
const envPath = process.env.APRIL_CONFIG;
|
|
12
12
|
if (envPath) {
|
|
13
13
|
if (!existsSync(envPath)) {
|
|
@@ -29,13 +29,23 @@ function findConfigPath() {
|
|
|
29
29
|
` - ${localPath}\n` +
|
|
30
30
|
"Create a config.yaml (see config.example.yaml).");
|
|
31
31
|
}
|
|
32
|
+
/** Whether a binary is resolvable on PATH. Non-throwing; used by `doctor`. */
|
|
33
|
+
export function checkTool(tool) {
|
|
34
|
+
try {
|
|
35
|
+
execSync(`which ${tool}`, { stdio: "pipe" });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The external binaries april needs for this agent + session backend. */
|
|
43
|
+
export function requiredTools(agentCli, sessionManager) {
|
|
44
|
+
return ["gh", "git", agentCli, sessionManager === "herdr" ? "herdr" : "tmux"];
|
|
45
|
+
}
|
|
32
46
|
function validateTools(agentCli, sessionManager) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
execSync(`which ${tool}`, { stdio: "pipe" });
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
47
|
+
for (const tool of requiredTools(agentCli, sessionManager)) {
|
|
48
|
+
if (!checkTool(tool)) {
|
|
39
49
|
throw new Error(`Required tool "${tool}" not found on PATH. Install it before running april.`);
|
|
40
50
|
}
|
|
41
51
|
}
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny client for the running daemon's HTTP endpoints. Used by `ps`/`doctor`
|
|
3
|
+
* to enrich their output when the daemon is up; every caller treats `null`
|
|
4
|
+
* (daemon down/unreachable) as a normal, non-error condition.
|
|
5
|
+
*/
|
|
6
|
+
async function getJson(url, timeoutMs = 1500) {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
11
|
+
if (!res.ok)
|
|
12
|
+
return null;
|
|
13
|
+
return (await res.json());
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function daemonStatus(port) {
|
|
23
|
+
return getJson(`http://127.0.0.1:${port}/status`);
|
|
24
|
+
}
|
|
25
|
+
export async function daemonReachable(port) {
|
|
26
|
+
return (await getJson(`http://127.0.0.1:${port}/health`)) !== null;
|
|
27
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { join, dirname } from "node:path";
|
|
2
|
+
import { join, dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { createLogger } from "./logger.js";
|
|
5
6
|
import { loadConfig } from "./config.js";
|
|
@@ -8,6 +9,16 @@ import { startServer } from "./server.js";
|
|
|
8
9
|
import { startWebhookForwarders, shutdownForwarders } from "./processes.js";
|
|
9
10
|
const log = createLogger("main");
|
|
10
11
|
const PID_PATH = join(homedir(), ".config", "april", "april.pid");
|
|
12
|
+
function readVersion() {
|
|
13
|
+
try {
|
|
14
|
+
// dist/index.js -> package root
|
|
15
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
return JSON.parse(readFileSync(join(root, "package.json"), "utf-8")).version ?? "unknown";
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "unknown";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
11
22
|
function checkPidFile() {
|
|
12
23
|
if (!existsSync(PID_PATH))
|
|
13
24
|
return;
|
|
@@ -42,7 +53,7 @@ function removePidFile() {
|
|
|
42
53
|
}
|
|
43
54
|
async function main() {
|
|
44
55
|
console.log("");
|
|
45
|
-
console.log(
|
|
56
|
+
console.log(` april v${readVersion()}`);
|
|
46
57
|
console.log("");
|
|
47
58
|
// 1. Load config
|
|
48
59
|
const config = loadConfig();
|
package/dist/processes.js
CHANGED
|
@@ -95,6 +95,14 @@ function spawnForwarder(config, repoKey, url) {
|
|
|
95
95
|
start();
|
|
96
96
|
return state;
|
|
97
97
|
}
|
|
98
|
+
/** Snapshot of the webhook forwarders, for the daemon's /status endpoint. */
|
|
99
|
+
export function getForwarderStatus() {
|
|
100
|
+
return forwarders.map((f) => ({
|
|
101
|
+
repoKey: f.repoKey,
|
|
102
|
+
alive: !!f.child && !f.child.killed && f.child.exitCode === null && !f.stopped,
|
|
103
|
+
consecutiveFailures: f.consecutiveFailures,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
98
106
|
export function startWebhookForwarders(config) {
|
|
99
107
|
const url = `http://localhost:${config.port}/webhook/github`;
|
|
100
108
|
for (const repo of config.repos) {
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import Fastify from "fastify";
|
|
2
2
|
import { createLogger } from "./logger.js";
|
|
3
3
|
import { parseWebhookEvent } from "./webhook.js";
|
|
4
|
-
import { isIssueActive, handlePrClosed } from "./spawner.js";
|
|
4
|
+
import { isIssueActive, handlePrClosed, getActiveCounts } from "./spawner.js";
|
|
5
|
+
import { getForwarderStatus } from "./processes.js";
|
|
5
6
|
const log = createLogger("server");
|
|
6
7
|
export async function startServer(config, onNewIssue) {
|
|
7
8
|
const app = Fastify({ logger: false });
|
|
@@ -68,6 +69,17 @@ export async function startServer(config, onNewIssue) {
|
|
|
68
69
|
return reply.code(200).send({ ok: true });
|
|
69
70
|
});
|
|
70
71
|
app.get("/health", async () => ({ status: "ok" }));
|
|
72
|
+
// Live daemon state for `april ps` / `april doctor`. Everything here is also
|
|
73
|
+
// derivable from disk, so the CLI degrades gracefully when the daemon is down.
|
|
74
|
+
app.get("/status", async () => ({
|
|
75
|
+
uptime: process.uptime(),
|
|
76
|
+
assignee: config.assignee,
|
|
77
|
+
label: config.label,
|
|
78
|
+
sessionManager: config.sessionManager ?? "tmux",
|
|
79
|
+
repos: config.repos.map((r) => `${r.owner}/${r.name}`),
|
|
80
|
+
active: await getActiveCounts(config),
|
|
81
|
+
forwarders: getForwarderStatus(),
|
|
82
|
+
}));
|
|
71
83
|
await app.listen({ port: config.port, host: "127.0.0.1" });
|
|
72
84
|
log.info(`Server listening on http://127.0.0.1:${config.port}`);
|
|
73
85
|
return app;
|
package/dist/service/systemd.js
CHANGED
|
@@ -50,7 +50,7 @@ function ensureSystemctlPresent() {
|
|
|
50
50
|
throw new Error("systemctl not found on PATH. april service install requires systemd.");
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
function lingerEnabled() {
|
|
53
|
+
export function lingerEnabled() {
|
|
54
54
|
try {
|
|
55
55
|
const out = execFileSync("loginctl", ["show-user", process.env.USER ?? ""], {
|
|
56
56
|
encoding: "utf-8",
|
package/dist/skill.js
CHANGED
|
@@ -3,20 +3,25 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { configuredLlmKind } from "./config.js";
|
|
5
5
|
import { getAgent } from "./agents.js";
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
|
|
6
|
+
/** Skill that works a labeled issue end-to-end (the daemon's worker). */
|
|
7
|
+
export const ISSUE_WORKER_SKILL = "issue-worker";
|
|
8
|
+
/** Skill that researches a free-text problem and files a GitHub issue. */
|
|
9
|
+
export const INVESTIGATE_SKILL = "issue-investigator";
|
|
10
|
+
/** Every skill april bundles and keeps installed for the configured agent. */
|
|
11
|
+
export const ALL_BUNDLED_SKILLS = [ISSUE_WORKER_SKILL, INVESTIGATE_SKILL];
|
|
12
|
+
export function bundledSkillPath(skill) {
|
|
13
|
+
// dist/skill.js -> dist/.. -> package root, then skills/<skill>/SKILL.md
|
|
9
14
|
const here = fileURLToPath(import.meta.url);
|
|
10
|
-
return resolve(dirname(here), "..", "skills",
|
|
15
|
+
return resolve(dirname(here), "..", "skills", skill, "SKILL.md");
|
|
11
16
|
}
|
|
12
|
-
export function skillDestPath(kind = configuredLlmKind()) {
|
|
13
|
-
return getAgent(kind).skillFile(
|
|
17
|
+
export function skillDestPath(skill, kind = configuredLlmKind()) {
|
|
18
|
+
return getAgent(kind).skillFile(skill);
|
|
14
19
|
}
|
|
15
|
-
export function compareSkill(kind = configuredLlmKind()) {
|
|
16
|
-
const dst = skillDestPath(kind);
|
|
20
|
+
export function compareSkill(skill, kind = configuredLlmKind()) {
|
|
21
|
+
const dst = skillDestPath(skill, kind);
|
|
17
22
|
if (!existsSync(dst))
|
|
18
23
|
return "missing";
|
|
19
|
-
const src = bundledSkillPath();
|
|
24
|
+
const src = bundledSkillPath(skill);
|
|
20
25
|
if (!existsSync(src))
|
|
21
26
|
return "matches-bundled"; // can't compare; don't alarm
|
|
22
27
|
return readFileSync(dst).equals(readFileSync(src))
|
package/dist/slug.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/** Lowercase, hyphenate, and truncate text at a word boundary for use in names. */
|
|
2
|
+
export function slugify(text, maxLen = 40, fallback = "issue") {
|
|
3
|
+
let slug = text
|
|
3
4
|
.toLowerCase()
|
|
4
5
|
.replace(/[^a-z0-9]+/g, "-")
|
|
5
6
|
.replace(/-{2,}/g, "-")
|
|
6
7
|
.replace(/^-+|-+$/g, "");
|
|
7
|
-
// Truncate
|
|
8
|
-
if (slug.length >
|
|
9
|
-
slug = slug.substring(0,
|
|
8
|
+
// Truncate at a word boundary
|
|
9
|
+
if (slug.length > maxLen) {
|
|
10
|
+
slug = slug.substring(0, maxLen);
|
|
10
11
|
const lastHyphen = slug.lastIndexOf("-");
|
|
11
12
|
if (lastHyphen > 0) {
|
|
12
13
|
slug = slug.substring(0, lastHyphen);
|
|
13
14
|
}
|
|
14
15
|
slug = slug.replace(/-+$/, "");
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
return `gh-${issueNumber}-${slug}`;
|
|
17
|
+
return slug.length === 0 ? fallback : slug;
|
|
18
|
+
}
|
|
19
|
+
export function makeSlug(issueNumber, title) {
|
|
20
|
+
return `gh-${issueNumber}-${slugify(title)}`;
|
|
21
21
|
}
|
package/dist/work.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createLogger } from "./logger.js";
|
|
5
|
+
import { getSessionBackend } from "./session/index.js";
|
|
6
|
+
const log = createLogger("work");
|
|
7
|
+
/** Session/worktree name prefix for daemon-driven issue work. */
|
|
8
|
+
export const ISSUE_PREFIX = "gh-";
|
|
9
|
+
/** Session name prefix for `april investigate` research sessions. */
|
|
10
|
+
export const INVESTIGATE_PREFIX = "inv-";
|
|
11
|
+
/**
|
|
12
|
+
* Parse an issue reference accepted by `run` / `cancel` / `kill`.
|
|
13
|
+
* Forms: `123`, `#123`, `owner/name#123`.
|
|
14
|
+
*/
|
|
15
|
+
export function parseIssueRef(ref) {
|
|
16
|
+
const hash = ref.indexOf("#");
|
|
17
|
+
if (hash !== -1) {
|
|
18
|
+
const repoRef = ref.slice(0, hash).trim() || undefined;
|
|
19
|
+
const number = parseInt(ref.slice(hash + 1).trim(), 10);
|
|
20
|
+
if (Number.isNaN(number))
|
|
21
|
+
throw new Error(`Invalid issue reference: ${ref}`);
|
|
22
|
+
return { repoRef, number };
|
|
23
|
+
}
|
|
24
|
+
const number = parseInt(ref.replace(/^#/, "").trim(), 10);
|
|
25
|
+
if (Number.isNaN(number))
|
|
26
|
+
throw new Error(`Invalid issue reference: ${ref}`);
|
|
27
|
+
return { number };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Pick the RepoConfig a command should act on. `ref` is an explicit
|
|
31
|
+
* "owner/name" (from `--repo` or an `owner/name#n` issue ref). When absent,
|
|
32
|
+
* defaults to the sole configured repo, else errors with the candidate list.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveRepo(config, ref) {
|
|
35
|
+
if (ref) {
|
|
36
|
+
const norm = ref.trim();
|
|
37
|
+
const match = config.repos.find((r) => `${r.owner}/${r.name}` === norm || r.name === norm);
|
|
38
|
+
if (!match) {
|
|
39
|
+
const list = config.repos.map((r) => `${r.owner}/${r.name}`).join(", ");
|
|
40
|
+
throw new Error(`Repo "${ref}" is not configured. Known repos: ${list}`);
|
|
41
|
+
}
|
|
42
|
+
return match;
|
|
43
|
+
}
|
|
44
|
+
if (config.repos.length === 1)
|
|
45
|
+
return config.repos[0];
|
|
46
|
+
const list = config.repos.map((r) => `${r.owner}/${r.name}`).join(", ");
|
|
47
|
+
throw new Error(`Multiple repos configured — specify which with --repo or owner/name#issue. Known repos: ${list}`);
|
|
48
|
+
}
|
|
49
|
+
/** Fetch a single issue's number+title via gh. Throws if it can't be read. */
|
|
50
|
+
export function fetchIssue(repo, number) {
|
|
51
|
+
let output;
|
|
52
|
+
try {
|
|
53
|
+
output = execFileSync("gh", [
|
|
54
|
+
"issue", "view", String(number),
|
|
55
|
+
"--repo", `${repo.owner}/${repo.name}`,
|
|
56
|
+
"--json", "number,title",
|
|
57
|
+
], { encoding: "utf-8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
throw new Error(`Could not fetch issue #${number} from ${repo.owner}/${repo.name}: ` +
|
|
61
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
62
|
+
}
|
|
63
|
+
const parsed = JSON.parse(output);
|
|
64
|
+
return { number: parsed.number, title: parsed.title };
|
|
65
|
+
}
|
|
66
|
+
/** Parse the issue number out of a `gh-<n>-...` slug, if present. */
|
|
67
|
+
export function issueNumberFromSlug(slug) {
|
|
68
|
+
const m = /^gh-(\d+)-/.exec(slug);
|
|
69
|
+
return m ? parseInt(m[1], 10) : undefined;
|
|
70
|
+
}
|
|
71
|
+
/** Worktree directory names under a repo's `.worktrees` that look like april work. */
|
|
72
|
+
function listWorktreeSlugs(repo) {
|
|
73
|
+
const dir = join(repo.path, ".worktrees");
|
|
74
|
+
if (!existsSync(dir))
|
|
75
|
+
return [];
|
|
76
|
+
try {
|
|
77
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
78
|
+
.filter((d) => d.isDirectory() && d.name.startsWith(ISSUE_PREFIX))
|
|
79
|
+
.map((d) => d.name);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Find the active slug for an issue by scanning this repo's worktrees and the
|
|
87
|
+
* session backend for a `gh-<n>-` match. Returns undefined if nothing is active.
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveActiveSlug(repo, issueNumber, config) {
|
|
90
|
+
const prefix = `${ISSUE_PREFIX}${issueNumber}-`;
|
|
91
|
+
const wt = listWorktreeSlugs(repo).find((s) => s.startsWith(prefix));
|
|
92
|
+
if (wt)
|
|
93
|
+
return wt;
|
|
94
|
+
const sessions = await getSessionBackend(config).listSessions();
|
|
95
|
+
return sessions.find((s) => s.startsWith(prefix));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Enumerate everything april is working on, derived purely from disk
|
|
99
|
+
* (`.worktrees/gh-*`) and the session backend — so it works whether or not the
|
|
100
|
+
* daemon is running. Correlates worktrees and sessions by slug.
|
|
101
|
+
*/
|
|
102
|
+
export async function listActiveWork(config) {
|
|
103
|
+
const bySlug = new Map();
|
|
104
|
+
// Worktrees pin a slug to a specific repo + path.
|
|
105
|
+
for (const repo of config.repos) {
|
|
106
|
+
for (const slug of listWorktreeSlugs(repo)) {
|
|
107
|
+
bySlug.set(slug, {
|
|
108
|
+
kind: "issue",
|
|
109
|
+
slug,
|
|
110
|
+
issueNumber: issueNumberFromSlug(slug),
|
|
111
|
+
repo: `${repo.owner}/${repo.name}`,
|
|
112
|
+
repoPath: repo.path,
|
|
113
|
+
worktreePath: join(repo.path, ".worktrees", slug),
|
|
114
|
+
sessionAlive: false,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Sessions mark slugs alive, and surface session-only work (notably `inv-`).
|
|
119
|
+
let sessions = [];
|
|
120
|
+
try {
|
|
121
|
+
sessions = await getSessionBackend(config).listSessions();
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
log.warn(`Could not list sessions: ${err instanceof Error ? err.message : String(err)}`);
|
|
125
|
+
}
|
|
126
|
+
for (const name of sessions) {
|
|
127
|
+
const existing = bySlug.get(name);
|
|
128
|
+
if (existing) {
|
|
129
|
+
existing.sessionAlive = true;
|
|
130
|
+
}
|
|
131
|
+
else if (name.startsWith(INVESTIGATE_PREFIX)) {
|
|
132
|
+
bySlug.set(name, { kind: "investigation", slug: name, sessionAlive: true });
|
|
133
|
+
}
|
|
134
|
+
else if (name.startsWith(ISSUE_PREFIX)) {
|
|
135
|
+
bySlug.set(name, {
|
|
136
|
+
kind: "issue",
|
|
137
|
+
slug: name,
|
|
138
|
+
issueNumber: issueNumberFromSlug(name),
|
|
139
|
+
sessionAlive: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return [...bySlug.values()].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
144
|
+
}
|