@stablekernel/pi-background-run 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +452 -59
- package/extension/digestPresets.ts +316 -0
- package/extension/index.ts +2315 -478
- package/package.json +10 -3
- package/skill/digest-config/SKILL.md +117 -0
- package/skill/run-bg/SKILL.md +81 -24
- package/extension/index.test.ts +0 -2569
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipped digest presets for pi-bgrun's opt-in digest scorecards.
|
|
3
|
+
*
|
|
4
|
+
* Pure data: each preset is a POSIX-sh command that receives the job's log
|
|
5
|
+
* path as `$1` and prints a short pass/fail scorecard. Every command ends in
|
|
6
|
+
* `head -N` so output is bounded no matter what the log contains. Presets are
|
|
7
|
+
* consumed via `resolveDigest()`; they only ever run for trust-gated projects
|
|
8
|
+
* that explicitly opted in via the `digest` config section (see
|
|
9
|
+
* extension/index.ts). Nothing here runs unless configured — no built-in
|
|
10
|
+
* pattern guessing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface DigestPreset {
|
|
14
|
+
id: string;
|
|
15
|
+
description: string;
|
|
16
|
+
command: string;
|
|
17
|
+
/**
|
|
18
|
+
* Advisory only — the conventional job `type` this preset is meant for,
|
|
19
|
+
* used by docs and the digest-config skill when scaffolding a config
|
|
20
|
+
* (e.g. `{ "type": "test", "preset": "go-test" }`). It carries NO runtime
|
|
21
|
+
* semantics: a preset entry never selects itself by type; the project's
|
|
22
|
+
* config still declares the `type` on each entry.
|
|
23
|
+
*/
|
|
24
|
+
suggestedType: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DIGEST_PRESETS: DigestPreset[] = [
|
|
28
|
+
{
|
|
29
|
+
id: "go-test",
|
|
30
|
+
description: "Go test output: package ok/FAIL counts + failing test names",
|
|
31
|
+
suggestedType: "test",
|
|
32
|
+
// Count `^ok `/`^FAIL` package lines, then list `--- FAIL: TestX` names
|
|
33
|
+
// (duration suffix stripped). Ends in head.
|
|
34
|
+
command:
|
|
35
|
+
"printf 'pass: %s fail: %s\\n' \"$(grep -c '^ok ' \"$1\")\" \"$(awk '/^FAIL\\t/ {n++} END {print n + 0}' \"$1\")\"; grep '^--- FAIL: ' \"$1\" | sed 's/^--- FAIL: //; s/ (.*//' | sort -u | head -10",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "jest",
|
|
39
|
+
description: "Jest output: Tests/Test Suites summary + failed test names",
|
|
40
|
+
suggestedType: "test",
|
|
41
|
+
// Jest prints `Tests:`/`Test Suites:` summary lines (with or without
|
|
42
|
+
// color) and marks individual failures with `●` (default reporter) or
|
|
43
|
+
// `✕`/`×` (verbose). Strip the leading bullet so names stay readable.
|
|
44
|
+
command:
|
|
45
|
+
'grep -E \'^(Test Suites|Tests):\' "$1"; grep -E \'●|✕|×\' "$1" | awk \'{sub(/^ *[^A-Za-z0-9]*/, ""); if ($0 != "") print}\' | sort -u | head -10',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "pytest",
|
|
49
|
+
description:
|
|
50
|
+
"pytest output: final passed/failed/error summary line + FAILED test ids",
|
|
51
|
+
suggestedType: "test",
|
|
52
|
+
// The short summary line looks like `===== 2 failed, 3 passed in 0.5s ===`;
|
|
53
|
+
// with -rA/-rf each failure also gets a `FAILED tests/test_x.py::test_y` line.
|
|
54
|
+
command:
|
|
55
|
+
"grep -E '^=+ [0-9]+ (passed|failed|error)' \"$1\"; grep '^FAILED ' \"$1\" | awk '{print $2}' | sort -u | head -10",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "junit-xml",
|
|
59
|
+
description: "JUnit XML: <failure>/<error> counts + failing testcase names",
|
|
60
|
+
suggestedType: "test",
|
|
61
|
+
// Count failure/error elements (attributes like failures="0" don't match —
|
|
62
|
+
// they lack the `<`), then pull the enclosing testcase's name attribute.
|
|
63
|
+
// Real pytest --junitxml emits the whole document on ONE line, so the scan
|
|
64
|
+
// is record-based (`RS='<testcase'`, not line-based); it trims each record at
|
|
65
|
+
// `</testcase>` and matches `[[:space:]]name="` so it never picks up
|
|
66
|
+
// `classname="..."` nor a `<failure` from text after the element.
|
|
67
|
+
command:
|
|
68
|
+
'printf \'failures: %s errors: %s\\n\' "$(grep -o \'<failure\' "$1" | wc -l | tr -d \' \')" "$(grep -o \'<error\' "$1" | wc -l | tr -d \' \')"; awk -v RS=\'<testcase\' \'NR>1 { r=$0; e=index(r,"</testcase>"); if (e) r=substr(r,1,e-1); if (match(r,/[[:space:]]name="[^"]*"/)) { n=substr(r,RSTART+7,RLENGTH-8); if (r ~ /<failure|<error/) print n } }\' "$1" | sort -u | head -10',
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export const DIGEST_PRESET_IDS = DIGEST_PRESETS.map((p) => p.id);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Matchers selecting which jobs a digest entry applies to. Both are glob
|
|
76
|
+
* patterns tested against the bgrun job's `name` (optional) and command line
|
|
77
|
+
* respectively. `*` matches any run of characters (including none) and `?`
|
|
78
|
+
* matches exactly one UTF-16 code unit; everything else is literal, and `\`
|
|
79
|
+
* escapes the next character so `\*` / `\?` / `\\` match literally. Matching
|
|
80
|
+
* is case-insensitive and **whole-string** (write `*text*` for a substring).
|
|
81
|
+
* An absent, empty, or blank `match` matches every job.
|
|
82
|
+
*/
|
|
83
|
+
export interface DigestMatch {
|
|
84
|
+
name?: string;
|
|
85
|
+
command?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One scorecard entry in the `digest` config: an optional job `type`, an
|
|
90
|
+
* optional `match`, an optional wake label, and either a shipped preset id or
|
|
91
|
+
* a custom sh command. Config normalizes to an ordered list of these; the
|
|
92
|
+
* first entry that matches a job wins (put the default entry last).
|
|
93
|
+
*
|
|
94
|
+
* `type` and `match` compose (AND): when both are present the entry matches
|
|
95
|
+
* only a job with that exact type that ALSO satisfies the glob `match`. Use
|
|
96
|
+
* `type` for a first-class job type declared at spawn time; use `match` alone
|
|
97
|
+
* as the fallback selector for jobs without a type. An entry with neither
|
|
98
|
+
* selector matches every job.
|
|
99
|
+
*/
|
|
100
|
+
export interface DigestEntry {
|
|
101
|
+
type?: string;
|
|
102
|
+
match?: DigestMatch;
|
|
103
|
+
label?: string;
|
|
104
|
+
preset?: string;
|
|
105
|
+
command?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The job a digest entry is selected against at wake time. `type` is the
|
|
110
|
+
* job's agent-declared type (e.g. "test", "build"), matched exactly
|
|
111
|
+
* (case-insensitively) against type-gated entries before the glob fallback.
|
|
112
|
+
*/
|
|
113
|
+
export interface DigestJobTarget {
|
|
114
|
+
name?: string;
|
|
115
|
+
type?: string;
|
|
116
|
+
command: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A digest entry resolved against a concrete job. */
|
|
120
|
+
export interface SelectedDigest {
|
|
121
|
+
command: string;
|
|
122
|
+
label: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a normalized digest entry into the concrete sh command to run (the
|
|
127
|
+
* job's log path arrives as `$1`). When both `preset` and `command` are
|
|
128
|
+
* configured, the preset wins — a curated, shipped preset is preferred over a
|
|
129
|
+
* hand-rolled command pointing at the same format. Returns undefined when
|
|
130
|
+
* nothing usable is configured.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveDigest(
|
|
133
|
+
digest: { preset?: string; command?: string } | undefined,
|
|
134
|
+
): string | undefined {
|
|
135
|
+
if (!digest) return undefined;
|
|
136
|
+
if (digest.preset) {
|
|
137
|
+
const preset = DIGEST_PRESETS.find((p) => p.id === digest.preset);
|
|
138
|
+
if (preset) return preset.command;
|
|
139
|
+
}
|
|
140
|
+
if (digest.command) return digest.command;
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Case-insensitive, whole-string glob match used by `match.name` /
|
|
146
|
+
* `match.command`. `*` matches any run of characters — including none, and
|
|
147
|
+
* including newlines so a multi-line command still matches — and `?` matches
|
|
148
|
+
* exactly one character. Every other character is literal. The pattern is
|
|
149
|
+
* escaped into a regex here, so no user text can become a regex metacharacter
|
|
150
|
+
* or quantifier: there is no backtracking hazard (ReDoS) and no anchoring
|
|
151
|
+
* ambiguity — the match is always against the whole string.
|
|
152
|
+
*/
|
|
153
|
+
function globMatches(pattern: string, text: string): boolean {
|
|
154
|
+
let re = "^";
|
|
155
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
156
|
+
const ch = pattern[i];
|
|
157
|
+
// `\` escapes the next character, so `\*` / `\?` / `\\` match literally.
|
|
158
|
+
if (ch === "\\" && i + 1 < pattern.length) {
|
|
159
|
+
re += escapeRegexChar(pattern[++i]);
|
|
160
|
+
} else if (ch === "*") {
|
|
161
|
+
re += "[\\s\\S]*";
|
|
162
|
+
} else if (ch === "?") {
|
|
163
|
+
re += "[\\s\\S]";
|
|
164
|
+
} else {
|
|
165
|
+
re += escapeRegexChar(ch);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
re += "$";
|
|
169
|
+
return new RegExp(re, "i").test(text);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Escape one literal character for a RegExp, so it can never be a metacharacter. */
|
|
173
|
+
function escapeRegexChar(ch: string): string {
|
|
174
|
+
return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Does a digest entry's glob `match` apply to this job? No `match` (or an
|
|
179
|
+
* empty one) matches every job. A present `name`/`command` pattern must
|
|
180
|
+
* match; `name` against a job with no name never matches. When both fields are
|
|
181
|
+
* present both must match (AND). Matching is **whole-string** and
|
|
182
|
+
* **case-insensitive** — so `"*unit*"` matches `"unit-tests-run3"`, while a
|
|
183
|
+
* bare `"unit-tests"` matches only exactly that. This helper handles the glob
|
|
184
|
+
* matcher only; `selectDigestEntry` composes it with the entry's `type` gate
|
|
185
|
+
* (both must match).
|
|
186
|
+
*/
|
|
187
|
+
export function entryMatchesJob(
|
|
188
|
+
entry: DigestEntry,
|
|
189
|
+
target: DigestJobTarget,
|
|
190
|
+
): boolean {
|
|
191
|
+
const match = entry.match;
|
|
192
|
+
if (!match) return true;
|
|
193
|
+
if (match.name === undefined && match.command === undefined) return true;
|
|
194
|
+
if (match.name !== undefined) {
|
|
195
|
+
if (target.name === undefined) return false;
|
|
196
|
+
if (!globMatches(match.name, target.name)) return false;
|
|
197
|
+
}
|
|
198
|
+
if (match.command !== undefined) {
|
|
199
|
+
if (!globMatches(match.command, target.command)) return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Terminal label when an entry sets neither `label` nor a `match.name`. */
|
|
205
|
+
function defaultDigestLabel(entry: DigestEntry): string {
|
|
206
|
+
return entry.preset ?? "command";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Wake label derived from a glob pattern: drop the (unescaped) wildcards,
|
|
211
|
+
* unwrap escapes, so `*cargo*` → `cargo` and `e2e-\*` → `e2e-*`.
|
|
212
|
+
*/
|
|
213
|
+
function labelFromMatchName(pattern: string): string {
|
|
214
|
+
let out = "";
|
|
215
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
216
|
+
const ch = pattern[i];
|
|
217
|
+
if (ch === "\\" && i + 1 < pattern.length) out += pattern[++i];
|
|
218
|
+
else if (ch === "*" || ch === "?") continue;
|
|
219
|
+
else out += ch;
|
|
220
|
+
}
|
|
221
|
+
return out.trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* One-line diagnostic for the silent no-digest case: a digest IS configured
|
|
226
|
+
* but no entry selected for this job. The usual causes are a `type` the agent
|
|
227
|
+
* never passes (or spells differently) and a `match` glob that never fires.
|
|
228
|
+
* Pure — the wake path decides whether to log it.
|
|
229
|
+
*/
|
|
230
|
+
export function digestNoMatchWarning(
|
|
231
|
+
target: DigestJobTarget,
|
|
232
|
+
entries: DigestEntry[],
|
|
233
|
+
): string {
|
|
234
|
+
const declaredTypes = [
|
|
235
|
+
...new Set(
|
|
236
|
+
entries.map((e) => e.type).filter((t): t is string => typeof t === "string"),
|
|
237
|
+
),
|
|
238
|
+
];
|
|
239
|
+
const job =
|
|
240
|
+
target.type === undefined
|
|
241
|
+
? target.name === undefined
|
|
242
|
+
? "a job with no type or name"
|
|
243
|
+
: `job name "${target.name}"`
|
|
244
|
+
: `job type "${target.type}"`;
|
|
245
|
+
const types = declaredTypes.length
|
|
246
|
+
? ` — configured types: ${declaredTypes.join(", ")}`
|
|
247
|
+
: "";
|
|
248
|
+
return `[pi-bgrun] digest configured but selected no entry for ${job}${types}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Select the digest entry for a job and resolve it to a concrete command +
|
|
253
|
+
* wake label. Selection order:
|
|
254
|
+
*
|
|
255
|
+
* 1. Type entries first: an entry declaring `type` is eligible only for a
|
|
256
|
+
* job declaring that same type (exact, case-insensitive) AND satisfying
|
|
257
|
+
* the entry's `match` when it has one — `type` and `match` compose (AND).
|
|
258
|
+
* Checked in config order, ahead of every match entry regardless of where
|
|
259
|
+
* it sits in the list. First match wins.
|
|
260
|
+
* 2. Fallback: ordered scan over entries WITHOUT a `type` — `match.name` /
|
|
261
|
+
* `match.command` globs (case-insensitive, whole-string) and no-`match`
|
|
262
|
+
* defaults. First match wins. Jobs with no type therefore behave exactly
|
|
263
|
+
* as before.
|
|
264
|
+
* 3. Nothing matched → undefined (no digest).
|
|
265
|
+
*
|
|
266
|
+
* Label precedence: entry `label` → (type entry) the type string → (match
|
|
267
|
+
* entry) the matched `match.name` → the entry's preset id (or "command"), so a
|
|
268
|
+
* bare preset entry labels the wake `digest (go-test):` instead of the old
|
|
269
|
+
* opaque "project-config". Returns undefined when the list is empty.
|
|
270
|
+
*/
|
|
271
|
+
export function selectDigestEntry(
|
|
272
|
+
entries: DigestEntry[] | undefined,
|
|
273
|
+
target: DigestJobTarget,
|
|
274
|
+
): SelectedDigest | undefined {
|
|
275
|
+
if (!entries) return undefined;
|
|
276
|
+
|
|
277
|
+
// First entry (in the given order) whose `match` passes and which resolves to
|
|
278
|
+
// a command. Shared by the type-first pass and the fallback pass so the
|
|
279
|
+
// matching/resolution rules can never drift between them.
|
|
280
|
+
const pick = (
|
|
281
|
+
candidates: DigestEntry[],
|
|
282
|
+
): { entry: DigestEntry; command: string } | undefined => {
|
|
283
|
+
for (const entry of candidates) {
|
|
284
|
+
if (!entryMatchesJob(entry, target)) continue;
|
|
285
|
+
const command = resolveDigest(entry);
|
|
286
|
+
if (command) return { entry, command };
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// 1. Type-first selection. Only entries declaring a type are eligible here,
|
|
292
|
+
// and only when the job declared one. A `match` on the entry, if any, must
|
|
293
|
+
// also pass. Config order decides ties.
|
|
294
|
+
if (target.type !== undefined) {
|
|
295
|
+
const want = target.type.toLowerCase();
|
|
296
|
+
const hit = pick(entries.filter((e) => e.type?.toLowerCase() === want));
|
|
297
|
+
// A type-matching entry always carries a non-empty type, so `entry.type` is
|
|
298
|
+
// the label fallback — no need for the preset-id default here.
|
|
299
|
+
if (hit?.entry.type) {
|
|
300
|
+
return { command: hit.command, label: hit.entry.label || hit.entry.type };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// 2. Glob/default fallback over entries without a type.
|
|
305
|
+
const hit = pick(entries.filter((e) => !e.type));
|
|
306
|
+
if (hit) {
|
|
307
|
+
const entry = hit.entry;
|
|
308
|
+
const matchLabel =
|
|
309
|
+
entry.match?.name === undefined ? "" : labelFromMatchName(entry.match.name);
|
|
310
|
+
return {
|
|
311
|
+
command: hit.command,
|
|
312
|
+
label: entry.label || matchLabel || defaultDigestLabel(entry),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|