@gevezex/gdt 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/LICENSE +21 -0
- package/README.md +375 -0
- package/dist/agents/adapter.js +7 -0
- package/dist/agents/claude.js +13 -0
- package/dist/agents/codex.js +13 -0
- package/dist/agents/index.js +15 -0
- package/dist/agents/mcode.js +14 -0
- package/dist/agents/omp.js +14 -0
- package/dist/agents/opencode.js +15 -0
- package/dist/agents/pi.js +14 -0
- package/dist/backends/backend.js +1 -0
- package/dist/backends/headless.js +55 -0
- package/dist/backends/herdr.js +286 -0
- package/dist/backends/index.js +20 -0
- package/dist/cli.js +466 -0
- package/dist/config.js +210 -0
- package/dist/contract.js +133 -0
- package/dist/decision.js +175 -0
- package/dist/doctor.js +231 -0
- package/dist/finding.js +3 -0
- package/dist/git.js +25 -0
- package/dist/github.js +95 -0
- package/dist/locale.js +44 -0
- package/dist/notify.js +29 -0
- package/dist/prompts.js +80 -0
- package/dist/protocol.js +114 -0
- package/dist/state.js +113 -0
- package/dist/steering.js +165 -0
- package/dist/supervisor.js +369 -0
- package/dist/worker.js +232 -0
- package/dist/workflow.js +333 -0
- package/locales/en.toml +28 -0
- package/locales/nl.toml +28 -0
- package/package.json +61 -0
- package/roles/developer.md +66 -0
- package/roles/issue-writer.md +40 -0
- package/roles/reviewer.md +65 -0
- package/roles/tester.md +58 -0
- package/schemas/gdt-answer.v1.schema.json +32 -0
- package/schemas/gdt-directive.v1.schema.json +36 -0
- package/schemas/gdt-handoff.v1.schema.json +117 -0
- package/schemas/gdt-question.v1.schema.json +82 -0
- package/schemas/gdt-review.v1.schema.json +131 -0
- package/schemas/gdt-round.v1.schema.json +28 -0
- package/schemas/gdt-test.v1.schema.json +131 -0
- package/skill/SKILL.md +46 -0
package/dist/locale.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { parse as parseToml } from "smol-toml";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
export const SECTION_KEYS = [
|
|
5
|
+
"plain_language",
|
|
6
|
+
"goal",
|
|
7
|
+
"context",
|
|
8
|
+
"definitions",
|
|
9
|
+
"acceptance_criteria",
|
|
10
|
+
"non_functional",
|
|
11
|
+
"out_of_scope",
|
|
12
|
+
"assumptions",
|
|
13
|
+
"open_questions",
|
|
14
|
+
"changelog",
|
|
15
|
+
"readiness",
|
|
16
|
+
];
|
|
17
|
+
const text = z.string().trim().min(1);
|
|
18
|
+
export const localeSchema = z.strictObject({
|
|
19
|
+
/** English name of the language, used in role prompts. */
|
|
20
|
+
name: text,
|
|
21
|
+
sections: z.strictObject(Object.fromEntries(SECTION_KEYS.map((key) => [key, text]))),
|
|
22
|
+
ac_fields: z.strictObject({ given: text, when: text, then: text, example: text }),
|
|
23
|
+
markers: z.strictObject({ none: text }),
|
|
24
|
+
vague_phrases: z.array(text),
|
|
25
|
+
});
|
|
26
|
+
const LOCALES_DIR = new URL("../locales/", import.meta.url);
|
|
27
|
+
/** Loads `locales/<language>.toml` shipped with gdt. Throws with a recovery hint when it is missing or invalid. */
|
|
28
|
+
export function loadLocale(language) {
|
|
29
|
+
const languages = shippedLanguages();
|
|
30
|
+
if (!languages.includes(language)) {
|
|
31
|
+
throw new Error(`No locale for language "${language}"; set language in .gdt/config.toml to one of ${languages.join(", ")}`);
|
|
32
|
+
}
|
|
33
|
+
const parsed = localeSchema.safeParse(parseToml(readFileSync(new URL(`${language}.toml`, LOCALES_DIR), "utf8")));
|
|
34
|
+
if (!parsed.success) {
|
|
35
|
+
throw new Error(`locales/${language}.toml is invalid: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
|
|
36
|
+
}
|
|
37
|
+
return parsed.data;
|
|
38
|
+
}
|
|
39
|
+
export function shippedLanguages() {
|
|
40
|
+
return readdirSync(LOCALES_DIR)
|
|
41
|
+
.filter((name) => name.endsWith(".toml"))
|
|
42
|
+
.map((name) => name.slice(0, -".toml".length))
|
|
43
|
+
.sort();
|
|
44
|
+
}
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { which } from "./doctor.js";
|
|
3
|
+
function appleScriptString(text) {
|
|
4
|
+
return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
5
|
+
}
|
|
6
|
+
const NOTIFIERS = [
|
|
7
|
+
{ name: "terminal-notifier", args: (title, message) => ["-title", title, "-message", message] },
|
|
8
|
+
{
|
|
9
|
+
name: "osascript",
|
|
10
|
+
args: (title, message) => ["-e", `display notification ${appleScriptString(message)} with title ${appleScriptString(title)}`],
|
|
11
|
+
},
|
|
12
|
+
{ name: "notify-send", args: (title, message) => [title, message] },
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Sends one notification through the first notifier that is on PATH and succeeds; otherwise writes one
|
|
16
|
+
* line with `log`. Returns the notifier used, or "log".
|
|
17
|
+
*/
|
|
18
|
+
export function notify(title, message, env, log) {
|
|
19
|
+
for (const notifier of NOTIFIERS) {
|
|
20
|
+
const bin = which(notifier.name, env);
|
|
21
|
+
if (bin === null)
|
|
22
|
+
continue;
|
|
23
|
+
const result = spawnSync(bin, notifier.args(title, message), { env, stdio: "ignore", timeout: 10_000 });
|
|
24
|
+
if (result.status === 0)
|
|
25
|
+
return notifier.name;
|
|
26
|
+
}
|
|
27
|
+
log(`notification: ${title}: ${message}`);
|
|
28
|
+
return "log";
|
|
29
|
+
}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { loadLocale, SECTION_KEYS } from "./locale.js";
|
|
5
|
+
import { marker, schemas } from "./protocol.js";
|
|
6
|
+
export const ROLE_FILES = ["developer", "tester", "reviewer", "issue-writer"];
|
|
7
|
+
/** The record kind each workflow role writes. */
|
|
8
|
+
export const RECORD_OF = { developer: "handoff", tester: "test", reviewer: "review" };
|
|
9
|
+
const ROLES_DIR = new URL("../roles/", import.meta.url);
|
|
10
|
+
export function roleFile(name) {
|
|
11
|
+
return readFileSync(new URL(`${name}.md`, ROLES_DIR), "utf8");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Trusted directives for `role` posted after that role's previous dispatch, i.e. with a comment id
|
|
15
|
+
* above `afterCommentId` (the highest comment id seen at that dispatch; 0 before the first one).
|
|
16
|
+
*/
|
|
17
|
+
export function pendingDirectives(records, trustedAuthors, role, afterCommentId) {
|
|
18
|
+
return records
|
|
19
|
+
.filter((r) => r.kind === "directive")
|
|
20
|
+
.filter((r) => trustedAuthors.includes(r.author) && r.data.role === role && r.comment_id > afterCommentId)
|
|
21
|
+
.map((r) => ({ comment_id: r.comment_id, directive: r.data.directive }));
|
|
22
|
+
}
|
|
23
|
+
function schemaSection(kind) {
|
|
24
|
+
return [
|
|
25
|
+
`## Protocol: ${marker(kind)}`,
|
|
26
|
+
"",
|
|
27
|
+
`Close the record with ${marker(kind, true)}. The JSON object must match this JSON Schema:`,
|
|
28
|
+
"",
|
|
29
|
+
"```json",
|
|
30
|
+
JSON.stringify(z.toJSONSchema(schemas[kind]), null, 2),
|
|
31
|
+
"```",
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
/** The prompt for one turn: role file, dispatch facts, language, protocol, directives and project rules. */
|
|
35
|
+
export function buildPrompt(role, dispatch, project) {
|
|
36
|
+
const locale = loadLocale(dispatch.language);
|
|
37
|
+
const parts = [roleFile(role).trimEnd()];
|
|
38
|
+
parts.push([
|
|
39
|
+
"## Turn facts",
|
|
40
|
+
"",
|
|
41
|
+
`Repository: ${dispatch.repository}`,
|
|
42
|
+
`Issue: #${dispatch.issue}`,
|
|
43
|
+
`Round: ${dispatch.round}`,
|
|
44
|
+
`Pull request: ${dispatch.pr_number === null ? "none yet" : `#${dispatch.pr_number}`}`,
|
|
45
|
+
`Head: ${dispatch.head ?? "none yet"}`,
|
|
46
|
+
`issue_body_sha256: ${dispatch.issue_body_sha256}`,
|
|
47
|
+
`Acceptance criteria: ${dispatch.acceptance_criteria.join(", ")}`,
|
|
48
|
+
`Language: ${locale.name}`,
|
|
49
|
+
].join("\n"));
|
|
50
|
+
const f = locale.ac_fields;
|
|
51
|
+
parts.push([
|
|
52
|
+
"## Language",
|
|
53
|
+
"",
|
|
54
|
+
`Write all human-facing GitHub text in ${locale.name}.`,
|
|
55
|
+
"Record markers, JSON keys, status values and finding ids stay in English.",
|
|
56
|
+
"",
|
|
57
|
+
`Issue section headings in ${locale.name}:`,
|
|
58
|
+
...SECTION_KEYS.map((key) => `- ${locale.sections[key]}`),
|
|
59
|
+
"",
|
|
60
|
+
`Acceptance-criterion fields: ${f.given}, ${f.when}, ${f.then}, ${f.example}`,
|
|
61
|
+
`"None" marker: ${locale.markers.none}`,
|
|
62
|
+
].join("\n"));
|
|
63
|
+
parts.push(schemaSection(RECORD_OF[role]), schemaSection("question"));
|
|
64
|
+
if (dispatch.directives.length > 0) {
|
|
65
|
+
parts.push([
|
|
66
|
+
"## Human directives",
|
|
67
|
+
"",
|
|
68
|
+
"Guidance from the user for this turn. Directives are not contract and never count as evidence.",
|
|
69
|
+
"",
|
|
70
|
+
...dispatch.directives.map((d) => `- ${d.directive}`),
|
|
71
|
+
].join("\n"));
|
|
72
|
+
}
|
|
73
|
+
if (project.extraRules !== undefined) {
|
|
74
|
+
const path = resolve(project.root, project.extraRules);
|
|
75
|
+
// A missing file is reported by `gdt doctor`; the prompt never invents rules.
|
|
76
|
+
if (existsSync(path))
|
|
77
|
+
parts.push(["## Project rules", "", readFileSync(path, "utf8").trimEnd()].join("\n"));
|
|
78
|
+
}
|
|
79
|
+
return `${parts.join("\n\n")}\n`;
|
|
80
|
+
}
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ROLES } from "./config.js";
|
|
3
|
+
/** Record kinds; the marker is `[gdt-<kind>:v1]` ... `[/gdt-<kind>:v1]`. */
|
|
4
|
+
export const KINDS = ["handoff", "test", "review", "question", "answer", "directive", "round"];
|
|
5
|
+
export const DEVELOPER_STATUSES = ["ready", "awaiting_human", "blocked"];
|
|
6
|
+
export const VERIFIER_STATUSES = ["approved", "changes_requested", "awaiting_human", "blocked"];
|
|
7
|
+
export const AC_RESULTS = ["passed", "failed", "not_verified"];
|
|
8
|
+
const repository = z.string().regex(/^[\w.-]+\/[\w.-]+$/, "expected owner/name");
|
|
9
|
+
const issue = z.int().positive();
|
|
10
|
+
const round = z.int().min(0);
|
|
11
|
+
const acId = z.string().regex(/^AC-[1-9]\d*$/, "expected AC-<n>");
|
|
12
|
+
const sha1 = z.string().regex(/^[0-9a-f]{40}$/, "expected a full 40-character commit SHA");
|
|
13
|
+
const sha256 = z.string().regex(/^[0-9a-f]{64}$/, "expected a sha256 hex digest");
|
|
14
|
+
const text = z.string().trim().min(1);
|
|
15
|
+
const core = {
|
|
16
|
+
repository,
|
|
17
|
+
issue,
|
|
18
|
+
round,
|
|
19
|
+
pr_number: z.int().positive().nullable(),
|
|
20
|
+
issue_body_sha256: sha256,
|
|
21
|
+
acceptance_criteria: z.array(acId),
|
|
22
|
+
};
|
|
23
|
+
const verifier = (role, findingPrefix) => z.strictObject({
|
|
24
|
+
role: z.literal(role),
|
|
25
|
+
status: z.enum(VERIFIER_STATUSES),
|
|
26
|
+
...core,
|
|
27
|
+
head: sha1,
|
|
28
|
+
ac_results: z.array(z.strictObject({ ac: acId, result: z.enum(AC_RESULTS), evidence: text })),
|
|
29
|
+
findings: z.array(z.strictObject({
|
|
30
|
+
id: z.string().regex(new RegExp(`^${findingPrefix}-[1-9]\\d*$`), `expected ${findingPrefix}-<n>`),
|
|
31
|
+
blocking: z.boolean(),
|
|
32
|
+
summary: text,
|
|
33
|
+
})),
|
|
34
|
+
});
|
|
35
|
+
export const schemas = {
|
|
36
|
+
handoff: z.strictObject({
|
|
37
|
+
role: z.literal("developer"),
|
|
38
|
+
status: z.enum(DEVELOPER_STATUSES),
|
|
39
|
+
...core,
|
|
40
|
+
ac_traceability: z.array(z.strictObject({ ac: acId, files: z.array(text), tests: z.array(text) })),
|
|
41
|
+
assumptions: z.array(text),
|
|
42
|
+
deviations: z.array(text),
|
|
43
|
+
}),
|
|
44
|
+
test: verifier("tester", "T"),
|
|
45
|
+
review: verifier("reviewer", "R"),
|
|
46
|
+
question: z.strictObject({
|
|
47
|
+
role: z.enum(ROLES),
|
|
48
|
+
...core,
|
|
49
|
+
question_id: z.string().regex(/^Q[1-9]\d*$/, "expected Q<n>"),
|
|
50
|
+
resume_role: z.enum(ROLES),
|
|
51
|
+
question: text,
|
|
52
|
+
}),
|
|
53
|
+
answer: z.strictObject({
|
|
54
|
+
repository,
|
|
55
|
+
issue,
|
|
56
|
+
question_id: z.string().regex(/^Q[1-9]\d*$/, "expected Q<n>"),
|
|
57
|
+
answer: text,
|
|
58
|
+
}),
|
|
59
|
+
directive: z.strictObject({ repository, issue, role: z.enum(ROLES), directive: text }),
|
|
60
|
+
/** Grants the correction round `round` beyond `max_correction_rounds`. */
|
|
61
|
+
round: z.strictObject({ repository, issue, round: z.int().min(1) }),
|
|
62
|
+
};
|
|
63
|
+
export function marker(kind, closing = false) {
|
|
64
|
+
return `[${closing ? "/" : ""}gdt-${kind}:v1]`;
|
|
65
|
+
}
|
|
66
|
+
/** The comment body carrying one record: opening marker, JSON object and closing marker. */
|
|
67
|
+
export function formatRecord(kind, data) {
|
|
68
|
+
return `${marker(kind)}\n${JSON.stringify(data, null, 2)}\n${marker(kind, true)}\n`;
|
|
69
|
+
}
|
|
70
|
+
const BLOCK = new RegExp(`\\[gdt-(${KINDS.join("|")}):v1\\]([\\s\\S]*?)\\[/gdt-\\1:v1\\]`, "g");
|
|
71
|
+
/** Tolerates a Markdown code fence around the JSON object. */
|
|
72
|
+
function unfence(content) {
|
|
73
|
+
const fenced = /^\s*```[\w-]*\r?\n([\s\S]*?)\r?\n\s*```\s*$/.exec(content);
|
|
74
|
+
return fenced?.[1] ?? content;
|
|
75
|
+
}
|
|
76
|
+
function schemaReason(error) {
|
|
77
|
+
return error.issues
|
|
78
|
+
.flatMap((issue) => issue.code === "unrecognized_keys"
|
|
79
|
+
? issue.keys.map((key) => `unrecognized key "${[...issue.path, key].join(".")}"`)
|
|
80
|
+
: [`${issue.path.length > 0 ? `${issue.path.join(".")}: ` : ""}${issue.message}`])
|
|
81
|
+
.map((reason) => `schema: ${reason}`)
|
|
82
|
+
.join("; ");
|
|
83
|
+
}
|
|
84
|
+
/** Extracts every record from `comments`. Invalid blocks are excluded and listed in `diagnostics`. */
|
|
85
|
+
export function parseRecords(comments) {
|
|
86
|
+
const records = [];
|
|
87
|
+
const diagnostics = [];
|
|
88
|
+
for (const comment of comments) {
|
|
89
|
+
for (const match of comment.body.matchAll(BLOCK)) {
|
|
90
|
+
const kind = match[1];
|
|
91
|
+
let json;
|
|
92
|
+
try {
|
|
93
|
+
json = JSON.parse(unfence(match[2] ?? ""));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
diagnostics.push({ comment_id: comment.id, kind, reason: "invalid JSON" });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const parsed = schemas[kind].safeParse(json);
|
|
100
|
+
if (!parsed.success) {
|
|
101
|
+
diagnostics.push({ comment_id: comment.id, kind, reason: schemaReason(parsed.error) });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
records.push({
|
|
105
|
+
kind,
|
|
106
|
+
data: parsed.data,
|
|
107
|
+
author: comment.author,
|
|
108
|
+
created_at: comment.created_at,
|
|
109
|
+
comment_id: comment.id,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { records, diagnostics };
|
|
114
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
export const STATUSES = [
|
|
5
|
+
"starting",
|
|
6
|
+
"running",
|
|
7
|
+
"paused",
|
|
8
|
+
"awaiting_human",
|
|
9
|
+
"waiting_for_checks",
|
|
10
|
+
"ready_to_merge",
|
|
11
|
+
"blocked",
|
|
12
|
+
"contract_changed",
|
|
13
|
+
"failed",
|
|
14
|
+
"stopped",
|
|
15
|
+
];
|
|
16
|
+
/** Statuses that notify the user when the workflow enters them. */
|
|
17
|
+
export const NOTIFY_STATUSES = ["awaiting_human", "blocked", "failed", "ready_to_merge"];
|
|
18
|
+
/** The repository's common Git directory, so worktrees share one state. */
|
|
19
|
+
function gitDir(root, env) {
|
|
20
|
+
const result = spawnSync("git", ["rev-parse", "--git-common-dir"], { cwd: root, env, encoding: "utf8" });
|
|
21
|
+
if (result.status === 0 && result.stdout.trim() !== "")
|
|
22
|
+
return resolve(root, result.stdout.trim());
|
|
23
|
+
return join(root, ".git");
|
|
24
|
+
}
|
|
25
|
+
export function paths(root, issue, env) {
|
|
26
|
+
const dir = join(gitDir(root, env), "gdt", `issue-${issue}`);
|
|
27
|
+
return {
|
|
28
|
+
root,
|
|
29
|
+
dir,
|
|
30
|
+
state: join(dir, "state.json"),
|
|
31
|
+
lock: join(dir, "supervisor.lock"),
|
|
32
|
+
pause: join(dir, "paused"),
|
|
33
|
+
stopping: join(dir, "stopping"),
|
|
34
|
+
overrides: join(dir, "overrides.json"),
|
|
35
|
+
logs: join(dir, "logs"),
|
|
36
|
+
panes: join(dir, "panes.json"),
|
|
37
|
+
pids: join(dir, "pids"),
|
|
38
|
+
dispatch: (role) => join(dir, "dispatch", `${role}.json`),
|
|
39
|
+
started: (key) => join(dir, "runs", `${key}.started`),
|
|
40
|
+
result: (key) => join(dir, "runs", `${key}.result.json`),
|
|
41
|
+
prompt: (key) => join(dir, "runs", `${key}.prompt.md`),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** A log line timestamp in local time, `YYYY-MM-DD hh:mm:ss`. */
|
|
45
|
+
export function logTimestamp(date = new Date()) {
|
|
46
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
47
|
+
return (`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
|
48
|
+
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`);
|
|
49
|
+
}
|
|
50
|
+
/** Writes via a temporary file and rename, so readers never see a partial file. */
|
|
51
|
+
export function writeJsonAtomic(path, value) {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
54
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
55
|
+
renameSync(tmp, path);
|
|
56
|
+
}
|
|
57
|
+
export function readJson(path) {
|
|
58
|
+
if (!existsSync(path))
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function readState(p) {
|
|
68
|
+
return readJson(p.state);
|
|
69
|
+
}
|
|
70
|
+
/** The per-issue agent/model overrides, empty when none are set. */
|
|
71
|
+
export function readOverrides(p) {
|
|
72
|
+
return readJson(p.overrides) ?? {};
|
|
73
|
+
}
|
|
74
|
+
export function writeState(p, state, now = new Date()) {
|
|
75
|
+
state.updated_at = now.toISOString();
|
|
76
|
+
writeJsonAtomic(p.state, state);
|
|
77
|
+
}
|
|
78
|
+
export function alive(pid) {
|
|
79
|
+
if (pid === null || pid === undefined || pid <= 0)
|
|
80
|
+
return false;
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return err.code === "EPERM";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The pid in the lock file if that process is alive. */
|
|
90
|
+
export function lockHolder(p) {
|
|
91
|
+
if (!existsSync(p.lock))
|
|
92
|
+
return null;
|
|
93
|
+
const pid = Number(readFileSync(p.lock, "utf8").trim());
|
|
94
|
+
return Number.isInteger(pid) && alive(pid) ? pid : null;
|
|
95
|
+
}
|
|
96
|
+
/** Takes the per-issue supervisor lock; returns the holder's pid when another live process has it. */
|
|
97
|
+
export function acquireLock(p) {
|
|
98
|
+
mkdirSync(p.dir, { recursive: true });
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(p.lock, `${process.pid}\n`, { flag: "wx" });
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// The lock file exists: honour it only while its process is alive.
|
|
105
|
+
}
|
|
106
|
+
const holder = lockHolder(p);
|
|
107
|
+
if (holder !== null && holder !== process.pid)
|
|
108
|
+
return holder;
|
|
109
|
+
const tmp = `${p.lock}.${process.pid}.tmp`;
|
|
110
|
+
writeFileSync(tmp, `${process.pid}\n`);
|
|
111
|
+
renameSync(tmp, p.lock);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
package/dist/steering.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { adapterFor, supportedAgents } from "./agents/index.js";
|
|
6
|
+
import { ROLES } from "./config.js";
|
|
7
|
+
import { findRepository, which } from "./doctor.js";
|
|
8
|
+
import { comments, issueSnapshot, postIssueComment, postPullRequestComment, repository } from "./github.js";
|
|
9
|
+
import { formatRecord, parseRecords } from "./protocol.js";
|
|
10
|
+
import { lockHolder, paths, readOverrides, readState, writeJsonAtomic } from "./state.js";
|
|
11
|
+
import { describe, fail, ok } from "./workflow.js";
|
|
12
|
+
function rootOf(cwd) {
|
|
13
|
+
return findRepository(cwd) ?? cwd;
|
|
14
|
+
}
|
|
15
|
+
function ghFailure(err) {
|
|
16
|
+
return fail(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
17
|
+
}
|
|
18
|
+
/** The role, or a failure result naming the allowed roles. */
|
|
19
|
+
function checkRole(role) {
|
|
20
|
+
return ROLES.includes(role) ? role : fail(`Unknown role "${role}"; use developer, tester or reviewer\n`);
|
|
21
|
+
}
|
|
22
|
+
/** `gdt answer <issue> <question-id> <text>`: posts one `human-answer` for an open question. */
|
|
23
|
+
export function answer(issue, questionId, text, cwd, env) {
|
|
24
|
+
const answerText = text.trim();
|
|
25
|
+
if (answerText === "")
|
|
26
|
+
return fail(`The answer for ${questionId} is empty.\n`);
|
|
27
|
+
const root = rootOf(cwd);
|
|
28
|
+
try {
|
|
29
|
+
const repo = repository(root, env);
|
|
30
|
+
const thread = comments(repo, issue, root, env);
|
|
31
|
+
for (const pr of issueSnapshot(issue, root, env).pullRequests)
|
|
32
|
+
thread.push(...comments(repo, pr, root, env));
|
|
33
|
+
const { records } = parseRecords(thread);
|
|
34
|
+
const questions = records.filter((r) => r.kind === "question" && r.data.issue === issue && r.data.question_id === questionId);
|
|
35
|
+
const answers = records.filter((r) => r.kind === "answer" && r.data.issue === issue && r.data.question_id === questionId);
|
|
36
|
+
const open = questions.filter((q) => !answers.some((a) => Date.parse(a.created_at) >= Date.parse(q.created_at)));
|
|
37
|
+
if (open.length === 0)
|
|
38
|
+
return fail(`No open question ${questionId} on #${issue}\n`);
|
|
39
|
+
postIssueComment(issue, formatRecord("answer", { repository: repo, issue, question_id: questionId, answer: answerText }), root, env);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return ghFailure(err);
|
|
43
|
+
}
|
|
44
|
+
return ok(`Answered ${questionId} on #${issue}.\n`);
|
|
45
|
+
}
|
|
46
|
+
/** `gdt steer <issue> --role <role> <text>`: posts one `human-directive` on the workflow channel. */
|
|
47
|
+
export function steer(issue, role, text, cwd, env) {
|
|
48
|
+
const checked = checkRole(role);
|
|
49
|
+
if (typeof checked !== "string")
|
|
50
|
+
return checked;
|
|
51
|
+
const directive = text.trim();
|
|
52
|
+
if (directive === "")
|
|
53
|
+
return fail(`The directive for ${checked} is empty.\n`);
|
|
54
|
+
const root = rootOf(cwd);
|
|
55
|
+
try {
|
|
56
|
+
const repo = repository(root, env);
|
|
57
|
+
const pullRequests = issueSnapshot(issue, root, env).pullRequests;
|
|
58
|
+
if (pullRequests.length > 1) {
|
|
59
|
+
return fail(`more than one open pull request closes #${issue}: ${pullRequests.map((n) => `#${n}`).join(", ")}\n`);
|
|
60
|
+
}
|
|
61
|
+
const body = formatRecord("directive", { repository: repo, issue, role: checked, directive });
|
|
62
|
+
const pr = pullRequests[0];
|
|
63
|
+
if (pr === undefined)
|
|
64
|
+
postIssueComment(issue, body, root, env);
|
|
65
|
+
else
|
|
66
|
+
postPullRequestComment(pr, body, root, env);
|
|
67
|
+
return ok(`Posted a directive for ${checked} on ${pr === undefined ? `issue #${issue}` : `pull request #${pr}`}.\n`);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return ghFailure(err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** `gdt pause <issue>`: no turn is dispatched until `gdt resume`. */
|
|
74
|
+
export function pause(issue, cwd, env) {
|
|
75
|
+
const p = paths(rootOf(cwd), issue, env);
|
|
76
|
+
if (readState(p) === null)
|
|
77
|
+
return fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
|
|
78
|
+
mkdirSync(dirname(p.pause), { recursive: true });
|
|
79
|
+
writeFileSync(p.pause, `${new Date().toISOString()}\n`);
|
|
80
|
+
return ok(`Paused #${issue}. Next: gdt resume ${issue}\n`);
|
|
81
|
+
}
|
|
82
|
+
/** `gdt resume <issue>`: clears the pause; the next decision is dispatched again. */
|
|
83
|
+
export function resume(issue, cwd, env) {
|
|
84
|
+
const root = rootOf(cwd);
|
|
85
|
+
const p = paths(root, issue, env);
|
|
86
|
+
const state = readState(p);
|
|
87
|
+
if (state === null)
|
|
88
|
+
return fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
|
|
89
|
+
rmSync(p.pause, { force: true });
|
|
90
|
+
// The supervisor corrects the status on its next poll; report the resumed workflow now.
|
|
91
|
+
const resumed = state.status === "paused" ? { ...state, status: "starting", reason: "" } : state;
|
|
92
|
+
return ok(`Resumed #${issue}. Next: ${describe(resumed, lockHolder(p) !== null).next}\n`);
|
|
93
|
+
}
|
|
94
|
+
/** `gdt allow-round <issue>`: grants one extra correction round when the budget is exhausted. */
|
|
95
|
+
export function allowRound(issue, cwd, env) {
|
|
96
|
+
const root = rootOf(cwd);
|
|
97
|
+
const p = paths(root, issue, env);
|
|
98
|
+
const state = readState(p);
|
|
99
|
+
if (state === null)
|
|
100
|
+
return fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
|
|
101
|
+
if (state.status !== "blocked" || !state.reason.startsWith("round budget exhausted")) {
|
|
102
|
+
return fail("allow-round is only valid when the round budget is exhausted\n");
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const repo = repository(root, env);
|
|
106
|
+
const round = (state.round ?? 0) + 1;
|
|
107
|
+
postIssueComment(issue, formatRecord("round", { repository: repo, issue, round }), root, env);
|
|
108
|
+
return ok(`Granted round ${round} for #${issue}. Next: wait\n`);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
return ghFailure(err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** `gdt set-agent <issue> <role> <agent>/<model>`: overrides the config for that role from its next turn. */
|
|
115
|
+
export function setAgent(issue, role, spec, cwd, env) {
|
|
116
|
+
const checked = checkRole(role);
|
|
117
|
+
if (typeof checked !== "string")
|
|
118
|
+
return checked;
|
|
119
|
+
const slash = spec.indexOf("/");
|
|
120
|
+
const agent = slash === -1 ? spec : spec.slice(0, slash);
|
|
121
|
+
const model = slash === -1 ? "" : spec.slice(slash + 1);
|
|
122
|
+
const supported = supportedAgents();
|
|
123
|
+
if (!supported.includes(agent)) {
|
|
124
|
+
return fail(`Unsupported agent "${agent}"; supported agents: ${supported.join(", ")}\n`);
|
|
125
|
+
}
|
|
126
|
+
if (model.trim() === "")
|
|
127
|
+
return fail(`Missing model in "${spec}"; use <agent>/<model>\n`);
|
|
128
|
+
const p = paths(rootOf(cwd), issue, env);
|
|
129
|
+
if (readState(p) === null)
|
|
130
|
+
return fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
|
|
131
|
+
writeJsonAtomic(p.overrides, { ...readOverrides(p), [checked]: { agent: agent, model } });
|
|
132
|
+
return ok(`${checked} for #${issue} now uses ${agent} with model ${model} from its next turn.\n`);
|
|
133
|
+
}
|
|
134
|
+
/** `gdt install-skill`: copies `skill/SKILL.md` into every detected harness; idempotent. */
|
|
135
|
+
export function installSkill(env) {
|
|
136
|
+
const source = fileURLToPath(new URL("../skill/SKILL.md", import.meta.url));
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = readFileSync(source, "utf8");
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
return fail(`Cannot read ${source}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
143
|
+
}
|
|
144
|
+
const home = env.HOME ?? homedir();
|
|
145
|
+
const lines = [];
|
|
146
|
+
for (const agent of supportedAgents()) {
|
|
147
|
+
const adapter = adapterFor(agent);
|
|
148
|
+
if (adapter === undefined)
|
|
149
|
+
continue;
|
|
150
|
+
if (which(adapter.binary, env) === null) {
|
|
151
|
+
lines.push(`skipped: ${agent} not found`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const dir = adapter.skillDir().replace(/^~/, home);
|
|
155
|
+
const destination = join(dir, "SKILL.md");
|
|
156
|
+
if (existsSync(destination) && readFileSync(destination, "utf8") === text) {
|
|
157
|
+
lines.push(`up to date ${destination}`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
mkdirSync(dir, { recursive: true });
|
|
161
|
+
writeFileSync(destination, text);
|
|
162
|
+
lines.push(`installed ${destination}`);
|
|
163
|
+
}
|
|
164
|
+
return ok(`${lines.join("\n")}\n`);
|
|
165
|
+
}
|