@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/contract.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { SECTION_KEYS } from "./locale.js";
|
|
2
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
3
|
+
const AC_HEADING = /^\s*(?:#{3,6}\s+|\*\*)AC-(\d+)\b/;
|
|
4
|
+
const UNCHECKED = /^\s*[-*+]\s+\[ \]\s+(.*)$/;
|
|
5
|
+
function escapeRegExp(text) {
|
|
6
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
+
}
|
|
8
|
+
/** Marks lines inside fenced code blocks, so headings and phrases in code are ignored. */
|
|
9
|
+
function classify(body) {
|
|
10
|
+
const lines = [];
|
|
11
|
+
let fence = null;
|
|
12
|
+
for (const text of body.replace(/\r\n?/g, "\n").split("\n")) {
|
|
13
|
+
const match = FENCE.exec(text);
|
|
14
|
+
if (fence === null) {
|
|
15
|
+
if (match?.[1] !== undefined)
|
|
16
|
+
fence = match[1];
|
|
17
|
+
lines.push({ text, code: fence !== null });
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const closing = match?.[1];
|
|
21
|
+
if (closing !== undefined && closing[0] === fence[0] && closing.length >= fence.length && text.trim() === closing) {
|
|
22
|
+
fence = null;
|
|
23
|
+
}
|
|
24
|
+
lines.push({ text, code: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
function splitSections(body) {
|
|
30
|
+
const sections = [];
|
|
31
|
+
let current = null;
|
|
32
|
+
for (const line of classify(body)) {
|
|
33
|
+
const heading = line.code ? null : /^## (.*)$/.exec(line.text);
|
|
34
|
+
if (heading?.[1] !== undefined) {
|
|
35
|
+
current = { heading: heading[1].trim(), lines: [] };
|
|
36
|
+
sections.push(current);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
current?.lines.push(line);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return sections;
|
|
43
|
+
}
|
|
44
|
+
function prose(lines) {
|
|
45
|
+
return lines.filter((line) => !line.code).map((line) => line.text);
|
|
46
|
+
}
|
|
47
|
+
function acceptanceCriteria(section, locale, options) {
|
|
48
|
+
const blocks = [];
|
|
49
|
+
for (const text of prose(section.lines)) {
|
|
50
|
+
const heading = AC_HEADING.exec(text);
|
|
51
|
+
if (heading?.[1] !== undefined)
|
|
52
|
+
blocks.push({ number: Number(heading[1]), lines: [] });
|
|
53
|
+
else
|
|
54
|
+
blocks.at(-1)?.lines.push(text);
|
|
55
|
+
}
|
|
56
|
+
const ids = blocks.map((block) => `AC-${block.number}`);
|
|
57
|
+
if (blocks.length === 0) {
|
|
58
|
+
return { ids, errors: [`No acceptance criteria found in section "${section.heading}"`] };
|
|
59
|
+
}
|
|
60
|
+
const errors = [];
|
|
61
|
+
if (blocks.some((block, i) => block.number !== i + 1)) {
|
|
62
|
+
errors.push(`Acceptance criteria must be numbered consecutively from AC-1; found ${ids.join(", ")}`);
|
|
63
|
+
}
|
|
64
|
+
if (blocks.length > options.maxAcceptanceCriteria) {
|
|
65
|
+
errors.push(`${blocks.length} acceptance criteria; maximum is ${options.maxAcceptanceCriteria}`);
|
|
66
|
+
}
|
|
67
|
+
const fields = [locale.ac_fields.given, locale.ac_fields.when, locale.ac_fields.then, locale.ac_fields.example];
|
|
68
|
+
for (const [i, block] of blocks.entries()) {
|
|
69
|
+
for (const field of fields) {
|
|
70
|
+
// "- Given: ...", also "- **Given:** ..." and "- **Given**: ...".
|
|
71
|
+
const pattern = new RegExp(`^\\s*[-*+]\\s+(?:\\*\\*)?${escapeRegExp(field)}(?::|\\*\\*\\s*:|\\s+:)`);
|
|
72
|
+
if (!block.lines.some((line) => pattern.test(line)))
|
|
73
|
+
errors.push(`${ids[i]}: missing "${field}"`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { ids, errors };
|
|
77
|
+
}
|
|
78
|
+
function withoutInlineCode(text) {
|
|
79
|
+
return text.replace(/(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)/g, " ");
|
|
80
|
+
}
|
|
81
|
+
function vaguePhraseErrors(sections, phrases) {
|
|
82
|
+
const patterns = phrases.map((phrase) => ({
|
|
83
|
+
phrase,
|
|
84
|
+
// Word boundaries that also hold for non-ASCII letters and for phrases ending in punctuation ("etc.").
|
|
85
|
+
regex: new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegExp(phrase)}(?![\\p{L}\\p{N}_])`, "iu"),
|
|
86
|
+
}));
|
|
87
|
+
const errors = [];
|
|
88
|
+
for (const section of sections) {
|
|
89
|
+
const text = withoutInlineCode([section.heading, ...prose(section.lines)].join("\n"));
|
|
90
|
+
for (const { phrase, regex } of patterns) {
|
|
91
|
+
if (regex.test(text))
|
|
92
|
+
errors.push(`Vague phrase "${phrase}" in section "${section.heading}"`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return errors;
|
|
96
|
+
}
|
|
97
|
+
/** The trimmed text of the first section with `heading`, or undefined when the body has none. */
|
|
98
|
+
export function sectionText(body, heading) {
|
|
99
|
+
const section = splitSections(body).find((s) => s.heading === heading);
|
|
100
|
+
return section?.lines.map((line) => line.text).join("\n").trim();
|
|
101
|
+
}
|
|
102
|
+
/** Validates an issue body against the contract for `locale`. Pure: no I/O. */
|
|
103
|
+
export function validateContract(body, locale, options) {
|
|
104
|
+
const sections = splitSections(body);
|
|
105
|
+
const find = (heading) => sections.find((section) => section.heading === heading);
|
|
106
|
+
const errors = [];
|
|
107
|
+
for (const key of SECTION_KEYS) {
|
|
108
|
+
const heading = locale.sections[key];
|
|
109
|
+
if (find(heading) === undefined)
|
|
110
|
+
errors.push(`Missing section: ${heading}`);
|
|
111
|
+
}
|
|
112
|
+
let ids = [];
|
|
113
|
+
const acSection = find(locale.sections.acceptance_criteria);
|
|
114
|
+
if (acSection !== undefined) {
|
|
115
|
+
const result = acceptanceCriteria(acSection, locale, options);
|
|
116
|
+
ids = result.ids;
|
|
117
|
+
errors.push(...result.errors);
|
|
118
|
+
}
|
|
119
|
+
const openQuestions = find(locale.sections.open_questions);
|
|
120
|
+
if (openQuestions !== undefined && openQuestions.lines.map((line) => line.text).join("\n").trim() !== locale.markers.none) {
|
|
121
|
+
errors.push(`${locale.sections.open_questions} must be exactly "${locale.markers.none}"`);
|
|
122
|
+
}
|
|
123
|
+
errors.push(...vaguePhraseErrors(sections, locale.vague_phrases));
|
|
124
|
+
const readiness = find(locale.sections.readiness);
|
|
125
|
+
if (readiness !== undefined) {
|
|
126
|
+
for (const text of prose(readiness.lines)) {
|
|
127
|
+
const item = UNCHECKED.exec(text)?.[1];
|
|
128
|
+
if (item !== undefined)
|
|
129
|
+
errors.push(`Readiness item not checked: ${item.trim()}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { valid: errors.length === 0, acceptance_criteria: ids, errors };
|
|
133
|
+
}
|
package/dist/decision.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
export const CHECK_STATES = ["success", "pending", "failure"];
|
|
2
|
+
const ROLE_OF = { handoff: "developer", test: "tester", review: "reviewer" };
|
|
3
|
+
function time(iso) {
|
|
4
|
+
return Date.parse(iso);
|
|
5
|
+
}
|
|
6
|
+
function isRoleRecord(record) {
|
|
7
|
+
return record.kind === "handoff" || record.kind === "test" || record.kind === "review";
|
|
8
|
+
}
|
|
9
|
+
/** Keeps records that are trusted and belong to this issue and, where they carry one, the current body hash. */
|
|
10
|
+
function relevant(snapshot) {
|
|
11
|
+
const trusted = new Set(snapshot.trusted_authors);
|
|
12
|
+
return snapshot.records
|
|
13
|
+
.filter((record) => trusted.has(record.author))
|
|
14
|
+
.filter((record) => record.data.repository === snapshot.repository && record.data.issue === snapshot.issue)
|
|
15
|
+
.filter((record) => !("issue_body_sha256" in record.data) || record.data.issue_body_sha256 === snapshot.issue_body_sha256)
|
|
16
|
+
.toSorted((a, b) => time(a.created_at) - time(b.created_at) || a.comment_id - b.comment_id);
|
|
17
|
+
}
|
|
18
|
+
function latest(records, match) {
|
|
19
|
+
return records.filter(match).at(-1);
|
|
20
|
+
}
|
|
21
|
+
function questionDecision(records) {
|
|
22
|
+
const questions = records.filter((r) => r.kind === "question");
|
|
23
|
+
const answers = records.filter((r) => r.kind === "answer");
|
|
24
|
+
const answerOf = (q) => answers.find((a) => a.data.question_id === q.data.question_id && time(a.created_at) >= time(q.created_at));
|
|
25
|
+
const open = questions.filter((q) => answerOf(q) === undefined);
|
|
26
|
+
if (open.length > 0) {
|
|
27
|
+
return { action: "awaiting_human", reason: `waiting for an answer to ${open.map((q) => q.data.question_id).join(", ")}` };
|
|
28
|
+
}
|
|
29
|
+
// The most recently answered question resumes its role, unless that role has published since the answer.
|
|
30
|
+
const answered = questions.map((q) => ({ q, a: answerOf(q) })).sort((x, y) => time(x.a.created_at) - time(y.a.created_at));
|
|
31
|
+
const last = answered.at(-1);
|
|
32
|
+
if (last === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
const role = last.q.data.resume_role;
|
|
35
|
+
const resumed = records.some((r) => isRoleRecord(r) && ROLE_OF[r.kind] === role && time(r.created_at) > time(last.a.created_at));
|
|
36
|
+
if (resumed)
|
|
37
|
+
return undefined;
|
|
38
|
+
return {
|
|
39
|
+
action: "dispatch",
|
|
40
|
+
role,
|
|
41
|
+
round: last.q.data.round,
|
|
42
|
+
reason: `${last.q.data.question_id} answered; resuming ${role}`,
|
|
43
|
+
question_id: last.q.data.question_id,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function correction(snapshot, records, record, round) {
|
|
47
|
+
// A verifier record with a stale or wrong round must not reset the budget.
|
|
48
|
+
const next = Math.max(round, record.data.round) + 1;
|
|
49
|
+
const granted = records.some((r) => r.kind === "round" && r.data.round === next);
|
|
50
|
+
const open = record.data.findings.map((f) => f.id);
|
|
51
|
+
if (next > snapshot.config.max_correction_rounds && !granted) {
|
|
52
|
+
return { action: "blocked", reason: `round budget exhausted; open findings: ${open.length > 0 ? open.join(", ") : "none"}` };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
action: "dispatch",
|
|
56
|
+
role: "developer",
|
|
57
|
+
round: next,
|
|
58
|
+
reason: `${ROLE_OF[record.kind]} requested changes${open.length > 0 ? `: ${open.join(", ")}` : ""}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function gates(snapshot, pr, approvals) {
|
|
62
|
+
for (const record of approvals) {
|
|
63
|
+
const role = ROLE_OF[record.kind];
|
|
64
|
+
const results = new Map(record.data.ac_results.map((r) => [r.ac, r.result]));
|
|
65
|
+
const unpassed = snapshot.acceptance_criteria.filter((ac) => results.get(ac) !== "passed");
|
|
66
|
+
if (unpassed.length > 0)
|
|
67
|
+
return { action: "blocked", reason: `${role} approved but not passed: ${unpassed.join(", ")}` };
|
|
68
|
+
const blocking = record.data.findings.filter((f) => f.blocking).map((f) => f.id);
|
|
69
|
+
if (blocking.length > 0)
|
|
70
|
+
return { action: "blocked", reason: `open blocking findings: ${blocking.join(", ")}` };
|
|
71
|
+
}
|
|
72
|
+
if (pr.mergeable === "conflicting")
|
|
73
|
+
return { action: "blocked", reason: "pull request has conflicts" };
|
|
74
|
+
if (pr.mergeable === "unknown")
|
|
75
|
+
return { action: "waiting_for_checks", reason: "mergeability not yet known" };
|
|
76
|
+
const required = snapshot.config.required_checks;
|
|
77
|
+
if (required.length === 0) {
|
|
78
|
+
return snapshot.config.allow_no_required_checks
|
|
79
|
+
? { action: "ready_to_merge", reason: "approved; no required checks configured (allowed by config)" }
|
|
80
|
+
: { action: "blocked", reason: "no required checks configured" };
|
|
81
|
+
}
|
|
82
|
+
const state = (name) => (Object.hasOwn(pr.checks, name) ? pr.checks[name] : undefined);
|
|
83
|
+
const missing = required.filter((name) => state(name) === undefined);
|
|
84
|
+
if (missing.length > 0)
|
|
85
|
+
return { action: "blocked", reason: `required check missing: ${missing.join(", ")}` };
|
|
86
|
+
const failed = required.filter((name) => state(name) === "failure");
|
|
87
|
+
if (failed.length > 0)
|
|
88
|
+
return { action: "blocked", reason: `required check failed: ${failed.join(", ")}` };
|
|
89
|
+
const pending = required.filter((name) => state(name) === "pending");
|
|
90
|
+
if (pending.length > 0)
|
|
91
|
+
return { action: "waiting_for_checks", reason: `waiting for required checks: ${pending.join(", ")}` };
|
|
92
|
+
return { action: "ready_to_merge", reason: "all gates passed" };
|
|
93
|
+
}
|
|
94
|
+
/** Evidence for the current head, created after both the head transition and `after`. */
|
|
95
|
+
function evidence(records, kind, pr, after) {
|
|
96
|
+
return latest(records, (r) => r.kind === kind && r.data.head === pr.head && time(r.created_at) > after);
|
|
97
|
+
}
|
|
98
|
+
/** Design 8: a verifier block counts only for the current head and after the latest head transition. */
|
|
99
|
+
function staleVerifierBlock(snapshot, record) {
|
|
100
|
+
const pr = snapshot.pr;
|
|
101
|
+
// Without a current head there is nothing to lift the block against; keep it.
|
|
102
|
+
if (pr === null)
|
|
103
|
+
return false;
|
|
104
|
+
const transition = snapshot.head_transition_at === null ? Number.NEGATIVE_INFINITY : time(snapshot.head_transition_at);
|
|
105
|
+
return record.data.head !== pr.head || time(record.created_at) <= transition;
|
|
106
|
+
}
|
|
107
|
+
/** A verifier record that is not an approval ends the chain here. */
|
|
108
|
+
function verdict(snapshot, records, record, round) {
|
|
109
|
+
const role = ROLE_OF[record.kind];
|
|
110
|
+
switch (record.data.status) {
|
|
111
|
+
case "approved":
|
|
112
|
+
return undefined;
|
|
113
|
+
case "changes_requested":
|
|
114
|
+
return correction(snapshot, records, record, round);
|
|
115
|
+
case "awaiting_human":
|
|
116
|
+
return { action: "awaiting_human", reason: `${role} is waiting for a human decision` };
|
|
117
|
+
case "blocked":
|
|
118
|
+
return { action: "blocked", reason: `${role} reported blocked` };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Ids of the blocking findings in the tester/reviewer evidence for the current head. */
|
|
122
|
+
export function openFindings(snapshot) {
|
|
123
|
+
const records = relevant(snapshot);
|
|
124
|
+
const pr = snapshot.pr;
|
|
125
|
+
if (pr === null)
|
|
126
|
+
return [];
|
|
127
|
+
const handoff = latest(records, (r) => r.kind === "handoff" && r.data.status === "ready");
|
|
128
|
+
if (handoff === undefined)
|
|
129
|
+
return [];
|
|
130
|
+
const transition = snapshot.head_transition_at === null ? Number.NEGATIVE_INFINITY : time(snapshot.head_transition_at);
|
|
131
|
+
const after = Math.max(transition, time(handoff.created_at));
|
|
132
|
+
const test = evidence(records, "test", pr, after);
|
|
133
|
+
const review = evidence(records, "review", pr, Math.max(after, test === undefined ? after : time(test.created_at)));
|
|
134
|
+
return [test, review].flatMap((record) => record === undefined ? [] : record.data.findings.filter((f) => f.blocking).map((f) => f.id));
|
|
135
|
+
}
|
|
136
|
+
/** The pure decision engine: no I/O, no clock. */
|
|
137
|
+
export function decide(snapshot) {
|
|
138
|
+
const records = relevant(snapshot);
|
|
139
|
+
const question = questionDecision(records);
|
|
140
|
+
if (question !== undefined)
|
|
141
|
+
return question;
|
|
142
|
+
const last = latest(records, isRoleRecord);
|
|
143
|
+
if (last !== undefined && last.data.status === "blocked") {
|
|
144
|
+
// AC-1/AC-4: a new head lifts a tester or reviewer block without consuming a round.
|
|
145
|
+
// AC-2: for the same head the block stays. AC-3: a developer block always stays.
|
|
146
|
+
const lifted = last.kind !== "handoff" && staleVerifierBlock(snapshot, last);
|
|
147
|
+
if (!lifted)
|
|
148
|
+
return { action: "blocked", reason: `${ROLE_OF[last.kind]} reported blocked` };
|
|
149
|
+
}
|
|
150
|
+
if (last !== undefined && last.data.status === "awaiting_human") {
|
|
151
|
+
return { action: "awaiting_human", reason: `${ROLE_OF[last.kind]} is waiting for a human decision` };
|
|
152
|
+
}
|
|
153
|
+
const handoff = latest(records, (r) => r.kind === "handoff" && r.data.status === "ready");
|
|
154
|
+
if (handoff === undefined)
|
|
155
|
+
return { action: "dispatch", role: "developer", round: 0, reason: "no developer handoff yet" };
|
|
156
|
+
const round = handoff.data.round;
|
|
157
|
+
const pr = snapshot.pr;
|
|
158
|
+
if (pr === null)
|
|
159
|
+
return { action: "blocked", reason: "developer handoff without a pull request" };
|
|
160
|
+
const transition = snapshot.head_transition_at === null ? Number.NEGATIVE_INFINITY : time(snapshot.head_transition_at);
|
|
161
|
+
const after = Math.max(transition, time(handoff.created_at));
|
|
162
|
+
const test = evidence(records, "test", pr, after);
|
|
163
|
+
if (test === undefined)
|
|
164
|
+
return { action: "dispatch", role: "tester", round, reason: `no tester evidence for head ${pr.head}` };
|
|
165
|
+
const afterTest = verdict(snapshot, records, test, round);
|
|
166
|
+
if (afterTest !== undefined)
|
|
167
|
+
return afterTest;
|
|
168
|
+
const review = evidence(records, "review", pr, Math.max(after, time(test.created_at)));
|
|
169
|
+
if (review === undefined)
|
|
170
|
+
return { action: "dispatch", role: "reviewer", round, reason: `no reviewer evidence for head ${pr.head}` };
|
|
171
|
+
const afterReview = verdict(snapshot, records, review, round);
|
|
172
|
+
if (afterReview !== undefined)
|
|
173
|
+
return afterReview;
|
|
174
|
+
return gates(snapshot, pr, [test, review]);
|
|
175
|
+
}
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { adapterFor } from "./agents/index.js";
|
|
5
|
+
import { CONFIG_PATH, LOCAL_CONFIG_PATH, loadConfig, ROLES, TEST_AGENT } from "./config.js";
|
|
6
|
+
import { hasErrors } from "./finding.js";
|
|
7
|
+
const TOOLS = [
|
|
8
|
+
{ name: "git", fix: "Install Git: https://git-scm.com/downloads" },
|
|
9
|
+
{ name: "gh", fix: "Install GitHub CLI: https://cli.github.com" },
|
|
10
|
+
];
|
|
11
|
+
/** The oldest herdr this backend's workspace and pane commands were verified against (docs/agents.md). */
|
|
12
|
+
export const MIN_HERDR_VERSION = "0.9.1";
|
|
13
|
+
const HERDR_VERSION = /herdr (\d+)\.(\d+)\.(\d+)/;
|
|
14
|
+
function versionAtLeast(found, minimum) {
|
|
15
|
+
const a = found.split(".").map(Number);
|
|
16
|
+
const b = minimum.split(".").map(Number);
|
|
17
|
+
for (let i = 0; i < 3; i++) {
|
|
18
|
+
const left = a[i] ?? 0;
|
|
19
|
+
const right = b[i] ?? 0;
|
|
20
|
+
if (left !== right)
|
|
21
|
+
return left > right;
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
/** Reads the installed herdr version, or null when it is missing or unreadable. */
|
|
26
|
+
function herdrVersion(env) {
|
|
27
|
+
const path = which("herdr", env);
|
|
28
|
+
if (path === null)
|
|
29
|
+
return null;
|
|
30
|
+
const result = spawnSync(path, ["--version"], { env, encoding: "utf8" });
|
|
31
|
+
const text = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
32
|
+
const match = HERDR_VERSION.exec(text);
|
|
33
|
+
if (result.status !== 0 || match === null)
|
|
34
|
+
return { path, error: text };
|
|
35
|
+
return { path, version: `${match[1]}.${match[2]}.${match[3]}` };
|
|
36
|
+
}
|
|
37
|
+
/** Preflight for `gdt start`: the recovery line when herdr is unusable, or null when it is fine. */
|
|
38
|
+
export function herdrPreflight(env) {
|
|
39
|
+
const found = herdrVersion(env);
|
|
40
|
+
if (found === null)
|
|
41
|
+
return 'herdr not found on PATH; install herdr or set workflow.terminal = "headless"';
|
|
42
|
+
if ("error" in found) {
|
|
43
|
+
return `herdr did not report a version (${found.error || "no output"}); update herdr to ${MIN_HERDR_VERSION} or newer, or set workflow.terminal = "headless"`;
|
|
44
|
+
}
|
|
45
|
+
if (!versionAtLeast(found.version, MIN_HERDR_VERSION)) {
|
|
46
|
+
return `herdr ${found.version} is older than the minimum supported ${MIN_HERDR_VERSION}; update herdr or set workflow.terminal = "headless"`;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
/** Checks that the configured herdr is present and at least `MIN_HERDR_VERSION`. */
|
|
51
|
+
function herdrFinding(env) {
|
|
52
|
+
const found = herdrVersion(env);
|
|
53
|
+
const fix = 'Install or update herdr (https://herdr.dev), or set workflow.terminal = "headless"';
|
|
54
|
+
if (found === null)
|
|
55
|
+
return { check: "herdr", level: "error", message: "herdr: not found on PATH", fix };
|
|
56
|
+
if ("error" in found) {
|
|
57
|
+
return { check: "herdr", level: "error", message: `herdr: could not read its version (${found.error || "no output"})`, fix };
|
|
58
|
+
}
|
|
59
|
+
if (!versionAtLeast(found.version, MIN_HERDR_VERSION)) {
|
|
60
|
+
return {
|
|
61
|
+
check: "herdr",
|
|
62
|
+
level: "error",
|
|
63
|
+
message: `herdr: ${found.version} is older than the minimum supported ${MIN_HERDR_VERSION}`,
|
|
64
|
+
fix,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { check: "herdr", level: "ok", message: `herdr: ${found.version} at ${found.path}`, fix: "" };
|
|
68
|
+
}
|
|
69
|
+
/** Resolves `name` against `env.PATH` like a shell would, without running anything. */
|
|
70
|
+
export function which(name, env) {
|
|
71
|
+
const extensions = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
72
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
73
|
+
if (dir === "")
|
|
74
|
+
continue;
|
|
75
|
+
for (const ext of extensions) {
|
|
76
|
+
const candidate = join(dir, name + ext);
|
|
77
|
+
try {
|
|
78
|
+
if (!statSync(candidate).isFile())
|
|
79
|
+
continue;
|
|
80
|
+
accessSync(candidate, constants.X_OK);
|
|
81
|
+
return candidate;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Not here; keep looking.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/** The nearest directory at or above `cwd` that contains `.git`. */
|
|
91
|
+
export function findRepository(cwd) {
|
|
92
|
+
for (let dir = resolve(cwd);; dir = dirname(dir)) {
|
|
93
|
+
if (existsSync(join(dir, ".git")))
|
|
94
|
+
return dir;
|
|
95
|
+
if (dirname(dir) === dir)
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function toolFindings(env) {
|
|
100
|
+
const findings = [];
|
|
101
|
+
const paths = new Map();
|
|
102
|
+
for (const tool of TOOLS) {
|
|
103
|
+
const path = which(tool.name, env);
|
|
104
|
+
if (path === null) {
|
|
105
|
+
findings.push({ check: tool.name, level: "error", message: `${tool.name}: not found on PATH`, fix: tool.fix });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
paths.set(tool.name, path);
|
|
109
|
+
findings.push({ check: tool.name, level: "ok", message: `${tool.name}: found at ${path}`, fix: "" });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { findings, paths };
|
|
113
|
+
}
|
|
114
|
+
function ghAuthFinding(gh, cwd, env) {
|
|
115
|
+
const result = spawnSync(gh, ["auth", "status", "--hostname", "github.com"], { cwd, env, encoding: "utf8" });
|
|
116
|
+
if (result.status === 0) {
|
|
117
|
+
return { check: "gh-auth", level: "ok", message: "gh: authenticated to github.com", fix: "" };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
check: "gh-auth",
|
|
121
|
+
level: "error",
|
|
122
|
+
message: "gh: not authenticated to github.com",
|
|
123
|
+
fix: 'Run "gh auth login"',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function excludePath(root, git, env) {
|
|
127
|
+
if (git !== undefined) {
|
|
128
|
+
const result = spawnSync(git, ["rev-parse", "--git-path", "info/exclude"], { cwd: root, env, encoding: "utf8" });
|
|
129
|
+
if (result.status === 0 && result.stdout.trim() !== "")
|
|
130
|
+
return resolve(root, result.stdout.trim());
|
|
131
|
+
}
|
|
132
|
+
const gitDir = join(root, ".git");
|
|
133
|
+
return statSync(gitDir).isDirectory() ? join(gitDir, "info", "exclude") : null;
|
|
134
|
+
}
|
|
135
|
+
/** Ensures the local override file is ignored by Git without touching the committed .gitignore. */
|
|
136
|
+
function excludeFinding(root, git, env) {
|
|
137
|
+
const check = "local-config-excluded";
|
|
138
|
+
const fix = `Add the line ${LOCAL_CONFIG_PATH} to .git/info/exclude`;
|
|
139
|
+
try {
|
|
140
|
+
const path = excludePath(root, git, env);
|
|
141
|
+
if (path === null) {
|
|
142
|
+
return { check, level: "warning", message: "Could not locate .git/info/exclude", fix };
|
|
143
|
+
}
|
|
144
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
145
|
+
if (!current.split(/\r?\n/).some((line) => line.trim() === LOCAL_CONFIG_PATH)) {
|
|
146
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
147
|
+
const separator = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
148
|
+
appendFileSync(path, `${separator}${LOCAL_CONFIG_PATH}\n`);
|
|
149
|
+
}
|
|
150
|
+
return { check, level: "ok", message: `${LOCAL_CONFIG_PATH} is listed in .git/info/exclude`, fix: "" };
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
154
|
+
return { check, level: "warning", message: `Could not update .git/info/exclude: ${reason}`, fix };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* AC-5: an error finding for every role whose agent has no adapter, i.e. no unattended invocation
|
|
159
|
+
* (docs/agents.md). Generic: any agent absent from the adapter registry is rejected here. The test
|
|
160
|
+
* agent is exempt: it runs a script instead of a CLI.
|
|
161
|
+
*/
|
|
162
|
+
export function unsupportedAgentFindings(roles) {
|
|
163
|
+
return ROLES.flatMap((role) => {
|
|
164
|
+
const { agent } = roles[role];
|
|
165
|
+
if (agent === TEST_AGENT || adapterFor(agent) !== undefined)
|
|
166
|
+
return [];
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
check: `roles.${role}`,
|
|
170
|
+
level: "error",
|
|
171
|
+
message: `roles.${role}.agent: ${agent} has no unattended mode; see docs/agents.md`,
|
|
172
|
+
fix: `Set roles.${role}.agent in ${CONFIG_PATH} to an agent with an unattended mode`,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/** Checks the agent binary of every configured role, and that the tester is independent of the developer. */
|
|
178
|
+
function agentFindings(config, env) {
|
|
179
|
+
const findings = unsupportedAgentFindings(config.roles);
|
|
180
|
+
for (const role of ROLES) {
|
|
181
|
+
const { agent } = config.roles[role];
|
|
182
|
+
const adapter = adapterFor(agent);
|
|
183
|
+
if (adapter === undefined)
|
|
184
|
+
continue;
|
|
185
|
+
const check = `roles.${role}`;
|
|
186
|
+
const path = which(adapter.binary, env);
|
|
187
|
+
findings.push(path === null
|
|
188
|
+
? { check, level: "error", message: `roles.${role}: ${adapter.binary} not found on PATH`, fix: adapter.install }
|
|
189
|
+
: { check, level: "ok", message: `roles.${role}: ${adapter.binary} found at ${path}`, fix: "" });
|
|
190
|
+
}
|
|
191
|
+
const vendor = (role) => adapterFor(config.roles[role].agent)?.vendorOf(config.roles[role].model);
|
|
192
|
+
const developer = vendor("developer");
|
|
193
|
+
if (developer !== undefined && developer === vendor("tester")) {
|
|
194
|
+
findings.push({
|
|
195
|
+
check: "roles.tester",
|
|
196
|
+
level: "warning",
|
|
197
|
+
message: `developer and tester both use ${developer}; use a different vendor for the tester for independent verification`,
|
|
198
|
+
fix: "Set roles.tester to an agent and model from another vendor",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return findings;
|
|
202
|
+
}
|
|
203
|
+
export function runDoctor(cwd, env) {
|
|
204
|
+
const findings = [];
|
|
205
|
+
const tools = toolFindings(env);
|
|
206
|
+
findings.push(...tools.findings);
|
|
207
|
+
const root = findRepository(cwd);
|
|
208
|
+
const gh = tools.paths.get("gh");
|
|
209
|
+
if (gh !== undefined)
|
|
210
|
+
findings.push(ghAuthFinding(gh, root ?? cwd, env));
|
|
211
|
+
if (root === null) {
|
|
212
|
+
findings.push({
|
|
213
|
+
check: "repository",
|
|
214
|
+
level: "error",
|
|
215
|
+
message: `${resolve(cwd)} is not inside a Git repository`,
|
|
216
|
+
fix: "Run gdt from inside a Git checkout of the target repository",
|
|
217
|
+
});
|
|
218
|
+
return { ok: false, repository: null, config: { valid: false, files: [] }, findings };
|
|
219
|
+
}
|
|
220
|
+
const { report, findings: configFindings } = loadConfig(root, env);
|
|
221
|
+
if (existsSync(join(root, CONFIG_PATH)) || existsSync(join(root, LOCAL_CONFIG_PATH))) {
|
|
222
|
+
findings.push(excludeFinding(root, tools.paths.get("git"), env));
|
|
223
|
+
}
|
|
224
|
+
findings.push(...configFindings);
|
|
225
|
+
if (report.valid) {
|
|
226
|
+
findings.push(...agentFindings(report, env));
|
|
227
|
+
if (report.workflow.terminal === "herdr")
|
|
228
|
+
findings.push(herdrFinding(env));
|
|
229
|
+
}
|
|
230
|
+
return { ok: !hasErrors(findings), repository: root, config: report, findings };
|
|
231
|
+
}
|
package/dist/finding.js
ADDED
package/dist/git.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
function git(args, cwd, env) {
|
|
3
|
+
return spawnSync("git", args, { cwd, env, encoding: "utf8" }).stdout ?? "";
|
|
4
|
+
}
|
|
5
|
+
/** Paths from `git status --porcelain -z`; renames report the new path. */
|
|
6
|
+
export function changedFiles(root, env, untracked) {
|
|
7
|
+
const entries = git(["status", "--porcelain", "-z", `--untracked-files=${untracked ? "all" : "no"}`], root, env).split("\0");
|
|
8
|
+
const files = [];
|
|
9
|
+
for (let i = 0; i < entries.length; i++) {
|
|
10
|
+
const entry = entries[i] ?? "";
|
|
11
|
+
if (entry.length < 4)
|
|
12
|
+
continue;
|
|
13
|
+
files.push(entry.slice(3));
|
|
14
|
+
// A rename or copy is followed by its original path.
|
|
15
|
+
if ("RC".includes(entry[0] ?? "") || "RC".includes(entry[1] ?? ""))
|
|
16
|
+
i++;
|
|
17
|
+
}
|
|
18
|
+
return files;
|
|
19
|
+
}
|
|
20
|
+
export function checkout(root, env) {
|
|
21
|
+
return {
|
|
22
|
+
head: git(["rev-parse", "HEAD"], root, env).trim(),
|
|
23
|
+
branch: git(["symbolic-ref", "--quiet", "--short", "HEAD"], root, env).trim(),
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/github.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { which } from "./doctor.js";
|
|
3
|
+
export class GhError extends Error {
|
|
4
|
+
}
|
|
5
|
+
function gh(args, cwd, env) {
|
|
6
|
+
const bin = which("gh", env);
|
|
7
|
+
if (bin === null)
|
|
8
|
+
throw new GhError("gh: not found on PATH. Install GitHub CLI: https://cli.github.com");
|
|
9
|
+
const result = spawnSync(bin, args, { cwd, env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
10
|
+
if (result.status !== 0) {
|
|
11
|
+
const reason = (result.stderr ?? "").trim().split("\n")[0] || `gh exited with ${result.status ?? result.signal}`;
|
|
12
|
+
throw new GhError(reason);
|
|
13
|
+
}
|
|
14
|
+
return result.stdout;
|
|
15
|
+
}
|
|
16
|
+
function ghJson(args, cwd, env) {
|
|
17
|
+
const out = gh(args, cwd, env);
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(out);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new GhError(`unexpected output from gh ${args.slice(0, 2).join(" ")}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Runs `gh args` with `input` on stdin; used to post a comment without putting its text in argv. */
|
|
26
|
+
function ghInput(args, input, cwd, env) {
|
|
27
|
+
const bin = which("gh", env);
|
|
28
|
+
if (bin === null)
|
|
29
|
+
throw new GhError("gh: not found on PATH. Install GitHub CLI: https://cli.github.com");
|
|
30
|
+
const result = spawnSync(bin, args, { cwd, env, encoding: "utf8", input, maxBuffer: 64 * 1024 * 1024 });
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
const reason = (result.stderr ?? "").trim().split("\n")[0] || `gh exited with ${result.status ?? result.signal}`;
|
|
33
|
+
throw new GhError(reason);
|
|
34
|
+
}
|
|
35
|
+
return result.stdout;
|
|
36
|
+
}
|
|
37
|
+
/** Posts one comment on an issue; pull requests are issues too, but prefer `postPullRequestComment`. */
|
|
38
|
+
export function postIssueComment(number, body, cwd, env) {
|
|
39
|
+
ghInput(["issue", "comment", String(number), "--body-file", "-"], body, cwd, env);
|
|
40
|
+
}
|
|
41
|
+
/** Posts one comment on a pull request conversation. */
|
|
42
|
+
export function postPullRequestComment(number, body, cwd, env) {
|
|
43
|
+
ghInput(["pr", "comment", String(number), "--body-file", "-"], body, cwd, env);
|
|
44
|
+
}
|
|
45
|
+
/** Fetches the current body of issue `issue` in the repository `gh` resolves from `cwd`. */
|
|
46
|
+
export function issueBody(issue, cwd, env) {
|
|
47
|
+
try {
|
|
48
|
+
const { body } = ghJson(["issue", "view", String(issue), "--json", "body"], cwd, env);
|
|
49
|
+
if (typeof body === "string")
|
|
50
|
+
return { body };
|
|
51
|
+
return { error: `Could not fetch issue #${issue}: unexpected output from gh issue view. Run "gdt doctor".` };
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
55
|
+
if (reason.startsWith("gh: not found"))
|
|
56
|
+
return { error: reason };
|
|
57
|
+
return { error: `Could not fetch issue #${issue}: ${reason}. Check the issue number and run "gdt doctor".` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** `owner/name` of the repository `gh` resolves from `cwd`. */
|
|
61
|
+
export function repository(cwd, env) {
|
|
62
|
+
return ghJson(["repo", "view", "--json", "nameWithOwner"], cwd, env).nameWithOwner;
|
|
63
|
+
}
|
|
64
|
+
/** The login `gh` is authenticated as. */
|
|
65
|
+
export function viewer(cwd, env) {
|
|
66
|
+
return ghJson(["api", "user"], cwd, env).login;
|
|
67
|
+
}
|
|
68
|
+
/** The issue body and the open pull requests that close the issue ("Closes #n"). */
|
|
69
|
+
export function issueSnapshot(issue, cwd, env) {
|
|
70
|
+
const data = ghJson(["issue", "view", String(issue), "--json", "body,closedByPullRequestsReferences"], cwd, env);
|
|
71
|
+
return { body: data.body, pullRequests: (data.closedByPullRequestsReferences ?? []).map((pr) => pr.number) };
|
|
72
|
+
}
|
|
73
|
+
/** All comments on an issue or pull request conversation, oldest first. */
|
|
74
|
+
export function comments(repo, number, cwd, env) {
|
|
75
|
+
const pages = ghJson(["api", "--paginate", "--slurp", `repos/${repo}/issues/${number}/comments`], cwd, env);
|
|
76
|
+
return pages.flat().map((c) => ({ id: c.id, author: c.user?.login ?? "", created_at: c.created_at, body: c.body ?? "" }));
|
|
77
|
+
}
|
|
78
|
+
/** Maps GitHub check runs and commit statuses to success, pending or failure; anything unexpected fails. */
|
|
79
|
+
export function checkStates(rollup) {
|
|
80
|
+
const states = {};
|
|
81
|
+
for (const item of rollup) {
|
|
82
|
+
if (item.__typename === "CheckRun") {
|
|
83
|
+
states[item.name] = item.status !== "COMPLETED" ? "pending" : item.conclusion === "SUCCESS" ? "success" : "failure";
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
states[item.context] = item.state === "SUCCESS" ? "success" : item.state === "PENDING" || item.state === "EXPECTED" ? "pending" : "failure";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return states;
|
|
90
|
+
}
|
|
91
|
+
export function pullRequest(number, cwd, env) {
|
|
92
|
+
const data = ghJson(["pr", "view", String(number), "--json", "number,headRefOid,mergeable,statusCheckRollup"], cwd, env);
|
|
93
|
+
const mergeable = data.mergeable === "MERGEABLE" ? "mergeable" : data.mergeable === "CONFLICTING" ? "conflicting" : "unknown";
|
|
94
|
+
return { number: data.number, head: data.headRefOid, mergeable, checks: checkStates(data.statusCheckRollup ?? []) };
|
|
95
|
+
}
|