@saadnvd1/agent-os 0.3.658 → 0.3.660
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/lib/agents/launch.ts +2 -1
- package/lib/agents/tool-path.ts +61 -0
- package/lib/orchestrator/external-gates.ts +6 -1
- package/lib/orchestrator/review-prompt.ts +1 -1
- package/lib/orchestrator/review.ts +6 -2
- package/lib/project-config/agentos.schema.json +25 -0
- package/lib/project-config/brief.ts +14 -0
- package/lib/project-config/index.ts +6 -0
- package/lib/project-config/schema.ts +21 -0
- package/lib/resurrect/index.ts +2 -2
- package/lib/tasks/brief.ts +20 -5
- package/lib/tasks/gh.ts +2 -0
- package/lib/tasks/import.ts +2 -2
- package/lib/tasks/index.ts +2 -2
- package/lib/tasks/move.ts +2 -2
- package/lib/tasks/state.ts +3 -0
- package/lib/tasks/ui-brief.ts +85 -0
- package/lib/tasks/ui-change.ts +57 -0
- package/package.json +1 -1
package/lib/agents/launch.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { runInBackground } from "../async-operations";
|
|
|
10
10
|
import { trustPromptKeys } from "../tasks/state";
|
|
11
11
|
import { BUS_BRIEF } from "./brief";
|
|
12
12
|
import { selfUrl } from "./self-env";
|
|
13
|
+
import { sessionPath } from "./tool-path";
|
|
13
14
|
import { autoCompactEnv } from "../chat/refresh";
|
|
14
15
|
import { subagentEnv } from "../models/subagents";
|
|
15
16
|
import { COMPACT_BRIEF } from "../chat/working-state";
|
|
@@ -43,7 +44,7 @@ export function agentEnv(sessionId: string): Record<string, string> {
|
|
|
43
44
|
...subagentEnv(),
|
|
44
45
|
AGENTOS_SESSION_ID: sessionId,
|
|
45
46
|
AGENTOS_URL: selfUrl(),
|
|
46
|
-
PATH: `${AOS_BIN_DIR}:${
|
|
47
|
+
PATH: `${AOS_BIN_DIR}:${sessionPath()}`,
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
// Where Saad's language version managers and user-level tools put their
|
|
6
|
+
// binaries, in the order they should win. A shell's rc files normally add
|
|
7
|
+
// these; a session AgentOS starts doesn't read them, so `bin/rails` would
|
|
8
|
+
// find the system Ruby. Built in code so it is fast and the same on the Mac
|
|
9
|
+
// and the Linux box (missing dirs are skipped).
|
|
10
|
+
const USER_TOOL_DIRS = [
|
|
11
|
+
".rbenv/shims",
|
|
12
|
+
".local/share/mise/shims",
|
|
13
|
+
".asdf/shims",
|
|
14
|
+
".pyenv/shims",
|
|
15
|
+
".local/bin",
|
|
16
|
+
".bun/bin",
|
|
17
|
+
".cargo/bin",
|
|
18
|
+
];
|
|
19
|
+
const SYSTEM_TOOL_DIRS = ["/opt/homebrew/bin", "/opt/homebrew/sbin"];
|
|
20
|
+
|
|
21
|
+
function isDir(p: string): boolean {
|
|
22
|
+
try {
|
|
23
|
+
return fs.statSync(p).isDirectory();
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// `npm run` puts every node_modules/.bin from the server's directory upward on
|
|
30
|
+
// PATH; for a session in another repo those would shadow the project's tools.
|
|
31
|
+
function serverBinDirs(cwd: string): Set<string> {
|
|
32
|
+
const dirs = new Set<string>();
|
|
33
|
+
for (let d = path.resolve(cwd); ; d = path.dirname(d)) {
|
|
34
|
+
dirs.add(path.join(d, "node_modules", ".bin"));
|
|
35
|
+
if (path.dirname(d) === d) break;
|
|
36
|
+
}
|
|
37
|
+
return dirs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sessionPath(
|
|
41
|
+
inherited: string | undefined = process.env.PATH,
|
|
42
|
+
home: string = os.homedir(),
|
|
43
|
+
cwd: string = process.cwd()
|
|
44
|
+
): string {
|
|
45
|
+
const drop = serverBinDirs(cwd);
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
const add = (dir: string) => {
|
|
49
|
+
if (!dir || seen.has(dir)) return;
|
|
50
|
+
seen.add(dir);
|
|
51
|
+
out.push(dir);
|
|
52
|
+
};
|
|
53
|
+
for (const d of [
|
|
54
|
+
...USER_TOOL_DIRS.map((d) => path.join(home, d)),
|
|
55
|
+
...SYSTEM_TOOL_DIRS,
|
|
56
|
+
])
|
|
57
|
+
if (isDir(d)) add(d);
|
|
58
|
+
for (const d of (inherited ?? "").split(path.delimiter))
|
|
59
|
+
if (!drop.has(d)) add(d);
|
|
60
|
+
return out.join(path.delimiter);
|
|
61
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* does carry is held to the same rules as a task's.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { hasUiEvidence } from "../tasks/ui-change";
|
|
13
14
|
import { MERGED_AS, mergePolicy } from "../tasks/merge-policy";
|
|
14
15
|
import { mergePR } from "../tasks/merge-pr";
|
|
15
16
|
import { attributionIn, codeReviewGate } from "../tasks/code-review";
|
|
@@ -171,7 +172,11 @@ export async function reviewExternal(
|
|
|
171
172
|
goal: `${ext.title}\n\n${ext.body}`.slice(0, GOAL_CAP),
|
|
172
173
|
repo: ext.repo,
|
|
173
174
|
refs: refsOf(ext),
|
|
174
|
-
pr: {
|
|
175
|
+
pr: {
|
|
176
|
+
number: ext.number,
|
|
177
|
+
url: ext.url,
|
|
178
|
+
uiEvidence: hasUiEvidence(ext.body),
|
|
179
|
+
},
|
|
175
180
|
},
|
|
176
181
|
ext.pr.head,
|
|
177
182
|
opts
|
|
@@ -7,7 +7,7 @@ import type { ChangedFile } from "./diff";
|
|
|
7
7
|
|
|
8
8
|
export const REVIEW_SYSTEM = `You are an independent code reviewer. You did not write this change and you owe its author nothing. You review one pull request at one exact commit, checked out read-only in your working directory. You can read the checkout's files; you cannot edit anything or run commands.
|
|
9
9
|
|
|
10
|
-
Look for what would be wrong if this merged: bugs, broken behaviour, missing error handling on real paths, security problems (secrets, injection, unsafe shell or SQL), data loss, and changes that don't do what the task asked. Style and taste are never blocking.
|
|
10
|
+
Look for what would be wrong if this merged: bugs, broken behaviour, missing error handling on real paths, security problems (secrets, injection, unsafe shell or SQL), data loss, and changes that don't do what the task asked. Style and taste are never blocking, and neither is a missing screenshot or browser or simulator check: AgentOS notes that itself.
|
|
11
11
|
|
|
12
12
|
A change that games a gate instead of passing it is always blocking: tests deleted, skipped or weakened, assertions loosened or changed to match wrong output, checks, lint rules or CI steps disabled or bypassed, or anything else that makes a failure stop showing rather than stop happening. Name what was weakened, at its file and line.
|
|
13
13
|
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* the orchestrator hears the verdict as an event.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { uiEvidenceNote, withUiEvidenceNote } from "../tasks/ui-change";
|
|
14
15
|
import { reviewGoal } from "../plans/features";
|
|
15
16
|
import { pushReviewBlocked, pushReviewReady } from "../notify/push/events";
|
|
16
17
|
import { db, type Session } from "../db";
|
|
@@ -89,7 +90,7 @@ export interface ReviewTarget {
|
|
|
89
90
|
goal: string;
|
|
90
91
|
repo: string;
|
|
91
92
|
refs: Pick<Session, "base_branch" | "branch_name">;
|
|
92
|
-
pr: Pick<TaskPR, "number" | "url" | "scopeChange">;
|
|
93
|
+
pr: Pick<TaskPR, "number" | "url" | "scopeChange" | "uiEvidence">;
|
|
93
94
|
task?: Session;
|
|
94
95
|
}
|
|
95
96
|
|
|
@@ -192,7 +193,10 @@ async function reviewJob(
|
|
|
192
193
|
const done = putCheck({
|
|
193
194
|
...row,
|
|
194
195
|
kind: "review",
|
|
195
|
-
...
|
|
196
|
+
...withUiEvidenceNote(
|
|
197
|
+
combineVerdicts(verdicts),
|
|
198
|
+
uiEvidenceNote(files, pr.uiEvidence)
|
|
199
|
+
),
|
|
196
200
|
});
|
|
197
201
|
if (task?.lh_card_id)
|
|
198
202
|
await checkScope({
|
|
@@ -142,6 +142,31 @@
|
|
|
142
142
|
"port": {
|
|
143
143
|
"type": "string",
|
|
144
144
|
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
|
|
145
|
+
},
|
|
146
|
+
"viewports": {
|
|
147
|
+
"minItems": 1,
|
|
148
|
+
"maxItems": 4,
|
|
149
|
+
"type": "array",
|
|
150
|
+
"items": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"pattern": "^\\d{3,4}x\\d{3,4}$"
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
"nav": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"propertyNames": {
|
|
158
|
+
"type": "string",
|
|
159
|
+
"minLength": 1,
|
|
160
|
+
"maxLength": 40
|
|
161
|
+
},
|
|
162
|
+
"additionalProperties": {
|
|
163
|
+
"type": "string",
|
|
164
|
+
"minLength": 1,
|
|
165
|
+
"maxLength": 200
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"verify": {
|
|
169
|
+
"type": "boolean"
|
|
145
170
|
}
|
|
146
171
|
},
|
|
147
172
|
"additionalProperties": false
|
|
@@ -50,6 +50,20 @@ export function runningBrief(
|
|
|
50
50
|
lines.push(
|
|
51
51
|
`- \`${browse.map}\` maps every page to its URL: navigate by URL, don't explore.`
|
|
52
52
|
);
|
|
53
|
+
const pages = Object.entries(browse?.nav ?? {});
|
|
54
|
+
if (pages.length)
|
|
55
|
+
lines.push(
|
|
56
|
+
`- Pages: ${pages
|
|
57
|
+
.map(([name, to]) => {
|
|
58
|
+
const url = /^https?:\/\//.test(to)
|
|
59
|
+
? to
|
|
60
|
+
: port
|
|
61
|
+
? `http://localhost:${port}${to.startsWith("/") ? "" : "/"}${to}`
|
|
62
|
+
: to;
|
|
63
|
+
return `${name} \`${withPorts(url, ports)}\``;
|
|
64
|
+
})
|
|
65
|
+
.join(", ")}.`
|
|
66
|
+
);
|
|
53
67
|
if (browse)
|
|
54
68
|
lines.push(
|
|
55
69
|
"- Check state with the browser's text snapshot, not screenshots. Screenshot only the final changed screen, and just the changed element (its uid) rather than the full page where the tool allows; every image stays in your context."
|
|
@@ -200,3 +200,9 @@ export function projectEnv(
|
|
|
200
200
|
env[name] = String(port);
|
|
201
201
|
return env;
|
|
202
202
|
}
|
|
203
|
+
|
|
204
|
+
export const DEFAULT_VIEWPORTS = ["1440x900", "390x844"];
|
|
205
|
+
|
|
206
|
+
// The sizes a UI change is checked at: the project's, else desktop and phone.
|
|
207
|
+
export const viewportsOf = (browse: ProjectConfig["browse"]): string[] =>
|
|
208
|
+
browse?.viewports?.length ? browse.viewports : DEFAULT_VIEWPORTS;
|
|
@@ -67,6 +67,27 @@ const browse = z.strictObject({
|
|
|
67
67
|
login: z.string().min(1).optional(),
|
|
68
68
|
map: projectPath.optional(),
|
|
69
69
|
port: envName.optional(),
|
|
70
|
+
// Screen sizes a UI change is checked at, "WIDTHxHEIGHT".
|
|
71
|
+
viewports: z
|
|
72
|
+
.array(
|
|
73
|
+
z
|
|
74
|
+
.string()
|
|
75
|
+
.regex(/^\d{3,4}x\d{3,4}$/, "must be WIDTHxHEIGHT, like 390x844")
|
|
76
|
+
)
|
|
77
|
+
.min(1)
|
|
78
|
+
.max(4)
|
|
79
|
+
.optional(),
|
|
80
|
+
// A short page name to path map, for the pages worth knowing by heart.
|
|
81
|
+
nav: z
|
|
82
|
+
.record(z.string().min(1).max(40), z.string().min(1).max(200))
|
|
83
|
+
.refine(
|
|
84
|
+
(n) => Object.keys(n).length <= 15,
|
|
85
|
+
"at most 15 pages; use map for more"
|
|
86
|
+
)
|
|
87
|
+
.optional(),
|
|
88
|
+
// Force the browser-check section of a task's brief on or off; otherwise
|
|
89
|
+
// the project's files decide (lib/tasks/ui-brief.ts).
|
|
90
|
+
verify: z.boolean().optional(),
|
|
70
91
|
});
|
|
71
92
|
|
|
72
93
|
// An iOS simulator tasks may drive on a Mac (lib/simulator). `start` runs in
|
package/lib/resurrect/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
transcriptPath,
|
|
34
34
|
writeTranscript,
|
|
35
35
|
} from "../tasks/transcript";
|
|
36
|
-
import { buildTaskBrief,
|
|
36
|
+
import { buildTaskBrief, briefFacts } from "../tasks/brief";
|
|
37
37
|
import { syncTaskCardInBackground } from "../lumifyhub/task-cards";
|
|
38
38
|
import { allocatePorts } from "../ports";
|
|
39
39
|
import { loadProjectConfig, portBases } from "../project-config";
|
|
@@ -440,7 +440,7 @@ async function relaunch(s: Session, cwd: string): Promise<void> {
|
|
|
440
440
|
? buildTaskBrief({
|
|
441
441
|
branch: s.branch_name,
|
|
442
442
|
baseBranch: s.base_branch ?? "main",
|
|
443
|
-
|
|
443
|
+
...briefFacts(cwd),
|
|
444
444
|
})
|
|
445
445
|
: undefined,
|
|
446
446
|
});
|
package/lib/tasks/brief.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { loadProjectConfig } from "../project-config";
|
|
1
|
+
import { loadProjectConfig, type ProjectConfig } from "../project-config";
|
|
2
|
+
import { uiKindOf, uiVerifyBrief, type UiKind } from "./ui-brief";
|
|
2
3
|
|
|
3
4
|
// A task stacked on another task's unmerged branch.
|
|
4
5
|
export interface StackedOn {
|
|
@@ -21,9 +22,18 @@ function stackedLines(baseBranch: string, stack: StackedOn): string {
|
|
|
21
22
|
- Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. AgentOS reviews your PR itself; a review of your head still counts for the head its restack left, and a push after that gets a new one.${also}`;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
//
|
|
25
|
-
export function
|
|
26
|
-
|
|
25
|
+
// What a brief needs from the checkout's config, from one read.
|
|
26
|
+
export function briefFacts(cwd: string): {
|
|
27
|
+
test?: string;
|
|
28
|
+
ui: UiKind | null;
|
|
29
|
+
browse: ProjectConfig["browse"];
|
|
30
|
+
} {
|
|
31
|
+
const { config } = loadProjectConfig(cwd);
|
|
32
|
+
return {
|
|
33
|
+
test: config.test || undefined,
|
|
34
|
+
ui: uiKindOf(cwd, config),
|
|
35
|
+
browse: config.browse,
|
|
36
|
+
};
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
function checksLine(test?: string): string {
|
|
@@ -40,8 +50,11 @@ export function buildTaskBrief(opts: {
|
|
|
40
50
|
stack?: StackedOn;
|
|
41
51
|
// The project's `test` from agentos.json, when it declares one.
|
|
42
52
|
test?: string;
|
|
53
|
+
// Whether the project has a UI to check, and how it is browsed.
|
|
54
|
+
ui?: UiKind | null;
|
|
55
|
+
browse?: ProjectConfig["browse"];
|
|
43
56
|
}): string {
|
|
44
|
-
|
|
57
|
+
const base = `You are running as an AgentOS background task, alone, in an isolated git worktree.
|
|
45
58
|
|
|
46
59
|
- Branch: ${opts.branch} (based on ${opts.baseBranch}). Stay in this directory and on this branch.
|
|
47
60
|
- Do the task fully. ${checksLine(opts.test)}
|
|
@@ -51,4 +64,6 @@ export function buildTaskBrief(opts: {
|
|
|
51
64
|
- Write scratch files (PR bodies, review notes, logs, test output) outside the worktree, in your scratchpad or \$TMPDIR. Anything uncommitted left in the worktree keeps it from being removed when the task is done.
|
|
52
65
|
- Do not add any AI or assistant attribution to commits or the pull request.
|
|
53
66
|
- If you are blocked or need a decision only a human can make, print one line starting with "BLOCKED:" that says what you need, then stop and wait.`;
|
|
67
|
+
const ui = uiVerifyBrief(opts.ui ?? null, opts.browse);
|
|
68
|
+
return ui ? `${base}\n\n${ui}` : base;
|
|
54
69
|
}
|
package/lib/tasks/gh.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from "child_process";
|
|
2
|
+
import { hasUiEvidence } from "./ui-change";
|
|
2
3
|
import { promisify } from "util";
|
|
3
4
|
import { git } from "../git-exec";
|
|
4
5
|
import { attributionIn } from "./code-review";
|
|
@@ -118,6 +119,7 @@ export async function toTaskPR(repoDir: string, pr: ListedPR): Promise<TaskPR> {
|
|
|
118
119
|
attribution:
|
|
119
120
|
pr.body === undefined ? undefined : (attributionIn(pr.body) ?? null),
|
|
120
121
|
scopeChange: parseScopeChange(pr.body),
|
|
122
|
+
...(pr.body !== undefined && { uiEvidence: hasUiEvidence(pr.body) }),
|
|
121
123
|
...(pr.mergeable && { mergeable: pr.mergeable }),
|
|
122
124
|
...(pr.baseRefName && { base: pr.baseRefName }),
|
|
123
125
|
};
|
package/lib/tasks/import.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { resolveModelForAgent } from "../model-catalog";
|
|
|
23
23
|
import { providerFor } from "../models/resolve";
|
|
24
24
|
import { preparePlanDir, PLAN_FILE } from "../models/plan-step";
|
|
25
25
|
import { carryTaskState } from "./move-carry";
|
|
26
|
-
import { buildTaskBrief,
|
|
26
|
+
import { buildTaskBrief, briefFacts } from "./brief";
|
|
27
27
|
import { expandHome } from "./session";
|
|
28
28
|
import { ensureProject } from "./project-ref";
|
|
29
29
|
import { checkoutBranchWorktree } from "./branch-worktree";
|
|
@@ -141,7 +141,7 @@ async function arrive(
|
|
|
141
141
|
const brief = buildTaskBrief({
|
|
142
142
|
branch: bundle.branch,
|
|
143
143
|
baseBranch,
|
|
144
|
-
|
|
144
|
+
...briefFacts(worktreePath),
|
|
145
145
|
});
|
|
146
146
|
// Checked and written with no await between, so two can't both pass.
|
|
147
147
|
db.transaction(() => {
|
package/lib/tasks/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { agentLabel } from "../agent-options";
|
|
|
33
33
|
import { supportsChat } from "../chat/capabilities";
|
|
34
34
|
import { AGENT_DEFAULT_MODEL } from "../providers/registry";
|
|
35
35
|
import { statusDetector } from "../status-detector";
|
|
36
|
-
import { buildTaskBrief,
|
|
36
|
+
import { buildTaskBrief, briefFacts, type StackedOn } from "./brief";
|
|
37
37
|
import {
|
|
38
38
|
attachTaskCard,
|
|
39
39
|
inBackground,
|
|
@@ -382,7 +382,7 @@ async function startTask(
|
|
|
382
382
|
branch: wt.branchName,
|
|
383
383
|
baseBranch,
|
|
384
384
|
stack: opts.base?.stack,
|
|
385
|
-
|
|
385
|
+
...briefFacts(wt.worktreePath),
|
|
386
386
|
}),
|
|
387
387
|
task_status: "running",
|
|
388
388
|
model_overrides: routing.override
|
package/lib/tasks/move.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { db, type Session } from "../db";
|
|
|
13
13
|
import { getProject } from "../projects";
|
|
14
14
|
import { endTmuxSession } from "../tmux/end";
|
|
15
15
|
import { launchClaude } from "../agents/launch";
|
|
16
|
-
import { buildTaskBrief,
|
|
16
|
+
import { buildTaskBrief, briefFacts } from "./brief";
|
|
17
17
|
import { git as runGit } from "../git-exec";
|
|
18
18
|
import { getTaskSession, isoUTC } from "./session";
|
|
19
19
|
import { projectRef } from "./project-ref";
|
|
@@ -309,7 +309,7 @@ async function relaunch(session: Session, note?: string): Promise<void> {
|
|
|
309
309
|
? buildTaskBrief({
|
|
310
310
|
branch: session.branch_name,
|
|
311
311
|
baseBranch: session.base_branch ?? "main",
|
|
312
|
-
|
|
312
|
+
...briefFacts(session.working_directory),
|
|
313
313
|
})
|
|
314
314
|
: undefined,
|
|
315
315
|
});
|
package/lib/tasks/state.ts
CHANGED
|
@@ -27,6 +27,9 @@ export interface TaskPR {
|
|
|
27
27
|
attribution?: AttributionKind | null;
|
|
28
28
|
// The body's "Scope change" note (tasks/scope-change.ts), when it has one.
|
|
29
29
|
scopeChange?: string | null;
|
|
30
|
+
// Whether the body shows a browser or simulator check (tasks/ui-change.ts);
|
|
31
|
+
// undefined when the body wasn't read.
|
|
32
|
+
uiEvidence?: boolean;
|
|
30
33
|
// GitHub's word on merging it into its base: CONFLICTING once the base
|
|
31
34
|
// moved under it (UNKNOWN while GitHub works it out).
|
|
32
35
|
mergeable?: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The browser-check section of a task's brief, ported from dispatch's
|
|
2
|
+
// browser_brief. A project's files decide whether a task gets it, not the
|
|
3
|
+
// prompt: the PRs that skipped a check were ones whose prompt never said UI.
|
|
4
|
+
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { viewportsOf } from "../project-config";
|
|
8
|
+
import type { ProjectConfig } from "../project-config";
|
|
9
|
+
|
|
10
|
+
export type UiKind = "web" | "simulator";
|
|
11
|
+
|
|
12
|
+
const WEB_DEPS = [
|
|
13
|
+
"react",
|
|
14
|
+
"next",
|
|
15
|
+
"vue",
|
|
16
|
+
"nuxt",
|
|
17
|
+
"svelte",
|
|
18
|
+
"@sveltejs/kit",
|
|
19
|
+
"astro",
|
|
20
|
+
"solid-js",
|
|
21
|
+
"preact",
|
|
22
|
+
"@angular/core",
|
|
23
|
+
];
|
|
24
|
+
const NATIVE_DEPS = ["react-native", "expo"];
|
|
25
|
+
|
|
26
|
+
function deps(cwd: string): Set<string> {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(
|
|
29
|
+
fs.readFileSync(path.join(cwd, "package.json"), "utf8")
|
|
30
|
+
);
|
|
31
|
+
return new Set([
|
|
32
|
+
...Object.keys(pkg.dependencies ?? {}),
|
|
33
|
+
...Object.keys(pkg.devDependencies ?? {}),
|
|
34
|
+
]);
|
|
35
|
+
} catch {
|
|
36
|
+
return new Set();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isRailsApp(cwd: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
return (
|
|
43
|
+
/^\s*gem\s+["']rails["']/m.test(
|
|
44
|
+
fs.readFileSync(path.join(cwd, "Gemfile"), "utf8")
|
|
45
|
+
) && fs.statSync(path.join(cwd, "app", "views")).isDirectory()
|
|
46
|
+
);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Deterministic and cheap: the config, then two small file reads.
|
|
53
|
+
export function uiKindOf(cwd: string, config: ProjectConfig): UiKind | null {
|
|
54
|
+
if (config.browse?.verify === false) return null;
|
|
55
|
+
if (config.simulator) return "simulator";
|
|
56
|
+
if (config.browse) return "web";
|
|
57
|
+
const have = deps(cwd);
|
|
58
|
+
if (NATIVE_DEPS.some((d) => have.has(d))) return null;
|
|
59
|
+
if (WEB_DEPS.some((d) => have.has(d)) || isRailsApp(cwd)) return "web";
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const SCREENSHOT_DIR = ".agentos/screenshots";
|
|
64
|
+
|
|
65
|
+
export function uiVerifyBrief(
|
|
66
|
+
kind: UiKind | null,
|
|
67
|
+
browse?: ProjectConfig["browse"]
|
|
68
|
+
): string {
|
|
69
|
+
if (!kind) return "";
|
|
70
|
+
if (kind === "simulator")
|
|
71
|
+
return `## Checking UI changes
|
|
72
|
+
|
|
73
|
+
If your diff changes what a user sees, check the changed screens in the iOS simulator, as "Running this project" describes, before calling it done. Fix anything visibly off. Add a **Simulator check** section to the PR body: the screens you checked, what you looked for, and the screenshot names. If you didn't check, say why in one line.`;
|
|
74
|
+
const sizes = viewportsOf(browse).join(" and ");
|
|
75
|
+
return `## Checking UI changes
|
|
76
|
+
|
|
77
|
+
If your diff changes what a user sees, run the app and look at every changed screen in a real browser before calling it done, at ${sizes} (\`resize_page\` between them). Do it cheaply:
|
|
78
|
+
|
|
79
|
+
- The browser is the **\`chrome-devtools\`** MCP server. Load \`mcp__chrome-devtools__new_page\`, \`navigate_page\`, \`resize_page\`, \`take_snapshot\`, \`take_screenshot\` and \`evaluate_script\` with ToolSearch. Don't load or call \`mcp__claude-in-chrome__*\`: it isn't configured here and its first call hangs for 30 seconds. If no \`chrome-devtools\` server is available, say so in the PR and describe what you checked instead.
|
|
80
|
+
- Log in and go to pages by URL, from the login, pages and map under "Running this project" when they are there. Exploring the UI to find a page is the most expensive thing a session does.
|
|
81
|
+
- One snapshot to find a uid, then act. Verify with a screenshot of the one element or a small \`evaluate_script\` returning the one value you need, not another full snapshot.
|
|
82
|
+
- Verify the thing that would be wrong, not the thing that is easy to see: a page that rendered proves it rendered. Fix anything visibly off (gaps, clipping, overlap, horizontal scroll, stray bands, misalignment; measure rects with \`evaluate_script\` and report numbers) and read the console for errors.
|
|
83
|
+
- Save screenshots of the final changed screens with \`filePath\` under \`${SCREENSHOT_DIR}/\`, after \`mkdir -p ${SCREENSHOT_DIR} && printf '*\\n' > .agentos/.gitignore\`. That folder is inside the browser's allowed root and git ignores it; a path in /tmp is refused.
|
|
84
|
+
- Add a **Browser check** section to the PR body: the pages and widths you checked, what you fixed, and the screenshot file names. If you didn't check (no visible change, or no browser), say why in one line.`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Whether a change touches what a user sees, and whether its PR body shows
|
|
2
|
+
// anyone looked. The note it makes is for the reviewer's detail only: UI
|
|
3
|
+
// screenshots are not a merge gate, so it can never change a verdict.
|
|
4
|
+
|
|
5
|
+
import type { ChangedFile } from "../orchestrator/diff";
|
|
6
|
+
import type { CheckStatus } from "../orchestrator/checks";
|
|
7
|
+
import { renderedLines } from "./code-review";
|
|
8
|
+
|
|
9
|
+
const UI_EXT =
|
|
10
|
+
/\.(tsx|jsx|vue|svelte|astro|css|scss|sass|less|html|erb|haml|slim)$/i;
|
|
11
|
+
const UI_DIR = /(^|\/)(app\/views|app\/components)\//;
|
|
12
|
+
const SWIFT_VIEW = /(^|\/)(Views?\/.*|\w*Views?)\.swift$/;
|
|
13
|
+
const NOT_UI = /(\.(test|spec|stories)\.|(^|\/)(__tests__|spec|tests?|e2e)\/)/;
|
|
14
|
+
|
|
15
|
+
export function isUiPath(file: string): boolean {
|
|
16
|
+
if (NOT_UI.test(file)) return false;
|
|
17
|
+
return UI_EXT.test(file) || UI_DIR.test(file) || SWIFT_VIEW.test(file);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function uiFilesIn(files: ChangedFile[]): string[] {
|
|
21
|
+
return files
|
|
22
|
+
.filter((f) => f.status !== "D" && isUiPath(f.path))
|
|
23
|
+
.map((f) => f.path);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const IMAGE =
|
|
27
|
+
/!\[|<img\b|\.(png|jpe?g|webp|gif)\b|user-images|githubusercontent/i;
|
|
28
|
+
const DESCRIBED =
|
|
29
|
+
/browser check|simulator check|screenshot|chrome-devtools|viewports?\b|\b\d{3,4}\s*[x×]\s*\d{3,4}\b|in (a|the) (real )?browser|in the (ios )?simulator/i;
|
|
30
|
+
|
|
31
|
+
// Lenient on purpose: a nudge, not a gate. Undefined when the body wasn't read.
|
|
32
|
+
export function hasUiEvidence(
|
|
33
|
+
body: string | null | undefined
|
|
34
|
+
): boolean | undefined {
|
|
35
|
+
if (body === null || body === undefined) return undefined;
|
|
36
|
+
return renderedLines(body).some((l) => IMAGE.test(l) || DESCRIBED.test(l));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function uiEvidenceNote(
|
|
40
|
+
files: ChangedFile[],
|
|
41
|
+
evidence: boolean | undefined
|
|
42
|
+
): string | null {
|
|
43
|
+
const ui = uiFilesIn(files);
|
|
44
|
+
if (!ui.length || evidence !== false) return null;
|
|
45
|
+
const shown = ui.slice(0, 3).join(", ");
|
|
46
|
+
const more = ui.length > 3 ? `, +${ui.length - 3} more` : "";
|
|
47
|
+
return `[minor] ${shown}${more}: this changes UI files and the PR body has no browser or simulator evidence (screenshots or a described check). Not a merge gate; add a Browser check section if you checked.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function withUiEvidenceNote(
|
|
51
|
+
verdict: { status: CheckStatus; detail: string },
|
|
52
|
+
note: string | null
|
|
53
|
+
): { status: CheckStatus; detail: string } {
|
|
54
|
+
return note
|
|
55
|
+
? { status: verdict.status, detail: `${verdict.detail}\n- ${note}`.trim() }
|
|
56
|
+
: verdict;
|
|
57
|
+
}
|