@elalfner/relevo 0.0.0-stage → 0.3.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/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/NOTICE +51 -0
- package/README.es.md +90 -0
- package/README.md +89 -2
- package/agents/dev.md +101 -0
- package/agents/qa.md +112 -0
- package/package.json +42 -4
- package/scripts/add-corrections.mjs +21 -0
- package/scripts/app-id.mjs +19 -0
- package/scripts/archive-state.mjs +31 -0
- package/scripts/branch-state.mjs +31 -0
- package/scripts/branch-work.mjs +25 -0
- package/scripts/can-edit-issue.mjs +45 -0
- package/scripts/capture.mjs +22 -0
- package/scripts/check-archive.mjs +19 -0
- package/scripts/check-issue.mjs +41 -0
- package/scripts/checklist.mjs +29 -0
- package/scripts/cli.mjs +28 -0
- package/scripts/commit-change.mjs +50 -0
- package/scripts/conflict-scope.mjs +19 -0
- package/scripts/design-fetch.mjs +103 -0
- package/scripts/device.mjs +12 -0
- package/scripts/digest.mjs +50 -0
- package/scripts/doctor.mjs +47 -0
- package/scripts/fix-guard.mjs +38 -0
- package/scripts/fix-stash.mjs +57 -0
- package/scripts/fix-state.mjs +28 -0
- package/scripts/fix-tasks.mjs +22 -0
- package/scripts/go-local.mjs +49 -0
- package/scripts/go-plan.mjs +82 -0
- package/scripts/init.mjs +109 -0
- package/scripts/intent-to-add.mjs +20 -0
- package/scripts/issue-coverage.mjs +30 -0
- package/scripts/issue-hash.mjs +44 -0
- package/scripts/issue-sections.mjs +42 -0
- package/scripts/label-change.mjs +35 -0
- package/scripts/lib/appid.mjs +12 -0
- package/scripts/lib/archive-check.mjs +29 -0
- package/scripts/lib/archive-state.mjs +15 -0
- package/scripts/lib/branch-work.mjs +13 -0
- package/scripts/lib/branch.mjs +43 -0
- package/scripts/lib/can-edit-issue.mjs +34 -0
- package/scripts/lib/checklist.mjs +68 -0
- package/scripts/lib/cli.mjs +82 -0
- package/scripts/lib/commit-change.mjs +57 -0
- package/scripts/lib/config.mjs +296 -0
- package/scripts/lib/design-fetch.mjs +156 -0
- package/scripts/lib/device.mjs +67 -0
- package/scripts/lib/doctor.mjs +614 -0
- package/scripts/lib/execution-mode.mjs +24 -0
- package/scripts/lib/fix-guard.mjs +23 -0
- package/scripts/lib/fix-stash.mjs +17 -0
- package/scripts/lib/fix.mjs +31 -0
- package/scripts/lib/github.mjs +161 -0
- package/scripts/lib/go-local.mjs +102 -0
- package/scripts/lib/init.mjs +324 -0
- package/scripts/lib/intent-to-add.mjs +32 -0
- package/scripts/lib/issue-check.mjs +63 -0
- package/scripts/lib/issue-coverage.mjs +47 -0
- package/scripts/lib/issue-hash.mjs +25 -0
- package/scripts/lib/issue-parse.mjs +194 -0
- package/scripts/lib/issue-sections.mjs +43 -0
- package/scripts/lib/labels.mjs +65 -0
- package/scripts/lib/language.mjs +9 -0
- package/scripts/lib/logaudit.mjs +123 -0
- package/scripts/lib/messages.mjs +328 -0
- package/scripts/lib/next-step.mjs +144 -0
- package/scripts/lib/openspec-commands.mjs +44 -0
- package/scripts/lib/openspec-status.mjs +16 -0
- package/scripts/lib/plan.mjs +256 -0
- package/scripts/lib/platform-of.mjs +32 -0
- package/scripts/lib/platform-scope.mjs +32 -0
- package/scripts/lib/platforms.mjs +90 -0
- package/scripts/lib/preflight.mjs +71 -0
- package/scripts/lib/protected-paths.mjs +102 -0
- package/scripts/lib/requeue.mjs +23 -0
- package/scripts/lib/review-adapters.mjs +37 -0
- package/scripts/lib/review-brief.mjs +43 -0
- package/scripts/lib/review-events.mjs +56 -0
- package/scripts/lib/review-state.mjs +157 -0
- package/scripts/lib/reviewers.mjs +39 -0
- package/scripts/lib/role-skills.mjs +112 -0
- package/scripts/lib/run-flags.mjs +16 -0
- package/scripts/lib/run-usage.mjs +65 -0
- package/scripts/lib/scan.mjs +73 -0
- package/scripts/lib/ship.mjs +161 -0
- package/scripts/lib/sla.mjs +90 -0
- package/scripts/lib/stack-profile.mjs +169 -0
- package/scripts/lib/staging.mjs +21 -0
- package/scripts/lib/status-data.mjs +109 -0
- package/scripts/lib/status.mjs +199 -0
- package/scripts/lib/tasks-file.mjs +81 -0
- package/scripts/lib/web-review.mjs +98 -0
- package/scripts/lib/yaml-lite.mjs +157 -0
- package/scripts/needs-reviewer.mjs +41 -0
- package/scripts/next-step.mjs +39 -0
- package/scripts/pick-reviewer.mjs +42 -0
- package/scripts/platform-of.mjs +49 -0
- package/scripts/preflight.mjs +32 -0
- package/scripts/render-message.mjs +35 -0
- package/scripts/requeue.mjs +53 -0
- package/scripts/review-adapter.mjs +32 -0
- package/scripts/review-brief.mjs +29 -0
- package/scripts/review-event.mjs +70 -0
- package/scripts/review-sla.mjs +41 -0
- package/scripts/review-state.mjs +171 -0
- package/scripts/review-who.mjs +21 -0
- package/scripts/role-skills.mjs +53 -0
- package/scripts/run-usage.mjs +22 -0
- package/scripts/scan-diff.mjs +16 -0
- package/scripts/ship-check.mjs +54 -0
- package/scripts/spec-ready.mjs +40 -0
- package/scripts/stack-profile.mjs +67 -0
- package/scripts/stage.mjs +14 -0
- package/scripts/status.mjs +47 -0
- package/scripts/usage-limit.mjs +17 -0
- package/scripts/validate-config.mjs +44 -0
- package/scripts/web-review.mjs +134 -0
- package/skills/doctor/SKILL.md +28 -0
- package/skills/fix/SKILL.md +128 -0
- package/skills/go/SKILL.md +242 -0
- package/skills/init/SKILL.md +72 -0
- package/skills/issue/SKILL.md +128 -0
- package/skills/issue/references/update.md +15 -0
- package/skills/review/SKILL.md +157 -0
- package/skills/ship/SKILL.md +128 -0
- package/skills/status/SKILL.md +27 -0
- package/skills/think/SKILL.md +76 -0
- package/stacks/flutter.md +63 -0
- package/stacks/laravel.md +65 -0
- package/stacks/vue.md +70 -0
- package/templates/issue.md +99 -0
- package/templates/relevo.yml +59 -0
- package/templates/workflows/relevo-digest.yml +54 -0
- package/templates/workflows/relevo-go.yml +263 -0
- package/templates/workflows/relevo-queue.yml +72 -0
- package/templates/workflows/relevo-review.yml +60 -0
- package/templates/workflows/relevo-sla.yml +53 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// A short brief for the person who reviews a built issue, computed from the issue so the model does not rewrite it.
|
|
2
|
+
// Implements "The review starts with a short brief" of openspec/specs/review/spec.md.
|
|
3
|
+
|
|
4
|
+
import { parseIssueBody } from './issue-parse.mjs';
|
|
5
|
+
import { section } from './issue-sections.mjs';
|
|
6
|
+
|
|
7
|
+
export const MAX_SENTENCES = 2;
|
|
8
|
+
export const MAX_LINE = 160;
|
|
9
|
+
|
|
10
|
+
const LABELS = {
|
|
11
|
+
en: { asked: 'Asked', outcome: 'Outcome', first: 'Check first', count: (s, e) => `To check: ${s} scenario(s), ${e} edge case(s)` },
|
|
12
|
+
es: { asked: 'Se pidió', outcome: 'Resultado', first: 'Revisa primero', count: (s, e) => `Por revisar: ${s} escenario(s), ${e} caso(s) borde` },
|
|
13
|
+
};
|
|
14
|
+
const RISK_RE = /(?:Riskiest area, check it first|Zona más riesgosa, revísala primero):\s*(.+)/;
|
|
15
|
+
|
|
16
|
+
const clean = (text) => String(text ?? '').replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim();
|
|
17
|
+
const cap = (text) => (text.length > MAX_LINE ? `${text.slice(0, MAX_LINE - 1).trimEnd()}…` : text);
|
|
18
|
+
|
|
19
|
+
/** Up to `max` sentences of a text. */
|
|
20
|
+
export function firstSentences(text, max = MAX_SENTENCES) {
|
|
21
|
+
const t = clean(text);
|
|
22
|
+
if (!t) return '';
|
|
23
|
+
return t.split(/(?<=[.!?])\s+/).slice(0, max).join(' ');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* issue { number, title, body }
|
|
28
|
+
* built the text of the `built` comment, when there is one (the risk line is taken from it)
|
|
29
|
+
* Returns the brief as a string of about five lines.
|
|
30
|
+
*/
|
|
31
|
+
export function reviewBrief({ issue, built = '', language = 'en' }) {
|
|
32
|
+
const L = LABELS[language === 'es' ? 'es' : 'en'];
|
|
33
|
+
const parsed = parseIssueBody(issue.body);
|
|
34
|
+
const lines = [`#${issue.number} ${clean(issue.title)}`];
|
|
35
|
+
const asked = firstSentences(section(issue.body, 'Summary')?.text);
|
|
36
|
+
if (asked) lines.push(`${L.asked}: ${asked}`);
|
|
37
|
+
const outcome = clean(section(issue.body, 'Outcome')?.text);
|
|
38
|
+
if (outcome) lines.push(`${L.outcome}: ${firstSentences(outcome, 1)}`);
|
|
39
|
+
const risk = String(built).match(RISK_RE)?.[1];
|
|
40
|
+
if (risk) lines.push(`${L.first}: ${clean(risk)}`);
|
|
41
|
+
lines.push(L.count(parsed.scenarios, parsed.edgeCases));
|
|
42
|
+
return lines.map(cap).join('\n');
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Keeps the lifecycle label in step with pull request reviews. Implements "Review events update the label"
|
|
2
|
+
// of openspec/specs/issue-lifecycle/spec.md. Pure: data in, decision out.
|
|
3
|
+
//
|
|
4
|
+
// event { kind: 'review', state: 'changes_requested' | 'approved' | 'commented' | ..., reviewer, body, url }
|
|
5
|
+
// { kind: 'push' }
|
|
6
|
+
// pr { number, url, author }
|
|
7
|
+
// issue { number, labels: [names] }
|
|
8
|
+
|
|
9
|
+
import { resolveLabelNames, transition } from './labels.mjs';
|
|
10
|
+
|
|
11
|
+
const SUMMARY_LENGTH = 300;
|
|
12
|
+
|
|
13
|
+
function summarise(body, es) {
|
|
14
|
+
const text = String(body ?? '').trim().replace(/\s+/g, ' ');
|
|
15
|
+
if (!text) return es ? 'Revisa los comentarios en el pull request.' : 'See the comments on the pull request.';
|
|
16
|
+
return text.length > SUMMARY_LENGTH ? `${text.slice(0, SUMMARY_LENGTH)}…` : text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Returns { action, labels: { add, remove }, comment } or null when the event changes nothing. */
|
|
20
|
+
export function planReviewEvent({ config, event, pr, issue }) {
|
|
21
|
+
if (config.review.mode === 'none') return null;
|
|
22
|
+
const names = resolveLabelNames(config.labels);
|
|
23
|
+
const has = (key) => issue.labels.includes(names[key]);
|
|
24
|
+
const es = String(config.language ?? 'en').startsWith('es');
|
|
25
|
+
|
|
26
|
+
if (event.kind === 'push') {
|
|
27
|
+
if (!has('changes-requested')) return null;
|
|
28
|
+
return { action: 'back-to-pr-open', labels: transition(issue.labels, 'pr-open', config.labels), comment: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (event.kind !== 'review' || event.reviewer === pr.author) return null;
|
|
32
|
+
|
|
33
|
+
if (event.state === 'changes_requested') {
|
|
34
|
+
if (!has('pr-open') && !has('changes-requested')) return null;
|
|
35
|
+
return {
|
|
36
|
+
action: 'changes-requested',
|
|
37
|
+
labels: transition(issue.labels, 'changes-requested', config.labels),
|
|
38
|
+
comment: {
|
|
39
|
+
kind: 'changes_requested',
|
|
40
|
+
data: { author: pr.author, issue: issue.number, reviewer: event.reviewer, summary: summarise(event.body, es), pr: event.url ?? pr.url },
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (event.state === 'approved') {
|
|
46
|
+
if (!has('pr-open')) return null;
|
|
47
|
+
// Who runs `ship`: the approver with merger "approver", the author with merger "author".
|
|
48
|
+
const recipient = config.review.merger === 'author' ? pr.author : event.reviewer;
|
|
49
|
+
return {
|
|
50
|
+
action: 'approved',
|
|
51
|
+
labels: { add: [], remove: [] },
|
|
52
|
+
comment: { kind: 'approved', data: { recipient, issue: issue.number, reviewer: event.reviewer, pr: pr.url } },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// State of one on-device review, and the rule that decides whether a pull request may be opened.
|
|
2
|
+
// Implements "Pull request only after verification" of openspec/specs/review/spec.md.
|
|
3
|
+
|
|
4
|
+
export const STATUSES = ['pending', 'ok', 'fail'];
|
|
5
|
+
|
|
6
|
+
const same = (a, b) => a.class === b.class && a.excerpt === b.excerpt;
|
|
7
|
+
|
|
8
|
+
export const AUTOPILOT_MODES = ['off', 'precheck', 'verify'];
|
|
9
|
+
|
|
10
|
+
export function newState(items, { autopilot = 'off' } = {}) {
|
|
11
|
+
if (!AUTOPILOT_MODES.includes(autopilot)) throw new Error(`autopilot must be one of ${AUTOPILOT_MODES.join(', ')}`);
|
|
12
|
+
return {
|
|
13
|
+
autopilot,
|
|
14
|
+
envConfirmed: false,
|
|
15
|
+
items: items.map((i) => ({ id: i.id, title: i.title, status: 'pending', note: null, precheck: null, auto: null })),
|
|
16
|
+
captures: [],
|
|
17
|
+
audit: null,
|
|
18
|
+
waivers: [],
|
|
19
|
+
agreed: false,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Waive the n-th finding of the audit (numbered from 1, as the audit prints them) with a written reason.
|
|
25
|
+
* Only the person calls this; an inconclusive audit and an empty reason are refused.
|
|
26
|
+
* Returns the state with the verdict recomputed.
|
|
27
|
+
*/
|
|
28
|
+
export function waiveFinding(state, number, reason) {
|
|
29
|
+
const why = String(reason ?? '').trim();
|
|
30
|
+
if (!state.audit) throw new Error('the log audit has not run: there is nothing to waive');
|
|
31
|
+
if (state.audit.verdict === 'REVIEW INCONCLUSIVE') throw new Error('an inconclusive audit cannot be waived: restart the app and repeat the steps');
|
|
32
|
+
if (!why) throw new Error('a waiver needs a written reason: waive <number> <reason>');
|
|
33
|
+
const finding = state.audit.findings[Number(number) - 1];
|
|
34
|
+
if (!finding) throw new Error(`there is no finding ${number} in the audit (it has ${state.audit.findings.length})`);
|
|
35
|
+
state.waivers ??= [];
|
|
36
|
+
if (!state.waivers.some((w) => same(w, finding))) state.waivers.push({ class: finding.class, excerpt: finding.excerpt, reason: why });
|
|
37
|
+
return applyWaivers(state);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Mark the findings that match a waiver and recompute the verdict: all waived is "REVIEW PASSED (with waivers)". */
|
|
41
|
+
export function applyWaivers(state) {
|
|
42
|
+
const audit = state.audit;
|
|
43
|
+
if (!audit || audit.verdict === 'REVIEW INCONCLUSIVE') return state;
|
|
44
|
+
audit.findings.forEach((f) => {
|
|
45
|
+
const w = (state.waivers ?? []).find((x) => same(x, f));
|
|
46
|
+
if (w) {
|
|
47
|
+
f.waived = true;
|
|
48
|
+
f.reason = w.reason;
|
|
49
|
+
} else {
|
|
50
|
+
delete f.waived;
|
|
51
|
+
delete f.reason;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
if (audit.findings.length > 0) audit.verdict = audit.findings.every((f) => f.waived) ? 'REVIEW PASSED (with waivers)' : 'REVIEW REJECTED';
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The markdown section of the pull request body and the report for the waived findings; empty when there are none. */
|
|
59
|
+
export function waivedSection(state) {
|
|
60
|
+
const waived = (state.audit?.findings ?? []).filter((f) => f.waived);
|
|
61
|
+
if (waived.length === 0) return '';
|
|
62
|
+
return ['## Waived findings', '', ...waived.map((f) => `- **${f.class}**: \`${f.excerpt.replace(/`/g, "'")}\` — waived by the reviewer: ${f.reason}`), ''].join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reconcile the checklist after a fix changed the specification and the code.
|
|
67
|
+
* Lines that were confirmed stay confirmed; lines that failed go back to pending so they are tested again;
|
|
68
|
+
* new scenarios are added as pending. The log audit and the person's agreement no longer apply to the new code.
|
|
69
|
+
*/
|
|
70
|
+
export function syncState(state, items) {
|
|
71
|
+
const before = new Map(state.items.map((i) => [i.title, i]));
|
|
72
|
+
state.items = items.map((i) => {
|
|
73
|
+
const old = before.get(i.title);
|
|
74
|
+
// A line confirmed by the person stays; one the agent verified automatically does not: the code under it changed.
|
|
75
|
+
const keep = old && old.status === 'ok' && !old.auto;
|
|
76
|
+
return { id: i.id, title: i.title, status: keep ? 'ok' : 'pending', note: null, precheck: null, auto: null };
|
|
77
|
+
});
|
|
78
|
+
state.audit = null;
|
|
79
|
+
state.waivers = [];
|
|
80
|
+
state.agreed = false;
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function setItem(state, id, status, note = null) {
|
|
85
|
+
if (!STATUSES.includes(status)) throw new Error(`status must be one of ${STATUSES.join(', ')}`);
|
|
86
|
+
const item = state.items.find((i) => i.id === id);
|
|
87
|
+
if (!item) throw new Error(`there is no checklist item ${id}`);
|
|
88
|
+
item.status = status;
|
|
89
|
+
item.note = note;
|
|
90
|
+
// What the person decides replaces what the agent recorded: a confirmation is theirs, a failure or a retest starts again.
|
|
91
|
+
item.auto = null;
|
|
92
|
+
if (status !== 'ok') item.precheck = null;
|
|
93
|
+
return state;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const driving = (state) => {
|
|
97
|
+
const mode = state.autopilot ?? 'off';
|
|
98
|
+
if (mode === 'off') throw new Error('the agent does not drive the review: review.autopilot is "off"');
|
|
99
|
+
if (!state.envConfirmed) throw new Error('the person has not confirmed in writing that the app points to non-production data: run "confirm-env" after they do');
|
|
100
|
+
return mode;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** The person confirmed that the app under review does not point to production data. */
|
|
104
|
+
export function confirmEnv(state) {
|
|
105
|
+
state.envConfirmed = true;
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The agent's own result for a line, apart from the person's confirmation. Modes precheck and verify only. */
|
|
110
|
+
export function precheckItem(state, id, result, note = null) {
|
|
111
|
+
driving(state);
|
|
112
|
+
if (!['ok', 'fail'].includes(result)) throw new Error('a pre-check result is ok or fail');
|
|
113
|
+
const item = state.items.find((i) => i.id === id);
|
|
114
|
+
if (!item) throw new Error(`there is no checklist item ${id}`);
|
|
115
|
+
item.precheck = result;
|
|
116
|
+
if (note) item.note = note;
|
|
117
|
+
return state;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The agent marks a line as verified automatically, saying how. Mode verify only; the line becomes ok but stays labelled. */
|
|
121
|
+
export function autoItem(state, id, how) {
|
|
122
|
+
const mode = driving(state);
|
|
123
|
+
if (mode !== 'verify') throw new Error(`a line can be verified automatically only when review.autopilot is "verify" (it is "${mode}")`);
|
|
124
|
+
const why = String(how ?? '').trim();
|
|
125
|
+
if (!why) throw new Error('say how the line was verified: auto <number> <the assertion, finder or log that proved it>');
|
|
126
|
+
const item = state.items.find((i) => i.id === id);
|
|
127
|
+
if (!item) throw new Error(`there is no checklist item ${id}`);
|
|
128
|
+
item.status = 'ok';
|
|
129
|
+
item.precheck = 'ok';
|
|
130
|
+
item.auto = why;
|
|
131
|
+
return state;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The "Verified automatically" section of the pull request body; empty when no line was. */
|
|
135
|
+
export function autoSection(state) {
|
|
136
|
+
const lines = state.items.filter((i) => i.auto);
|
|
137
|
+
if (lines.length === 0) return '';
|
|
138
|
+
return ['## Verified automatically', '', ...lines.map((i) => `- ${i.id}. ${i.title}: ${i.auto}`), ''].join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* May the pull request be opened? Only when the log audit passed, every checklist line is confirmed
|
|
143
|
+
* working by the person, and the person agrees. Returns { open, reasons }.
|
|
144
|
+
*/
|
|
145
|
+
export function decidePr(state) {
|
|
146
|
+
const reasons = [];
|
|
147
|
+
if (!state.audit) reasons.push('the log audit has not run');
|
|
148
|
+
else if (state.audit.verdict === 'REVIEW INCONCLUSIVE') reasons.push('the log audit was inconclusive: it had no log of the app to judge, so it proves nothing');
|
|
149
|
+
else if (!['REVIEW PASSED', 'REVIEW PASSED (with waivers)'].includes(state.audit.verdict)) reasons.push('the log audit found problems');
|
|
150
|
+
state.items.forEach((i) => {
|
|
151
|
+
if (i.status === 'pending') reasons.push(`checklist item ${i.id} ("${i.title}") is not confirmed yet`);
|
|
152
|
+
if (i.status === 'fail') reasons.push(`checklist item ${i.id} ("${i.title}") failed${i.note ? `: ${i.note}` : ''}`);
|
|
153
|
+
});
|
|
154
|
+
if (state.items.length === 0) reasons.push('there are no scenarios to verify');
|
|
155
|
+
if (!state.agreed) reasons.push('the person has not agreed to open the pull request');
|
|
156
|
+
return { open: reasons.length === 0, reasons };
|
|
157
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Chooses who reviews a pull request.
|
|
2
|
+
// Implements "Reviewer selection" of openspec/specs/issue-lifecycle/spec.md.
|
|
3
|
+
|
|
4
|
+
import { resolveLabelNames } from './labels.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* members team.members
|
|
8
|
+
* specWriter team.spec_writer (never reviews)
|
|
9
|
+
* author whoever opens the pull request (never reviews their own)
|
|
10
|
+
* approvers team.approvers (only used when mode is "roles")
|
|
11
|
+
* mode review.mode
|
|
12
|
+
* openCounts { login: number of open review requests }
|
|
13
|
+
* prNumber breaks ties in rotation
|
|
14
|
+
* Returns a login, or null when nobody is eligible.
|
|
15
|
+
*/
|
|
16
|
+
export function pickReviewer({ members, specWriter = null, author, approvers = [], mode = 'peer', openCounts = {}, prNumber = 0 }) {
|
|
17
|
+
const eligible = members.filter((m) => m !== author && m !== specWriter && (mode !== 'roles' || approvers.includes(m)));
|
|
18
|
+
if (eligible.length === 0) return null;
|
|
19
|
+
const load = (m) => openCounts[m] ?? 0;
|
|
20
|
+
const least = Math.min(...eligible.map(load));
|
|
21
|
+
const ties = eligible.filter((m) => load(m) === least).sort();
|
|
22
|
+
return ties[prNumber % ties.length];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to do when nobody is eligible: label the issue `needs-reviewer` next to its state and mention the spec writer.
|
|
27
|
+
* Returns { labels: { add, remove }, comment } (comment is null when there is no spec writer to mention).
|
|
28
|
+
*/
|
|
29
|
+
export function noReviewerDecision({ config, issue }) {
|
|
30
|
+
const names = resolveLabelNames(config.labels);
|
|
31
|
+
const already = issue.labels?.includes(names['needs-reviewer']);
|
|
32
|
+
const specWriter = config.team.spec_writer;
|
|
33
|
+
return {
|
|
34
|
+
labels: { add: already ? [] : [names['needs-reviewer']], remove: [] },
|
|
35
|
+
comment: specWriter
|
|
36
|
+
? { kind: 'needs_reviewer', data: { spec_writer: specWriter, issue: issue.number, title: issue.title, pr: issue.pr } }
|
|
37
|
+
: null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Extra skills per role. Implements the "role-skills" specification: a skill is given to an agent only when it
|
|
2
|
+
// is trustworthy, and the reason a skill was left out is always reported.
|
|
3
|
+
//
|
|
4
|
+
// `git(args) -> stdout` is injected (it throws when the command fails) so the logic is tested without a repository.
|
|
5
|
+
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { ROLES } from './stack-profile.mjs';
|
|
8
|
+
|
|
9
|
+
export const SKILL_DIRS = ['.agents/skills', '.claude/skills'];
|
|
10
|
+
export const SIZE_BUDGET_BYTES = 20 * 1024;
|
|
11
|
+
const SCRIPT_EXTENSIONS = ['.sh', '.bash', '.js', '.mjs', '.cjs', '.ts', '.py', '.rb', '.php', '.ps1'];
|
|
12
|
+
const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
13
|
+
|
|
14
|
+
/** The roles whose lists apply to `role`: `fix` uses those of `dev` and `qa`. */
|
|
15
|
+
export const rolesFor = (role) => (role === 'fix' ? ['dev', 'qa'] : [role]);
|
|
16
|
+
|
|
17
|
+
/** Does a skill folder hold anything that can run? `files` are paths relative to the folder. */
|
|
18
|
+
export function hasScripts(files) {
|
|
19
|
+
return files.some((f) => {
|
|
20
|
+
const parts = f.split('/');
|
|
21
|
+
return parts.slice(0, -1).includes('scripts') || SCRIPT_EXTENSIONS.some((ext) => parts[parts.length - 1].toLowerCase().endsWith(ext));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Does the frontmatter of a SKILL.md reserve the skill for the person? */
|
|
26
|
+
export function reservedForPerson(skillMd) {
|
|
27
|
+
const head = String(skillMd ?? '').replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---/);
|
|
28
|
+
return head ? /^disable-model-invocation:\s*true\s*$/m.test(head[1]) : false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Entries of `git ls-tree -r` for a path: [{ mode, path }]. */
|
|
32
|
+
function listTree(git, ref, dir) {
|
|
33
|
+
let out;
|
|
34
|
+
try {
|
|
35
|
+
out = git(['ls-tree', '-r', ref, '--', dir]);
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return String(out).split('\n').filter(Boolean).map((line) => {
|
|
40
|
+
const [meta, path] = line.split('\t');
|
|
41
|
+
return { mode: meta.split(' ')[0], path };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decide whether one skill can be given to an agent.
|
|
47
|
+
* Returns { name, path, scripts } when it can, or { name, left_out: true, reason }.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveSkill({ name, git, baseRef, allowScripts = [], dirs = SKILL_DIRS }) {
|
|
50
|
+
const out = (reason) => ({ name, left_out: true, reason });
|
|
51
|
+
if (!NAME_RE.test(name)) return out('invalid name');
|
|
52
|
+
|
|
53
|
+
let sawLink = false;
|
|
54
|
+
for (const dir of dirs) {
|
|
55
|
+
const folder = `${dir}/${name}`;
|
|
56
|
+
const entries = listTree(git, baseRef, folder);
|
|
57
|
+
if (entries.length === 0) continue;
|
|
58
|
+
if (entries.some((e) => e.mode === '120000')) {
|
|
59
|
+
sawLink = true;
|
|
60
|
+
continue; // a link is followed only through the folder it points to, which is one of `dirs`
|
|
61
|
+
}
|
|
62
|
+
const files = entries.map((e) => e.path.slice(folder.length + 1));
|
|
63
|
+
const skillMd = `${folder}/SKILL.md`;
|
|
64
|
+
if (!files.includes('SKILL.md')) return out('no SKILL.md on the base branch');
|
|
65
|
+
let text;
|
|
66
|
+
try {
|
|
67
|
+
text = git(['show', `${baseRef}:${skillMd}`]);
|
|
68
|
+
} catch {
|
|
69
|
+
return out('no SKILL.md on the base branch');
|
|
70
|
+
}
|
|
71
|
+
if (reservedForPerson(text)) return out('reserved for the person');
|
|
72
|
+
const scripts = hasScripts(files);
|
|
73
|
+
if (scripts && !allowScripts.includes(name)) return out('has scripts and is not allowed');
|
|
74
|
+
return { name, path: skillMd, scripts };
|
|
75
|
+
}
|
|
76
|
+
return out(sawLink ? 'is a link to a folder that is not on the base branch' : 'not on the base branch');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Resolve the skills of a role (or of `fix`) from a resolved profile. */
|
|
80
|
+
export function resolveRoleSkills({ role, profile, git, baseRef, allowScripts = [], dirs = SKILL_DIRS }) {
|
|
81
|
+
if (![...ROLES, 'fix'].includes(role)) throw new Error(`unknown role "${role}"; allowed: ${[...ROLES, 'fix'].join(', ')}`);
|
|
82
|
+
const names = [...new Set(rolesFor(role).flatMap((r) => profile.skills[r] ?? []))];
|
|
83
|
+
return names.map((name) => resolveSkill({ name, git, baseRef, allowScripts, dirs }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Sum of the sizes (bytes) of the skills handed to one role, and whether it is over the budget. */
|
|
87
|
+
export function skillBudget(sizes) {
|
|
88
|
+
const bytes = sizes.reduce((a, b) => a + b, 0);
|
|
89
|
+
return { bytes, over: bytes > SIZE_BUDGET_BYTES };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The hash `skills-lock.json` keeps for a skill folder: SHA-256 over its files, sorted by path without regard to
|
|
94
|
+
* case, each contributing its relative path and then its content. `files` is [{ path, content }].
|
|
95
|
+
* (Found by reproducing the `computedHash` of a lock file written by the skills installer.)
|
|
96
|
+
*/
|
|
97
|
+
export function folderHash(files) {
|
|
98
|
+
const h = createHash('sha256');
|
|
99
|
+
[...files].sort((a, b) => (a.path.toLowerCase() < b.path.toLowerCase() ? -1 : a.path.toLowerCase() > b.path.toLowerCase() ? 1 : 0)).forEach((f) => {
|
|
100
|
+
h.update(f.path);
|
|
101
|
+
h.update(f.content);
|
|
102
|
+
});
|
|
103
|
+
return h.digest('hex');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Names of the skills whose folder hash differs from the one in the lock file. `hashes` maps name to hash (or null when the folder is absent). */
|
|
107
|
+
export function lockDifferences(lock, hashes) {
|
|
108
|
+
const skills = lock && typeof lock === 'object' && lock.skills && typeof lock.skills === 'object' ? lock.skills : {};
|
|
109
|
+
return Object.entries(skills)
|
|
110
|
+
.filter(([name, entry]) => hashes[name] !== undefined && hashes[name] !== null && entry?.computedHash && hashes[name] !== entry.computedHash)
|
|
111
|
+
.map(([name]) => name);
|
|
112
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The limits and the closed tool lists of an unattended run, written once. Implements "The limits of a run are
|
|
2
|
+
// written once" of the go specification: the local run (`go-local.mjs`) uses this function and a test compares its
|
|
3
|
+
// values with the `claude_args` of templates/workflows/relevo-go.yml, which people copy and so keeps literal values.
|
|
4
|
+
|
|
5
|
+
export const ALLOWED_TOOLS = 'Bash,Read,Write,Edit,Glob,Grep,Task';
|
|
6
|
+
export const DISALLOWED_TOOLS = 'WebFetch,WebSearch,Bash(curl:*),Bash(wget:*)';
|
|
7
|
+
|
|
8
|
+
/** The `claude -p` arguments that bound a run: turns, cost, and the closed lists of tools. */
|
|
9
|
+
export function runFlags(config) {
|
|
10
|
+
return [
|
|
11
|
+
'--max-turns', String(config.limits.run_max_turns),
|
|
12
|
+
'--max-budget-usd', String(config.limits.run_max_cost),
|
|
13
|
+
'--allowedTools', ALLOWED_TOOLS,
|
|
14
|
+
'--disallowedTools', DISALLOWED_TOOLS,
|
|
15
|
+
];
|
|
16
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// What a cloud run used, read from the execution log of the agent runner. Implements "The consumption of a run is
|
|
2
|
+
// visible" of the execution-modes specification.
|
|
3
|
+
//
|
|
4
|
+
// The format of that log is not documented (only its path is an output of the action), so everything here is
|
|
5
|
+
// tolerant: a JSON array, JSON lines or a single object are accepted, the last entry that looks like a result is
|
|
6
|
+
// used, and anything missing is simply left out. Nothing found means nothing is said, never an error.
|
|
7
|
+
|
|
8
|
+
function entries(text) {
|
|
9
|
+
const t = String(text ?? '').trim();
|
|
10
|
+
if (!t) return [];
|
|
11
|
+
try {
|
|
12
|
+
const whole = JSON.parse(t);
|
|
13
|
+
return Array.isArray(whole) ? whole : [whole];
|
|
14
|
+
} catch {
|
|
15
|
+
return t.split('\n').map((line) => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(line);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}).filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
26
|
+
|
|
27
|
+
/** { turns, cost, tokens, cached } with null for what the log does not say, or null when it says none of them. */
|
|
28
|
+
export function readUsage(text) {
|
|
29
|
+
const all = entries(text).filter((e) => e && typeof e === 'object');
|
|
30
|
+
const result = [...all].reverse().find((e) => e.type === 'result') ?? [...all].reverse().find((e) => num(e.num_turns) !== null);
|
|
31
|
+
if (!result) return null;
|
|
32
|
+
const input = num(result.usage?.input_tokens);
|
|
33
|
+
const output = num(result.usage?.output_tokens);
|
|
34
|
+
const usage = {
|
|
35
|
+
turns: num(result.num_turns),
|
|
36
|
+
cost: num(result.total_cost_usd),
|
|
37
|
+
tokens: input !== null || output !== null ? (input ?? 0) + (output ?? 0) : null,
|
|
38
|
+
// Tokens read from or written to the prompt cache are reported apart: they are most of a long run and the
|
|
39
|
+
// plain input/output figure alone looks far smaller than what the run really processed.
|
|
40
|
+
cached: num(result.usage?.cache_read_input_tokens) !== null || num(result.usage?.cache_creation_input_tokens) !== null
|
|
41
|
+
? (num(result.usage?.cache_read_input_tokens) ?? 0) + (num(result.usage?.cache_creation_input_tokens) ?? 0)
|
|
42
|
+
: null,
|
|
43
|
+
};
|
|
44
|
+
return usage.turns === null && usage.cost === null && usage.tokens === null ? null : usage;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One line for a comment, or an empty string when there is nothing to say. */
|
|
48
|
+
export function usageLine(usage, { subscription = false } = {}) {
|
|
49
|
+
if (!usage) return '';
|
|
50
|
+
const parts = [];
|
|
51
|
+
if (usage.turns !== null) parts.push(`${usage.turns} turn${usage.turns === 1 ? '' : 's'}`);
|
|
52
|
+
if (usage.cost !== null) parts.push(`about $${usage.cost.toFixed(2)}${subscription ? ' (notional: a subscription is not billed per run)' : ''}`);
|
|
53
|
+
if (usage.tokens !== null) parts.push(`${usage.tokens.toLocaleString('en-US')} tokens${usage.cached ? ` (plus ${usage.cached.toLocaleString('en-US')} from the cache)` : ''}`);
|
|
54
|
+
return parts.length ? `This run used ${parts.join(', ')}.` : '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Messages the Claude CLI and the API document for a usage limit, a quota or an empty credit balance. These patterns
|
|
58
|
+
// are not from a failed run seen here (see docs/pilot-findings.md); a wrong guess only changes the wording of a
|
|
59
|
+
// comment on a run that already failed.
|
|
60
|
+
const LIMIT_RE = /usage limit|limit reached|rate[ _-]?limit|credit balance is too low|exceeded your (current )?quota|insufficient[ _]quota/i;
|
|
61
|
+
|
|
62
|
+
/** Does the log show that a usage limit, a quota or the credit was reached? */
|
|
63
|
+
export function reachedUsageLimit(text) {
|
|
64
|
+
return LIMIT_RE.test(String(text ?? ''));
|
|
65
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Scans for temporary markers and credentials.
|
|
2
|
+
// Used before every commit, push and merge, and when validating relevo.yml.
|
|
3
|
+
// Findings never include the secret itself, only what kind of secret it looks like.
|
|
4
|
+
|
|
5
|
+
const MARKER_RE = /(\/\/|#|<!--|\/\*)\s*(NOCOMMIT|TEMP|DO NOT COMMIT)\b/;
|
|
6
|
+
|
|
7
|
+
const CREDENTIAL_PATTERNS = [
|
|
8
|
+
{ name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{10,}/ },
|
|
9
|
+
{ name: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}/ },
|
|
10
|
+
{ name: 'github-fine-grained-token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/ },
|
|
11
|
+
{ name: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
12
|
+
{ name: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
13
|
+
{ name: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
14
|
+
{
|
|
15
|
+
name: 'assigned-secret',
|
|
16
|
+
re: /\b(api[_-]?key|secret|token|password|passwd)\b["']?\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{16,}/i,
|
|
17
|
+
},
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// A long unbroken token is only suspicious in a configuration value, not in code.
|
|
21
|
+
const OPAQUE_TOKEN_RE = /^[A-Za-z0-9+/_-]{40,}$/;
|
|
22
|
+
|
|
23
|
+
/** Returns [{ name }] for every credential-looking pattern in `text`. */
|
|
24
|
+
export function findCredentials(text, { strict = false } = {}) {
|
|
25
|
+
const found = CREDENTIAL_PATTERNS.filter(({ re }) => re.test(text)).map(({ name }) => ({ name }));
|
|
26
|
+
if (strict && OPAQUE_TOKEN_RE.test(text.trim())) found.push({ name: 'opaque-token' });
|
|
27
|
+
return found;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Returns the marker found in a line of text, or null. */
|
|
31
|
+
export function findMarker(text) {
|
|
32
|
+
const m = text.match(MARKER_RE);
|
|
33
|
+
return m ? m[2] : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Scan a unified diff (for example `git diff --cached`). Only added lines are checked.
|
|
38
|
+
* Returns [{ file, line, kind: 'marker' | 'credential', detail }].
|
|
39
|
+
*/
|
|
40
|
+
export function scanDiff(diffText) {
|
|
41
|
+
const findings = [];
|
|
42
|
+
let file = null;
|
|
43
|
+
let line = 0;
|
|
44
|
+
String(diffText).split('\n').forEach((raw) => {
|
|
45
|
+
if (raw.startsWith('+++ ')) {
|
|
46
|
+
const target = raw.slice(4).trim();
|
|
47
|
+
file = target === '/dev/null' ? null : target.replace(/^b\//, '');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (raw.startsWith('--- ') || raw.startsWith('diff --git') || raw.startsWith('index ')) return;
|
|
51
|
+
const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
52
|
+
if (hunk) {
|
|
53
|
+
line = Number.parseInt(hunk[1], 10);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (raw.startsWith('+')) {
|
|
57
|
+
const added = raw.slice(1);
|
|
58
|
+
const marker = findMarker(added);
|
|
59
|
+
if (marker) findings.push({ file, line, kind: 'marker', detail: marker });
|
|
60
|
+
findCredentials(added).forEach(({ name }) => findings.push({ file, line, kind: 'credential', detail: name }));
|
|
61
|
+
line += 1;
|
|
62
|
+
} else if (raw.startsWith(' ')) {
|
|
63
|
+
line += 1;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
return findings;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatFindings(findings) {
|
|
70
|
+
return findings
|
|
71
|
+
.map((f) => `${f.file ?? '(unknown file)'}:${f.line} ${f.kind === 'marker' ? 'temporary marker' : 'possible credential'} (${f.detail})`)
|
|
72
|
+
.join('\n');
|
|
73
|
+
}
|