@a2f0/agent-tool 0.1.49 → 0.1.51
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/package.json +1 -1
- package/skills/explicit-state-machines/SKILL.md +186 -0
- package/skills/explicit-state-machines/references/lessons.md +36 -0
- package/skills/explicit-state-machines/references/patterns.md +224 -0
- package/src/cli.ts +4 -1
- package/src/permissions/catalog.ts +1 -2
- package/src/pr/squashMerge.ts +1 -1
- package/src/review/reviewPrompt.ts +1 -1
- package/src/runAgentToolAction.ts +8 -15
- package/src/skills/bundled.ts +9 -1
- package/src/skills/install.ts +17 -10
- package/src/stateMachines/command.ts +226 -0
- package/src/stateMachines/scan.ts +306 -0
- package/src/workflows/pr.ts +9 -10
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic signals of implicit state machines in one source file. Read-only
|
|
3
|
+
* and line-based: it points a reader at files worth reading, it never decides.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
type SignalKind = "flags" | "status";
|
|
7
|
+
|
|
8
|
+
export interface Signal {
|
|
9
|
+
readonly kind: SignalKind;
|
|
10
|
+
/** The flag names, or the status field name. */
|
|
11
|
+
readonly names: string[];
|
|
12
|
+
/** For status signals, the distinct string values assigned. */
|
|
13
|
+
readonly values?: string[];
|
|
14
|
+
/** 1-based lines where the flags are toggled or the status is assigned. */
|
|
15
|
+
readonly lines: number[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FileReport {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly score: number;
|
|
21
|
+
readonly signals: Signal[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Source extensions the scan reads, by the comment syntax their lines use. */
|
|
25
|
+
const LANGUAGES: Record<string, "c" | "hash"> = {
|
|
26
|
+
".ts": "c",
|
|
27
|
+
".tsx": "c",
|
|
28
|
+
".mts": "c",
|
|
29
|
+
".cts": "c",
|
|
30
|
+
".js": "c",
|
|
31
|
+
".jsx": "c",
|
|
32
|
+
".mjs": "c",
|
|
33
|
+
".cjs": "c",
|
|
34
|
+
".swift": "c",
|
|
35
|
+
".kt": "c",
|
|
36
|
+
".kts": "c",
|
|
37
|
+
".go": "c",
|
|
38
|
+
".rs": "c",
|
|
39
|
+
".java": "c",
|
|
40
|
+
".dart": "c",
|
|
41
|
+
".py": "hash",
|
|
42
|
+
".rb": "hash",
|
|
43
|
+
".sh": "hash",
|
|
44
|
+
".bash": "hash",
|
|
45
|
+
".zsh": "hash",
|
|
46
|
+
".exp": "hash",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Interpreters whose extensionless scripts the scan reads, by their shebang. */
|
|
50
|
+
const INTERPRETER = /^(?:sh|bash|zsh|dash|ksh|python[\d.]*|ruby|expect|tclsh[\d.]*)$/;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a first line is a shebang for an interpreter the scan reads:
|
|
54
|
+
* `#!/bin/sh`, `#!/usr/bin/env -S python3 -u`, `#!/usr/bin/expect -f`. Parsed
|
|
55
|
+
* token by token (no backtracking regex) and bounded, since the line is
|
|
56
|
+
* untrusted input of any length.
|
|
57
|
+
*/
|
|
58
|
+
function readsShebang(firstLine: string): boolean {
|
|
59
|
+
if (!firstLine.startsWith("#!")) return false;
|
|
60
|
+
const tokens = firstLine.slice(2, 512).trim().split(/\s+/);
|
|
61
|
+
const base = (token: string | undefined) => token?.slice(token.lastIndexOf("/") + 1) ?? "";
|
|
62
|
+
let index = 0;
|
|
63
|
+
if (base(tokens[0]) === "env") {
|
|
64
|
+
index = 1;
|
|
65
|
+
while (index < tokens.length && (tokens[index]!.startsWith("-") || tokens[index]!.includes("="))) index++;
|
|
66
|
+
}
|
|
67
|
+
return INTERPRETER.test(base(tokens[index]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Largest file the scan reads; bigger tracked files are data or generated. */
|
|
71
|
+
export const MAX_BYTES = 512 * 1024;
|
|
72
|
+
|
|
73
|
+
/** Paths that are generated, vendored, tests, or agent skills rather than application code. */
|
|
74
|
+
const SKIPPED = [
|
|
75
|
+
/(^|\/)(node_modules|vendor|dist|build|out|coverage|Pods|DerivedData|\.next|\.turbo|generated|__generated__)\//,
|
|
76
|
+
/(^|\/)\.(agents|claude|codex|opencode)\//,
|
|
77
|
+
/(^|\/)(__tests__|__mocks__|e2e|fixtures?|test|tests)\//,
|
|
78
|
+
/\.(test|spec|stories|e2e)\.[a-z]+$/,
|
|
79
|
+
/_test\.(go|py)$/,
|
|
80
|
+
/(^|\/)(test_[^/]*|conftest)\.py$/,
|
|
81
|
+
/(Tests?|Spec)\.(swift|kt|java)$/,
|
|
82
|
+
/\.d\.ts$/,
|
|
83
|
+
/\.min\.[a-z]+$/,
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The comment syntax of a source file, from its extension; an extensionless
|
|
88
|
+
* file (`bin/deploy`, `files/functions`) counts when its first line is a
|
|
89
|
+
* shell, Python, Ruby or Expect shebang. Without `firstLine`, extensionless
|
|
90
|
+
* files report "maybe" so a caller knows to read the first line.
|
|
91
|
+
*/
|
|
92
|
+
export function language(file: string, firstLine?: string): "c" | "hash" | "maybe" | undefined {
|
|
93
|
+
if (SKIPPED.some(pattern => pattern.test(file))) return undefined;
|
|
94
|
+
const base = file.slice(file.lastIndexOf("/") + 1);
|
|
95
|
+
const dot = base.lastIndexOf(".");
|
|
96
|
+
if (dot > 0) return LANGUAGES[base.slice(dot).toLowerCase()];
|
|
97
|
+
if (firstLine === undefined) return "maybe";
|
|
98
|
+
return readsShebang(firstLine) ? "hash" : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Names that read as a lifecycle or mode flag rather than a setting: `isLoading`,
|
|
103
|
+
* `hasError`, `_running`, `submitting`, `show_dialog`. Settings such as
|
|
104
|
+
* `darkMode` or `enabled` toggled by a user are usually independent, so a
|
|
105
|
+
* bare `enabled`/`visible` does not count on its own. Nor do `cancelled` and
|
|
106
|
+
* `settled`: effect cleanups and promise races set them once as guards.
|
|
107
|
+
*/
|
|
108
|
+
const FLAG =
|
|
109
|
+
/^_*(?:(?:is|has|was|did|should|show|needs?|can)(?:[A-Z_].*)?|(?:loading|pending|running|started|starting|stopping|stopped|done|finished|complete|completed|ready|busy|saving|submitting|fetching|syncing|connecting|connected|reconnecting|initiali[sz]ed|initiali[sz]ing|cancell?ing|failed|retrying|paused|waiting|polling|booting|editing|reserving|paging|rendered|rendering|unavailable|interrupted|dismissed|opened|published|uploading|downloading|refreshing|processing|in_?progress|inflight|in_?flight)(?:[A-Z_].*)?)$/;
|
|
110
|
+
|
|
111
|
+
/** Field names that hold a mode as a string. */
|
|
112
|
+
const STATUS = /^_*(?:status|state|phase|mode|stage|step|lifecycle|connectionState|syncState|loadState|[a-z]+(?:Status|State|Phase|Stage))$/;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The flag or field an assignment target names: the last member of a chain
|
|
116
|
+
* (`record.steps[i]!.status` -> `status`, `this.#rendered` -> `rendered`), or a
|
|
117
|
+
* React ref's own name (`busy.current` -> `busy`).
|
|
118
|
+
*/
|
|
119
|
+
function targetName(owner: string | undefined, member: string): string {
|
|
120
|
+
const name = member.replace(/^#/, "");
|
|
121
|
+
return name === "current" && owner ? owner : name;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isFlag(name: string): boolean {
|
|
125
|
+
return FLAG.test(name) && !/^(?:is|has|was|did|should|show|needs?|can)$/.test(name);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `setIsLoading` -> `isLoading`; `set_loading` stays as is. */
|
|
129
|
+
function fromSetter(name: string): string {
|
|
130
|
+
return name[0].toLowerCase() + name.slice(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Blanks comments, which often quote code, keeping line numbers. */
|
|
134
|
+
export function codeLines(text: string, syntax: "c" | "hash"): string[] {
|
|
135
|
+
const lines = text.split("\n");
|
|
136
|
+
if (syntax === "hash") return lines.map(line => (line.trimStart().startsWith("#") ? "" : line));
|
|
137
|
+
// String-aware, so "/*" or "//" inside a string literal never hides code.
|
|
138
|
+
// Strings are tracked per line; a template literal spanning lines is rare
|
|
139
|
+
// enough in state code that a lead missed there is acceptable.
|
|
140
|
+
const state = { inBlock: false };
|
|
141
|
+
return lines.map(line => stripComments(line, state));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const QUOTES = new Set(['"', "'", "`"]);
|
|
145
|
+
|
|
146
|
+
/** One line without its comments; `state.inBlock` carries an open block comment to the next line. */
|
|
147
|
+
function stripComments(line: string, state: { inBlock: boolean }): string {
|
|
148
|
+
let out = "";
|
|
149
|
+
let index = 0;
|
|
150
|
+
while (index < line.length) {
|
|
151
|
+
if (state.inBlock) {
|
|
152
|
+
index = blockCommentEnd(line, index, state);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (line.startsWith("/*", index)) {
|
|
156
|
+
state.inBlock = true;
|
|
157
|
+
index += 2;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (line.startsWith("//", index)) break;
|
|
161
|
+
const end = QUOTES.has(line[index]) ? stringEnd(line, index) : index + 1;
|
|
162
|
+
out += line.slice(index, end);
|
|
163
|
+
index = end;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Where an open block comment ends on this line (just past its `*/`), or the line's end.
|
|
169
|
+
function blockCommentEnd(line: string, index: number, state: { inBlock: boolean }): number {
|
|
170
|
+
const close = line.indexOf("*/", index);
|
|
171
|
+
if (close < 0) return line.length;
|
|
172
|
+
state.inBlock = false;
|
|
173
|
+
return close + 2;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Just past the string literal opening at `start`, honoring backslash escapes; the line's end when unterminated. */
|
|
177
|
+
function stringEnd(line: string, start: number): number {
|
|
178
|
+
const quote = line[start];
|
|
179
|
+
let index = start + 1;
|
|
180
|
+
while (index < line.length) {
|
|
181
|
+
if (line[index] === "\\") index += 2;
|
|
182
|
+
else if (line[index] === quote) return index + 1;
|
|
183
|
+
else index++;
|
|
184
|
+
}
|
|
185
|
+
return index;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const BOOLEAN = String.raw`(true|false|True|False)\b`;
|
|
189
|
+
/** An assignment target's last two members: `(owner.)?member`, where the owner may follow any chain. */
|
|
190
|
+
const TARGET = String.raw`(?<![\w$#])(?:([A-Za-z_$][\w$]*)[!?]?\.)?(#?[A-Za-z_$][\w$]*)`;
|
|
191
|
+
/**
|
|
192
|
+
* `x = true`, `this.x = false`, `self._x = True`, `this.#x = true`,
|
|
193
|
+
* `busy.current = true`, `press.moved = true`, `x: false` (object literal),
|
|
194
|
+
* but not `==`/`===`.
|
|
195
|
+
*/
|
|
196
|
+
const ASSIGN = new RegExp(TARGET + String.raw`\s*(?:=(?!=)|:)\s*` + BOOLEAN, "g");
|
|
197
|
+
/** `setIsOpen(true)`, `set_loading(False)`. */
|
|
198
|
+
const SETTER = new RegExp(String.raw`\bset_?([A-Za-z_$][\w$]*)\(\s*` + BOOLEAN, "g");
|
|
199
|
+
/** `const [isOpen, setIsOpen] = useState(false)` names the flag and its first value. */
|
|
200
|
+
const USE_STATE = /\[\s*([A-Za-z_$][\w$]*)\s*,\s*set[A-Za-z_$][\w$]*\s*\]\s*=\s*(?:React\.)?useState(?:<boolean>)?\(\s*(true|false)\s*\)/g;
|
|
201
|
+
/**
|
|
202
|
+
* `status = "loading"`, `this.phase = 'done'`, `setStatus("error")`. Not
|
|
203
|
+
* `status: "idle"`: type members and returned object literals are how an
|
|
204
|
+
* explicit machine names its states, not scattered writes.
|
|
205
|
+
*/
|
|
206
|
+
const STATUS_ASSIGN = new RegExp(TARGET + String.raw`\s*=(?!=)\s*(["'\`])([\w-]+)\3`, "g");
|
|
207
|
+
/** Tcl and Expect: `set busy true`, `set ::done 0`, `set status running`, `set status "done"`. */
|
|
208
|
+
const TCL_FLAG = /^\s*set\s+(?:::)?([A-Za-z_][\w]*)\s+(true|false|1|0|yes|no)\s*$/;
|
|
209
|
+
const TCL_STATUS = /^\s*set\s+(?:::)?([A-Za-z_][\w]*)\s+"?([A-Za-z][\w-]*)"?\s*$/;
|
|
210
|
+
const TCL_TRUE = new Set(["true", "1", "yes"]);
|
|
211
|
+
const STATUS_SETTER = /\bset_?([A-Za-z_$][\w$]*)\(\s*(["'`])([\w-]+)\2\s*\)/g;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Signals in one file:
|
|
215
|
+
* - `flags`: two or more lifecycle-style booleans that are each set both true
|
|
216
|
+
* and false, so their combinations encode one mode with impossible states.
|
|
217
|
+
* - `status`: one string field assigned three or more distinct literal values
|
|
218
|
+
* from several places, so its transitions are scattered.
|
|
219
|
+
*/
|
|
220
|
+
export function scanText(text: string, syntax: "c" | "hash"): Signal[] {
|
|
221
|
+
const flags: Writes = new Map();
|
|
222
|
+
const statuses: Writes = new Map();
|
|
223
|
+
codeLines(text, syntax).forEach((line, index) => {
|
|
224
|
+
const at = index + 1;
|
|
225
|
+
noteFlagWrites(line, at, flags);
|
|
226
|
+
noteStatusAssigns(line, at, statuses);
|
|
227
|
+
if (syntax === "hash") noteTclWrites(line, at, flags, statuses);
|
|
228
|
+
noteStatusSetters(line, at, statuses);
|
|
229
|
+
});
|
|
230
|
+
return signalsFrom(flags, statuses);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The values each flag or status field is written with, and the lines that write it. */
|
|
234
|
+
type Writes = Map<string, { values: Set<string>; lines: Set<number> }>;
|
|
235
|
+
|
|
236
|
+
function note(map: Writes, name: string, value: string, line: number): void {
|
|
237
|
+
const entry = map.get(name) ?? { values: new Set<string>(), lines: new Set<number>() };
|
|
238
|
+
entry.values.add(value);
|
|
239
|
+
entry.lines.add(line);
|
|
240
|
+
map.set(name, entry);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `useState` booleans, `x = true` assignments and `setX(false)` calls. */
|
|
244
|
+
function noteFlagWrites(line: string, at: number, flags: Writes): void {
|
|
245
|
+
for (const match of line.matchAll(USE_STATE)) if (isFlag(match[1])) note(flags, match[1], match[2], at);
|
|
246
|
+
for (const match of line.matchAll(ASSIGN)) {
|
|
247
|
+
const name = targetName(match[1], match[2]);
|
|
248
|
+
if (isFlag(name)) note(flags, name, match[3].toLowerCase(), at);
|
|
249
|
+
}
|
|
250
|
+
for (const match of line.matchAll(SETTER)) {
|
|
251
|
+
const name = fromSetter(match[1]);
|
|
252
|
+
if (isFlag(name)) note(flags, name, match[2].toLowerCase(), at);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function noteStatusAssigns(line: string, at: number, statuses: Writes): void {
|
|
257
|
+
for (const match of line.matchAll(STATUS_ASSIGN)) {
|
|
258
|
+
const name = targetName(match[1], match[2]);
|
|
259
|
+
if (STATUS.test(name)) note(statuses, name, match[4], at);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Tcl's `set name value`: a flag when the value is boolean, otherwise a status. */
|
|
264
|
+
function noteTclWrites(line: string, at: number, flags: Writes, statuses: Writes): void {
|
|
265
|
+
const flag = TCL_FLAG.exec(line);
|
|
266
|
+
if (flag && isFlag(flag[1])) note(flags, flag[1], String(TCL_TRUE.has(flag[2])), at);
|
|
267
|
+
const status = flag ? undefined : TCL_STATUS.exec(line);
|
|
268
|
+
if (status && STATUS.test(status[1])) note(statuses, status[1], status[2], at);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function noteStatusSetters(line: string, at: number, statuses: Writes): void {
|
|
272
|
+
for (const match of line.matchAll(STATUS_SETTER)) {
|
|
273
|
+
const name = fromSetter(match[1]);
|
|
274
|
+
if (STATUS.test(name)) note(statuses, name, match[3], at);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function signalsFrom(flags: Writes, statuses: Writes): Signal[] {
|
|
279
|
+
const signals: Signal[] = [];
|
|
280
|
+
const toggled = [...flags].filter(([, entry]) => entry.values.size === 2);
|
|
281
|
+
if (toggled.length >= 2) {
|
|
282
|
+
signals.push({
|
|
283
|
+
kind: "flags",
|
|
284
|
+
names: toggled.map(([name]) => name).sort(),
|
|
285
|
+
lines: [...new Set(toggled.flatMap(([, entry]) => [...entry.lines]))].sort((a, b) => a - b),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
for (const [name, entry] of statuses) {
|
|
289
|
+
if (entry.values.size >= 3 && entry.lines.size >= 3) {
|
|
290
|
+
signals.push({ kind: "status", names: [name], values: [...entry.values].sort(), lines: [...entry.lines].sort((a, b) => a - b) });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return signals;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Toggled flags weigh most: each one doubles the combinations a reader must rule out. */
|
|
297
|
+
function score(signals: Signal[]): number {
|
|
298
|
+
return signals.reduce((total, signal) => total + (signal.kind === "flags" ? 2 * signal.names.length : signal.values!.length), 0);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function scanFile(file: string, text: string): FileReport | undefined {
|
|
302
|
+
const syntax = language(file, text.split("\n", 1)[0]);
|
|
303
|
+
if (!syntax || syntax === "maybe" || text.length > MAX_BYTES || text.includes("\u0000")) return undefined;
|
|
304
|
+
const signals = scanText(text, syntax);
|
|
305
|
+
return signals.length ? { path: file, score: score(signals), signals } : undefined;
|
|
306
|
+
}
|
package/src/workflows/pr.ts
CHANGED
|
@@ -89,18 +89,17 @@ export function preflight(expectedHead: string, baseBranch: string, baseOid: str
|
|
|
89
89
|
/**
|
|
90
90
|
* Merge a reviewed HEAD only after every preflight gate passes against the
|
|
91
91
|
* freshly fetched live base: a skipped, failed, or stale preflight run never
|
|
92
|
-
* leads to a merge.
|
|
93
|
-
* merge
|
|
92
|
+
* leads to a merge. The reviewed HEAD and base branch are required, so no
|
|
93
|
+
* merge path skips preflight.
|
|
94
94
|
*/
|
|
95
|
-
export function mergeReviewedHead(rootDir: string, subject
|
|
96
|
-
|
|
95
|
+
export function mergeReviewedHead(rootDir: string, subject: string | undefined, expectedHead: string, baseBranch: string): number {
|
|
96
|
+
assert(!!expectedHead && !!baseBranch, "Merge requires a reviewed HEAD and a base branch.");
|
|
97
97
|
const identity = resolvePr();
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return squashMerge(rootDir, subject, expectedHead, branch);
|
|
98
|
+
const baseOid = resolveFreshBaseRef(identity.repo, baseBranch);
|
|
99
|
+
try { preflight(expectedHead, baseBranch, baseOid); }
|
|
100
|
+
catch (error) { throw new Error(`Refusing to merge: preflight failed against live ${baseBranch} ${baseOid}: ${(error as Error).message}`); }
|
|
101
|
+
// Bind the merge to the branch that passed preflight, so a retarget in between is refused.
|
|
102
|
+
return squashMerge(rootDir, subject, expectedHead, baseBranch);
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
interface Comment { id: string; databaseId: number; body: string; url: string; author: { login: string } | null }
|