@tamsensedev/runner 0.9.0 → 0.13.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 +8 -0
- package/dist/cli.js +1596 -733
- package/dist/cli.js.map +1 -1
- package/dist/recorder.cjs +42 -6
- package/dist/recorder.cjs.map +1 -1
- package/library/agents/ui-tester.md +1 -1
- package/library/skills/ui-testing/SKILL.md +1 -1
- package/library/skills/ui-testing/references/headless-check.md +17 -2
- package/library/skills/ui-testing/references/recording.md +6 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,136 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { existsSync as
|
|
4
|
+
import { existsSync as existsSync8 } from "fs";
|
|
5
5
|
import { homedir as homedir3 } from "os";
|
|
6
|
-
import
|
|
6
|
+
import process7 from "process";
|
|
7
|
+
|
|
8
|
+
// src/api.ts
|
|
9
|
+
import { setTimeout as wait } from "timers/promises";
|
|
10
|
+
|
|
11
|
+
// src/connection.ts
|
|
12
|
+
var BUSY_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
|
|
13
|
+
var REFUSED_STATUSES = /* @__PURE__ */ new Set([429, 503]);
|
|
14
|
+
var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN"]);
|
|
15
|
+
var NO_CONNECTION_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "ENETDOWN", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
16
|
+
var CLOSED_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "UND_ERR_SOCKET", "UND_ERR_CLOSED"]);
|
|
17
|
+
var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
|
|
18
|
+
var CERTIFICATE_CODE = /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS/;
|
|
19
|
+
function codeOf(error) {
|
|
20
|
+
for (let current = error, depth = 0; current && typeof current === "object" && depth < 5; depth += 1) {
|
|
21
|
+
const fields = current;
|
|
22
|
+
if (typeof fields.code === "string") {
|
|
23
|
+
return fields.code;
|
|
24
|
+
}
|
|
25
|
+
current = fields.cause ?? (Array.isArray(fields.errors) ? fields.errors[0] : null);
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function isTimeout(error) {
|
|
30
|
+
return error instanceof Error && error.name === "TimeoutError";
|
|
31
|
+
}
|
|
32
|
+
function networkFailure(error, server, timeoutMs) {
|
|
33
|
+
const host = new URL(server).host;
|
|
34
|
+
const code = codeOf(error);
|
|
35
|
+
if (isTimeout(error)) {
|
|
36
|
+
return { kind: "timeout", text: `TAMsense did not answer in ${Math.round(timeoutMs / 1e3)} seconds`, unsent: false, waitMs: null };
|
|
37
|
+
}
|
|
38
|
+
if (code && TIMEOUT_CODES.has(code)) {
|
|
39
|
+
return { kind: "timeout", text: `TAMsense did not answer in time (${code})`, unsent: false, waitMs: null };
|
|
40
|
+
}
|
|
41
|
+
if (code && NOT_FOUND_CODES.has(code)) {
|
|
42
|
+
return { kind: "no_address", text: `Could not find ${host} (${code}). Check the internet connection`, unsent: true, waitMs: null };
|
|
43
|
+
}
|
|
44
|
+
if (code && NO_CONNECTION_CODES.has(code)) {
|
|
45
|
+
return { kind: "no_connection", text: `Could not connect to ${host} (${code})`, unsent: true, waitMs: null };
|
|
46
|
+
}
|
|
47
|
+
if (code && CLOSED_CODES.has(code)) {
|
|
48
|
+
return { kind: "closed", text: `The network closed the connection to TAMsense (${code})`, unsent: false, waitMs: null };
|
|
49
|
+
}
|
|
50
|
+
if (code && CERTIFICATE_CODE.test(code)) {
|
|
51
|
+
return { kind: "certificate", text: `The secure connection to ${host} failed (${code}). A proxy or antivirus may be in the way`, unsent: true, waitMs: null };
|
|
52
|
+
}
|
|
53
|
+
return { kind: "other", text: `Could not reach ${server}${code ? ` (${code})` : ""}. Check your internet connection`, unsent: false, waitMs: null };
|
|
54
|
+
}
|
|
55
|
+
function retryAfterMs(header, now) {
|
|
56
|
+
if (!header) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const seconds = Number(header);
|
|
60
|
+
const at = Number.isFinite(seconds) ? now + seconds * 1e3 : Date.parse(header);
|
|
61
|
+
return Number.isFinite(at) ? Math.max(0, at - now) : null;
|
|
62
|
+
}
|
|
63
|
+
function busyFailure(response, now = Date.now()) {
|
|
64
|
+
return {
|
|
65
|
+
kind: "busy",
|
|
66
|
+
text: `TAMsense is busy (${response.status})`,
|
|
67
|
+
unsent: REFUSED_STATUSES.has(response.status),
|
|
68
|
+
waitMs: retryAfterMs(response.headers.get("retry-after"), now)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function backoffMs(failures, baseMs, capMs, random = Math.random) {
|
|
72
|
+
const ceiling = Math.min(capMs, baseMs * 2 ** Math.max(0, failures - 1));
|
|
73
|
+
return Math.round(ceiling / 2 + random() * ceiling / 2);
|
|
74
|
+
}
|
|
75
|
+
function durationText(ms) {
|
|
76
|
+
const seconds = Math.round(ms / 1e3);
|
|
77
|
+
if (seconds < 60) {
|
|
78
|
+
return `${seconds} s`;
|
|
79
|
+
}
|
|
80
|
+
const minutes = Math.round(seconds / 60);
|
|
81
|
+
return minutes < 60 ? `${minutes} min` : `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
|
|
82
|
+
}
|
|
83
|
+
function connectionWatch(print2, now = Date.now) {
|
|
84
|
+
let lostAt = null;
|
|
85
|
+
let failures = 0;
|
|
86
|
+
let told = /* @__PURE__ */ new Set();
|
|
87
|
+
let reconnect = new AbortController();
|
|
88
|
+
return {
|
|
89
|
+
failed(failure) {
|
|
90
|
+
lostAt ??= now();
|
|
91
|
+
failures += 1;
|
|
92
|
+
if (!told.has(failure.kind)) {
|
|
93
|
+
told.add(failure.kind);
|
|
94
|
+
print2(`${failure.text}. Trying again.`);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
reached() {
|
|
98
|
+
if (lostAt === null) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
print2(`Connected to TAMsense again after ${durationText(now() - lostAt)}, ${failures} request(s) failed meanwhile.`);
|
|
102
|
+
lostAt = null;
|
|
103
|
+
failures = 0;
|
|
104
|
+
told = /* @__PURE__ */ new Set();
|
|
105
|
+
reconnect.abort();
|
|
106
|
+
reconnect = new AbortController();
|
|
107
|
+
},
|
|
108
|
+
nextReconnect: () => reconnect.signal
|
|
109
|
+
};
|
|
110
|
+
}
|
|
7
111
|
|
|
8
112
|
// src/api.ts
|
|
9
113
|
var DEFAULT_SERVER = "https://my.tamsense.com";
|
|
114
|
+
var REPEATABLE_TIMEOUT_MS = 3e4;
|
|
115
|
+
var ACTION_TIMEOUT_MS = 2 * 6e4;
|
|
116
|
+
var UPLOAD_TIMEOUT_MS = 15 * 6e4;
|
|
117
|
+
var ATTEMPTS = 3;
|
|
118
|
+
var RETRY_BASE_MS = 1e3;
|
|
119
|
+
var RETRY_CAP_MS = 1e4;
|
|
120
|
+
var REPEATABLE_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE"]);
|
|
121
|
+
var ConnectionError = class extends Error {
|
|
122
|
+
kind;
|
|
123
|
+
reported;
|
|
124
|
+
constructor(failure, reported) {
|
|
125
|
+
super(failure.text);
|
|
126
|
+
this.name = "ConnectionError";
|
|
127
|
+
this.kind = failure.kind;
|
|
128
|
+
this.reported = reported;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
function alreadyReported(error) {
|
|
132
|
+
return error instanceof ConnectionError && error.reported;
|
|
133
|
+
}
|
|
10
134
|
var ApiError = class extends Error {
|
|
11
135
|
status;
|
|
12
136
|
code;
|
|
@@ -33,32 +157,84 @@ function textOf(value) {
|
|
|
33
157
|
return typeof value === "string" && value.trim() ? value : null;
|
|
34
158
|
}
|
|
35
159
|
async function errorOf(response) {
|
|
36
|
-
const
|
|
37
|
-
const code = textOf(
|
|
38
|
-
const message = textOf(
|
|
160
|
+
const reply4 = await response.json().catch(() => ({}));
|
|
161
|
+
const code = textOf(reply4.data?.code);
|
|
162
|
+
const message = textOf(reply4.message) ?? textOf(reply4.statusMessage) ?? `TAMsense answered with status ${response.status}`;
|
|
39
163
|
return new ApiError(response.status, code, message);
|
|
40
164
|
}
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
165
|
+
function isFailure(outcome) {
|
|
166
|
+
return "kind" in outcome;
|
|
167
|
+
}
|
|
168
|
+
function createApi(server, token = null, options = {}) {
|
|
169
|
+
const { fetch: fetchImpl = fetch, watch, timeoutMs = REPEATABLE_TIMEOUT_MS, actionTimeoutMs = ACTION_TIMEOUT_MS, sleep: sleep2 = (ms) => wait(ms) } = options;
|
|
170
|
+
async function once(sending) {
|
|
171
|
+
const { method, path, headers, body } = sending;
|
|
47
172
|
let response;
|
|
48
173
|
try {
|
|
49
|
-
response = await fetchImpl(new URL(path, server), { method, headers
|
|
174
|
+
response = await fetchImpl(new URL(path, server), { method, headers, body, signal: AbortSignal.timeout(sending.timeoutMs), ...body instanceof ReadableStream ? { duplex: "half" } : {} });
|
|
50
175
|
} catch (error) {
|
|
51
|
-
|
|
176
|
+
return networkFailure(error, server, sending.timeoutMs);
|
|
177
|
+
}
|
|
178
|
+
if (BUSY_STATUSES.has(response.status)) {
|
|
179
|
+
await response.body?.cancel().catch(() => {
|
|
180
|
+
});
|
|
181
|
+
return busyFailure(response);
|
|
52
182
|
}
|
|
183
|
+
watch?.reached();
|
|
53
184
|
if (!response.ok) {
|
|
54
185
|
throw await errorOf(response);
|
|
55
186
|
}
|
|
56
|
-
|
|
187
|
+
try {
|
|
188
|
+
return { reply: await response.json() };
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof SyntaxError) {
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
return networkFailure(error, server, sending.timeoutMs);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function send(sending) {
|
|
197
|
+
const headers = { accept: "application/json", ...sending.headers };
|
|
198
|
+
if (token) {
|
|
199
|
+
headers.authorization = `Bearer ${token}`;
|
|
200
|
+
}
|
|
201
|
+
for (let attempt2 = 1; ; attempt2 += 1) {
|
|
202
|
+
const outcome = await once({ ...sending, headers });
|
|
203
|
+
if (!isFailure(outcome)) {
|
|
204
|
+
return outcome.reply;
|
|
205
|
+
}
|
|
206
|
+
if (attempt2 >= sending.attempts || !(outcome.unsent || sending.safeToRepeat)) {
|
|
207
|
+
watch?.failed(outcome);
|
|
208
|
+
throw new ConnectionError(outcome, Boolean(watch));
|
|
209
|
+
}
|
|
210
|
+
await sleep2(outcome.waitMs === null ? backoffMs(attempt2, RETRY_BASE_MS, RETRY_CAP_MS) : Math.min(outcome.waitMs, RETRY_CAP_MS));
|
|
211
|
+
}
|
|
57
212
|
}
|
|
58
213
|
return {
|
|
59
214
|
server,
|
|
60
|
-
request: (method, path, body
|
|
61
|
-
|
|
215
|
+
request: (method, path, body, requestOptions = {}) => {
|
|
216
|
+
const safeToRepeat = REPEATABLE_METHODS.has(method) || Boolean(requestOptions.safeToRepeat);
|
|
217
|
+
return send({
|
|
218
|
+
method,
|
|
219
|
+
path,
|
|
220
|
+
headers: body ? { "content-type": "application/json" } : {},
|
|
221
|
+
body: body && JSON.stringify(body),
|
|
222
|
+
attempts: ATTEMPTS,
|
|
223
|
+
timeoutMs: safeToRepeat ? timeoutMs : actionTimeoutMs,
|
|
224
|
+
safeToRepeat
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
// поток файла не перечитать, поэтому отправка файла не повторяется
|
|
228
|
+
upload: (method, path, body, contentType) => send({
|
|
229
|
+
method,
|
|
230
|
+
path,
|
|
231
|
+
headers: { "content-type": contentType },
|
|
232
|
+
body,
|
|
233
|
+
attempts: 1,
|
|
234
|
+
timeoutMs: UPLOAD_TIMEOUT_MS,
|
|
235
|
+
safeToRepeat: false
|
|
236
|
+
}),
|
|
237
|
+
...watch ? { nextReconnect: watch.nextReconnect } : {}
|
|
62
238
|
};
|
|
63
239
|
}
|
|
64
240
|
|
|
@@ -80,7 +256,8 @@ var HELP_TEXT = `Connects this computer to TAMsense so it can work on your tasks
|
|
|
80
256
|
|
|
81
257
|
Usage:
|
|
82
258
|
${LOGIN_COMMAND} connect this computer
|
|
83
|
-
${START_COMMAND} take your tasks and work on them until you press Ctrl+C
|
|
259
|
+
${START_COMMAND} take your tasks and work on them until you press Ctrl+C;
|
|
260
|
+
run it from your code folder (or the folder above it) and it finds the code there
|
|
84
261
|
${BROWSER_COMMAND} install the headless browser for interface checks now, or show it
|
|
85
262
|
${BROWSER_COMMAND} remove delete that browser
|
|
86
263
|
npx @tamsensedev/runner@latest status show the connection
|
|
@@ -187,7 +364,7 @@ function findNpm(nodePath, env, exists) {
|
|
|
187
364
|
}
|
|
188
365
|
return null;
|
|
189
366
|
}
|
|
190
|
-
var runProcess = (command, args, options) => new Promise((
|
|
367
|
+
var runProcess = (command, args, options) => new Promise((resolve5, reject) => {
|
|
191
368
|
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
192
369
|
const tail = [];
|
|
193
370
|
for (const stream of [child.stdout, child.stderr]) {
|
|
@@ -201,7 +378,7 @@ var runProcess = (command, args, options) => new Promise((resolve3, reject) => {
|
|
|
201
378
|
});
|
|
202
379
|
}
|
|
203
380
|
child.on("error", reject);
|
|
204
|
-
child.on("close", (code) =>
|
|
381
|
+
child.on("close", (code) => resolve5({ code: code ?? 1, output: tail.join("\n") }));
|
|
205
382
|
});
|
|
206
383
|
function failureText(what, result) {
|
|
207
384
|
if (OFFLINE_PATTERN.test(result.output)) {
|
|
@@ -314,8 +491,8 @@ async function probeCapabilities(stop) {
|
|
|
314
491
|
stop?.throwIfAborted();
|
|
315
492
|
let release = () => {
|
|
316
493
|
};
|
|
317
|
-
const released = new Promise((
|
|
318
|
-
release =
|
|
494
|
+
const released = new Promise((resolve5) => {
|
|
495
|
+
release = resolve5;
|
|
319
496
|
});
|
|
320
497
|
const nothing = {
|
|
321
498
|
[Symbol.asyncIterator]: () => ({
|
|
@@ -340,8 +517,8 @@ async function probeCapabilities(stop) {
|
|
|
340
517
|
}
|
|
341
518
|
|
|
342
519
|
// src/claude.ts
|
|
343
|
-
import { createSdkMcpServer, query as
|
|
344
|
-
import { z as
|
|
520
|
+
import { createSdkMcpServer as createSdkMcpServer2, query as query3, tool as tool5 } from "@anthropic-ai/claude-agent-sdk";
|
|
521
|
+
import { z as z5 } from "zod";
|
|
345
522
|
|
|
346
523
|
// src/askPerson.ts
|
|
347
524
|
var MAX_ASK_TRIES = 5;
|
|
@@ -401,21 +578,21 @@ function shorten(text2) {
|
|
|
401
578
|
const line = text2.replace(/\s+/g, " ").trim();
|
|
402
579
|
return line.length > MAX_ACTIVITY_LENGTH ? `${line.slice(0, MAX_ACTIVITY_LENGTH - 1)}\u2026` : line;
|
|
403
580
|
}
|
|
404
|
-
function activityLine(
|
|
581
|
+
function activityLine(tool6, input) {
|
|
405
582
|
const fields = typeof input === "object" && input !== null ? input : {};
|
|
406
|
-
if (
|
|
583
|
+
if (tool6 === "Bash" && typeof fields.command === "string") {
|
|
407
584
|
return `$ ${shorten(fields.command)}`;
|
|
408
585
|
}
|
|
409
|
-
if (FILE_TOOLS.has(
|
|
410
|
-
return `${
|
|
586
|
+
if (FILE_TOOLS.has(tool6) && typeof fields.file_path === "string") {
|
|
587
|
+
return `${tool6} ${shorten(fields.file_path)}`;
|
|
411
588
|
}
|
|
412
|
-
if (
|
|
589
|
+
if (tool6 === "Skill" && typeof fields.skill === "string") {
|
|
413
590
|
return `Skill ${shorten(fields.skill)}`;
|
|
414
591
|
}
|
|
415
|
-
if (HELPER_TOOLS.has(
|
|
592
|
+
if (HELPER_TOOLS.has(tool6) && typeof fields.subagent_type === "string") {
|
|
416
593
|
return `Agent ${shorten(fields.subagent_type)}`;
|
|
417
594
|
}
|
|
418
|
-
return
|
|
595
|
+
return tool6;
|
|
419
596
|
}
|
|
420
597
|
function usageOf(modelUsage) {
|
|
421
598
|
const entries = Object.values(modelUsage);
|
|
@@ -450,6 +627,11 @@ var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
|
|
|
450
627
|
var IDENTITY_PATH = "/api/runner/me";
|
|
451
628
|
var NEXT_RUN_PATH = "/api/runner/runs/next";
|
|
452
629
|
var CAPABILITIES_PATH = "/api/runner/me/capabilities";
|
|
630
|
+
var FOUND_FOLDERS_PATH = "/api/runner/me/folders";
|
|
631
|
+
var FOLDER_CHECKS_PATH = "/api/runner/folders/checks";
|
|
632
|
+
function folderCheckPath(folderId) {
|
|
633
|
+
return `/api/runner/folders/${folderId}`;
|
|
634
|
+
}
|
|
453
635
|
function runPath(runId, action) {
|
|
454
636
|
return `/api/runner/runs/${runId}/${action}`;
|
|
455
637
|
}
|
|
@@ -465,6 +647,9 @@ function permissionPath(runId, permissionId) {
|
|
|
465
647
|
function recordingUploadPath(runId, upload, part) {
|
|
466
648
|
return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
|
|
467
649
|
}
|
|
650
|
+
function checkPath(checkId, action) {
|
|
651
|
+
return `/api/runner/checks/${checkId}/${action}`;
|
|
652
|
+
}
|
|
468
653
|
|
|
469
654
|
// src/feed.ts
|
|
470
655
|
var MAX_TEXT_LENGTH = 8e3;
|
|
@@ -485,29 +670,29 @@ function textField(fields, name) {
|
|
|
485
670
|
return typeof value === "string" && value.trim() ? value : null;
|
|
486
671
|
}
|
|
487
672
|
function toolDraft(block, parent) {
|
|
488
|
-
const
|
|
673
|
+
const tool6 = (block.name ?? "tool").replace(MCP_PREFIX, "");
|
|
489
674
|
const fields = fieldsOf(block.input);
|
|
490
|
-
const base = { tool:
|
|
491
|
-
if (
|
|
675
|
+
const base = { tool: tool6, ref: block.id ?? null, parent, failed: false };
|
|
676
|
+
if (tool6 === "Skill" && textField(fields, "skill")) {
|
|
492
677
|
return { ...base, kind: "skill", text: clip(textField(fields, "skill"), MAX_SUMMARY_LENGTH) };
|
|
493
678
|
}
|
|
494
|
-
if (HELPER_TOOLS2.has(
|
|
679
|
+
if (HELPER_TOOLS2.has(tool6)) {
|
|
495
680
|
const who = textField(fields, "subagent_type") ?? "general-purpose";
|
|
496
681
|
const what = textField(fields, "description");
|
|
497
682
|
return { ...base, kind: "agent", text: clip(what ? `${who}: ${what}` : who, MAX_SUMMARY_LENGTH) };
|
|
498
683
|
}
|
|
499
|
-
if (
|
|
684
|
+
if (tool6 === "submit_verdict") {
|
|
500
685
|
const verdict = fields.couldNotRun === true ? "Could not run" : fields.passed === true ? "Passed" : "Did not pass";
|
|
501
686
|
return { ...base, kind: "tool", text: clip(`${verdict}. ${textField(fields, "report") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
502
687
|
}
|
|
503
|
-
if (
|
|
688
|
+
if (tool6 === "hand_off") {
|
|
504
689
|
return { ...base, kind: "tool", text: clip(`Hand off to ${textField(fields, "to") ?? "?"}: ${textField(fields, "note") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
505
690
|
}
|
|
506
|
-
if (
|
|
507
|
-
return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[
|
|
691
|
+
if (tool6 in ATTACH_LABELS) {
|
|
692
|
+
return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool6]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
508
693
|
}
|
|
509
694
|
const command = textField(fields, "command");
|
|
510
|
-
const summary =
|
|
695
|
+
const summary = tool6 === "Bash" && command ? `$ ${command}` : activityLine(tool6, block.input);
|
|
511
696
|
return { ...base, kind: "tool", text: clip(summary, MAX_SUMMARY_LENGTH) };
|
|
512
697
|
}
|
|
513
698
|
function resultText(content) {
|
|
@@ -758,8 +943,8 @@ function inputChannel(options = {}) {
|
|
|
758
943
|
}
|
|
759
944
|
const next = queue.shift();
|
|
760
945
|
if (!next) {
|
|
761
|
-
await new Promise((
|
|
762
|
-
wake =
|
|
946
|
+
await new Promise((resolve5) => {
|
|
947
|
+
wake = resolve5;
|
|
763
948
|
});
|
|
764
949
|
continue;
|
|
765
950
|
}
|
|
@@ -819,9 +1004,9 @@ function inputChannel(options = {}) {
|
|
|
819
1004
|
onSwitch(apply) {
|
|
820
1005
|
applySwitch2 = apply;
|
|
821
1006
|
if (waitingSwitch) {
|
|
822
|
-
const { change, resolve:
|
|
1007
|
+
const { change, resolve: resolve5 } = waitingSwitch;
|
|
823
1008
|
waitingSwitch = null;
|
|
824
|
-
void apply(change).then(() =>
|
|
1009
|
+
void apply(change).then(() => resolve5(true), () => resolve5(false));
|
|
825
1010
|
}
|
|
826
1011
|
},
|
|
827
1012
|
onStopHelper(stop) {
|
|
@@ -882,8 +1067,8 @@ function inputChannel(options = {}) {
|
|
|
882
1067
|
const earlier = waitingSwitch;
|
|
883
1068
|
earlier?.resolve(false);
|
|
884
1069
|
const merged = { model: change.model ?? earlier?.change.model ?? null, effort: change.effort ?? earlier?.change.effort ?? null };
|
|
885
|
-
return new Promise((
|
|
886
|
-
waitingSwitch = { change: merged, resolve:
|
|
1070
|
+
return new Promise((resolve5) => {
|
|
1071
|
+
waitingSwitch = { change: merged, resolve: resolve5 };
|
|
887
1072
|
});
|
|
888
1073
|
}
|
|
889
1074
|
};
|
|
@@ -1046,8 +1231,8 @@ function messageOf2(error) {
|
|
|
1046
1231
|
async function decisionOf(tools, first, signal) {
|
|
1047
1232
|
const now = tools.now ?? Date.now;
|
|
1048
1233
|
const giveUpAt = Date.parse(first.waitUntil) + SITE_SILENCE_MS;
|
|
1049
|
-
let
|
|
1050
|
-
while (
|
|
1234
|
+
let reply4 = first;
|
|
1235
|
+
while (reply4.decision === "wait") {
|
|
1051
1236
|
await tools.sleep(CHECK_INTERVAL_MS, signal);
|
|
1052
1237
|
if (signal.aborted) {
|
|
1053
1238
|
return STOPPED_TEXT2;
|
|
@@ -1055,9 +1240,9 @@ async function decisionOf(tools, first, signal) {
|
|
|
1055
1240
|
if (now() > giveUpAt) {
|
|
1056
1241
|
return SITE_SILENT_TEXT;
|
|
1057
1242
|
}
|
|
1058
|
-
|
|
1243
|
+
reply4 = await tools.check(reply4.id).catch(() => reply4);
|
|
1059
1244
|
}
|
|
1060
|
-
return
|
|
1245
|
+
return reply4;
|
|
1061
1246
|
}
|
|
1062
1247
|
function askPermission(tools, lastWords, folder) {
|
|
1063
1248
|
return async (toolName, input, options) => {
|
|
@@ -1078,345 +1263,227 @@ function askPermission(tools, lastWords, folder) {
|
|
|
1078
1263
|
} catch (error) {
|
|
1079
1264
|
return deny(`Could not ask for permission on TAMsense: ${messageOf2(error)}. Do it another way, or explain in your report why it is needed.`);
|
|
1080
1265
|
}
|
|
1081
|
-
const
|
|
1082
|
-
if (typeof
|
|
1083
|
-
return deny(
|
|
1266
|
+
const reply4 = await decisionOf(tools, first, options.signal);
|
|
1267
|
+
if (typeof reply4 === "string") {
|
|
1268
|
+
return deny(reply4);
|
|
1084
1269
|
}
|
|
1085
|
-
return
|
|
1270
|
+
return reply4.decision === "allow" ? { behavior: "allow", updatedInput: input } : deny(reply4.message);
|
|
1086
1271
|
};
|
|
1087
1272
|
}
|
|
1088
1273
|
var denyWhenAsked = async () => deny(NOBODY_TO_ASK);
|
|
1089
1274
|
|
|
1090
|
-
// src/
|
|
1091
|
-
import {
|
|
1092
|
-
import {
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
".webp": "image/webp",
|
|
1108
|
-
".gif": "image/gif",
|
|
1109
|
-
".csv": "text/csv",
|
|
1110
|
-
".txt": "text/plain",
|
|
1111
|
-
".md": "text/markdown",
|
|
1112
|
-
".json": "application/json",
|
|
1113
|
-
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1114
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1115
|
-
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
1116
|
-
".zip": "application/zip"
|
|
1117
|
-
};
|
|
1118
|
-
var EXTENSIONS = { document: ".md", table: ".csv", file: "", link: "" };
|
|
1119
|
-
function isFolderJob(job) {
|
|
1120
|
-
return job.workspace === "folder";
|
|
1121
|
-
}
|
|
1122
|
-
function resultTools(job, api, root) {
|
|
1123
|
-
if (!isFolderJob(job) || job.stage.kind !== "code") {
|
|
1124
|
-
return null;
|
|
1125
|
-
}
|
|
1275
|
+
// src/localWorkspace.ts
|
|
1276
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1277
|
+
import { join as join4, resolve as resolve3 } from "path";
|
|
1278
|
+
|
|
1279
|
+
// src/folders.ts
|
|
1280
|
+
import { existsSync as existsSync2, realpathSync, statSync } from "fs";
|
|
1281
|
+
import { readdir } from "fs/promises";
|
|
1282
|
+
import { basename, isAbsolute, join as join2, relative, resolve } from "path";
|
|
1283
|
+
import process3 from "process";
|
|
1284
|
+
|
|
1285
|
+
// src/git.ts
|
|
1286
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
1287
|
+
import { spawn as spawn2 } from "child_process";
|
|
1288
|
+
import process2 from "process";
|
|
1289
|
+
var GITHUB_ORIGIN = "https://github.com/";
|
|
1290
|
+
function githubAuthEnv(token) {
|
|
1291
|
+
const credentials = Buffer2.from(`x-access-token:${token}`).toString("base64");
|
|
1126
1292
|
return {
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1293
|
+
GIT_CONFIG_COUNT: "1",
|
|
1294
|
+
GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
|
|
1295
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
|
|
1296
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
1130
1297
|
};
|
|
1131
1298
|
}
|
|
1132
|
-
function
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1299
|
+
function personalGitEnv(env = process2.env) {
|
|
1300
|
+
return { GIT_TERMINAL_PROMPT: "0", ...env.GIT_SSH_COMMAND ? {} : { GIT_SSH_COMMAND: "ssh -o BatchMode=yes" } };
|
|
1301
|
+
}
|
|
1302
|
+
function runGit(args, options) {
|
|
1303
|
+
const env = { ...process2.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
|
|
1304
|
+
return new Promise((resolve5, reject) => {
|
|
1305
|
+
const child = spawn2("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"], ...options.timeoutMs ? { timeout: options.timeoutMs } : {} });
|
|
1306
|
+
let stdout = "";
|
|
1307
|
+
let stderr = "";
|
|
1308
|
+
child.stdout.on("data", (chunk) => {
|
|
1309
|
+
stdout += chunk.toString();
|
|
1310
|
+
});
|
|
1311
|
+
child.stderr.on("data", (chunk) => {
|
|
1312
|
+
stderr += chunk.toString();
|
|
1313
|
+
});
|
|
1314
|
+
child.on("error", reject);
|
|
1315
|
+
child.on("close", (code) => resolve5({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
async function git(args, options) {
|
|
1319
|
+
const result = await runGit(args, options);
|
|
1320
|
+
if (result.code !== 0) {
|
|
1321
|
+
throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
|
|
1136
1322
|
}
|
|
1137
|
-
return
|
|
1323
|
+
return result.stdout;
|
|
1138
1324
|
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1325
|
+
|
|
1326
|
+
// src/folders.ts
|
|
1327
|
+
var FIRST_REMOTE = "origin";
|
|
1328
|
+
var USUAL_BASES = ["main", "master"];
|
|
1329
|
+
var CHECKS_INTERVAL_MS = 5e3;
|
|
1330
|
+
var WEB_REMOTE = /^[a-z][\w+.-]*:\/\//i;
|
|
1331
|
+
var NETWORK_REMOTE = /^(?!file:)[a-z][\w+.-]*:\/\/|^[^/@]+@[^/:]+:/i;
|
|
1332
|
+
function problem(text2) {
|
|
1333
|
+
return { ok: false, problem: text2 };
|
|
1334
|
+
}
|
|
1335
|
+
async function gitText(args, cwd) {
|
|
1336
|
+
const result = await runGit(args, { cwd });
|
|
1337
|
+
return result.code === 0 && result.stdout ? result.stdout : null;
|
|
1338
|
+
}
|
|
1339
|
+
function expandHome(path, home) {
|
|
1340
|
+
const trimmed = path.trim();
|
|
1341
|
+
return trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\") ? join2(home, trimmed.slice(1)) : trimmed;
|
|
1342
|
+
}
|
|
1343
|
+
function remoteWithoutSecrets(url) {
|
|
1344
|
+
if (!WEB_REMOTE.test(url)) {
|
|
1345
|
+
return url;
|
|
1346
|
+
}
|
|
1347
|
+
try {
|
|
1348
|
+
const parsed = new URL(url);
|
|
1349
|
+
if (parsed.username || parsed.password) {
|
|
1350
|
+
parsed.username = "";
|
|
1351
|
+
parsed.password = "";
|
|
1352
|
+
}
|
|
1353
|
+
return parsed.toString().replace(/\/$/, "");
|
|
1354
|
+
} catch {
|
|
1355
|
+
return url;
|
|
1145
1356
|
}
|
|
1146
|
-
taken.add(name.toLowerCase());
|
|
1147
|
-
return name;
|
|
1148
1357
|
}
|
|
1149
|
-
function
|
|
1150
|
-
|
|
1358
|
+
function remoteRepository(remoteUrl) {
|
|
1359
|
+
const parts = remoteUrl && NETWORK_REMOTE.test(remoteUrl) ? remoteUrl.replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).filter(Boolean) : [];
|
|
1360
|
+
return parts.length >= 3 ? `${parts.at(-2)}/${parts.at(-1)}` : null;
|
|
1151
1361
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1362
|
+
function repositoryName(remoteUrl, folder) {
|
|
1363
|
+
return remoteRepository(remoteUrl) ?? basename(folder);
|
|
1364
|
+
}
|
|
1365
|
+
async function remoteOf(folder) {
|
|
1366
|
+
const remotes = (await gitText(["remote"], folder))?.split("\n").map((line) => line.trim()).filter(Boolean) ?? [];
|
|
1367
|
+
return remotes.includes(FIRST_REMOTE) ? FIRST_REMOTE : remotes[0] ?? null;
|
|
1368
|
+
}
|
|
1369
|
+
async function hasRef(folder, ref) {
|
|
1370
|
+
return (await runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: folder })).code === 0;
|
|
1371
|
+
}
|
|
1372
|
+
async function baseBranchOf(folder, remote, current) {
|
|
1373
|
+
const remoteHead = remote ? await gitText(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`], folder) : null;
|
|
1374
|
+
if (remoteHead?.startsWith(`${remote}/`)) {
|
|
1375
|
+
return remoteHead.slice(remote.length + 1);
|
|
1157
1376
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
const missing = [];
|
|
1162
|
-
for (const result of results.filter((item) => item.kind !== "link")) {
|
|
1163
|
-
const name = resultFileName(result, taken);
|
|
1164
|
-
if (result.kind === "file") {
|
|
1165
|
-
try {
|
|
1166
|
-
const file = await api.request("GET", resultFilePath(job.runId, result.id));
|
|
1167
|
-
await writeFile2(join2(folder, name), Buffer.from(file.data, "base64"));
|
|
1168
|
-
} catch (error) {
|
|
1169
|
-
missing.push(`${result.title}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1170
|
-
continue;
|
|
1171
|
-
}
|
|
1172
|
-
} else {
|
|
1173
|
-
await writeFile2(join2(folder, name), result.content ?? "");
|
|
1377
|
+
for (const name of USUAL_BASES) {
|
|
1378
|
+
if (await hasRef(folder, `refs/heads/${name}`) || remote && await hasRef(folder, `refs/remotes/${remote}/${name}`)) {
|
|
1379
|
+
return name;
|
|
1174
1380
|
}
|
|
1175
|
-
lines.push(`${RESULTS_FOLDER}/${name}: ${result.title}${result.version ? `, version ${result.version}` : ""}`);
|
|
1176
1381
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1382
|
+
return current;
|
|
1383
|
+
}
|
|
1384
|
+
function samePath(first, second) {
|
|
1385
|
+
const [a, b] = [realpathSync.native(first), realpathSync.native(second)];
|
|
1386
|
+
return process3.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
1387
|
+
}
|
|
1388
|
+
function isInside(path, folder) {
|
|
1389
|
+
const between = relative(resolve(folder), resolve(path));
|
|
1390
|
+
return between === "" || !between.startsWith("..") && !isAbsolute(between);
|
|
1391
|
+
}
|
|
1392
|
+
async function inspectFolder(path, home) {
|
|
1393
|
+
const folder = expandHome(path, home);
|
|
1394
|
+
if (!isAbsolute(folder)) {
|
|
1395
|
+
return problem("Enter the full path of the folder, for example /Users/anna/code/shop or C:\\code\\shop.");
|
|
1182
1396
|
}
|
|
1183
|
-
if (
|
|
1184
|
-
|
|
1185
|
-
${missing.map((line) => `- ${line}`).join("\n")}
|
|
1186
|
-
`);
|
|
1397
|
+
if (!existsSync2(folder) || !statSync(folder).isDirectory()) {
|
|
1398
|
+
return problem(`There is no folder ${folder} on this computer.`);
|
|
1187
1399
|
}
|
|
1188
|
-
|
|
1400
|
+
if (isInside(folder, join2(home, ".tamsense"))) {
|
|
1401
|
+
return problem("This is a TAMsense work folder. Choose your own copy of the repository.");
|
|
1402
|
+
}
|
|
1403
|
+
const top = await gitText(["rev-parse", "--show-toplevel"], folder);
|
|
1404
|
+
if (!top) {
|
|
1405
|
+
return problem(`${folder} is not a git repository. Choose the folder of a cloned repository, or run git init there.`);
|
|
1406
|
+
}
|
|
1407
|
+
if (!samePath(top, folder)) {
|
|
1408
|
+
return problem(`${folder} is inside the repository ${resolve(top)}. Add the top folder of the repository instead.`);
|
|
1409
|
+
}
|
|
1410
|
+
if (!await hasRef(folder, "HEAD")) {
|
|
1411
|
+
return problem("The repository has no commits yet. Make the first commit, then check the folder again.");
|
|
1412
|
+
}
|
|
1413
|
+
const remote = await remoteOf(folder);
|
|
1414
|
+
const remoteUrl = remote ? await gitText(["remote", "get-url", remote], folder) : null;
|
|
1415
|
+
const currentBranch = await gitText(["symbolic-ref", "--quiet", "--short", "HEAD"], folder);
|
|
1416
|
+
const defaultBranch = await baseBranchOf(folder, remote, currentBranch);
|
|
1417
|
+
if (!defaultBranch) {
|
|
1418
|
+
return problem("Could not tell the main branch of the repository. Check out the main branch in this folder, then check it again.");
|
|
1419
|
+
}
|
|
1420
|
+
const safeUrl = remoteUrl && remoteWithoutSecrets(remoteUrl);
|
|
1421
|
+
return { ok: true, repository: repositoryName(safeUrl, folder), currentBranch, defaultBranch, remoteUrl: safeUrl };
|
|
1189
1422
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1423
|
+
function reportLine(path, report) {
|
|
1424
|
+
return report.ok ? `Checked the folder ${path}: ${report.repository}, main branch ${report.defaultBranch}.` : `The folder ${path} cannot be used: ${report.problem}`;
|
|
1425
|
+
}
|
|
1426
|
+
async function checkFolders(tools, stop) {
|
|
1427
|
+
let complained = false;
|
|
1428
|
+
while (!stop.aborted) {
|
|
1429
|
+
try {
|
|
1430
|
+
const { checks } = await tools.api.request("POST", FOLDER_CHECKS_PATH, void 0, { safeToRepeat: true });
|
|
1431
|
+
for (const check of checks) {
|
|
1432
|
+
const report = await tools.inspect(check.path).catch((error) => problem(error instanceof Error ? error.message : String(error)));
|
|
1433
|
+
await tools.api.request("POST", folderCheckPath(check.id), report);
|
|
1434
|
+
tools.print(reportLine(check.path, report));
|
|
1435
|
+
}
|
|
1436
|
+
complained = false;
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
if (!complained && !stop.aborted && !alreadyReported(error)) {
|
|
1442
|
+
tools.print(`Could not check the folders for TAMsense: ${error instanceof Error ? error.message : String(error)}`);
|
|
1443
|
+
complained = true;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
await tools.sleep(CHECKS_INTERVAL_MS, stop);
|
|
1193
1447
|
}
|
|
1194
|
-
await mkdir2(root, { recursive: true });
|
|
1195
|
-
return { root, repositories: [], results: await writeResults(join2(root, RESULTS_FOLDER), job, api) };
|
|
1196
1448
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1449
|
+
var SKIPPED_FOLDER = "node_modules";
|
|
1450
|
+
var MORE_CODE_TEXT = "To add more code, start the program from your code folder or the folder above it, or add a path in Board settings > Code.";
|
|
1451
|
+
async function childRepositories(start) {
|
|
1452
|
+
const entries = await readdir(start, { withFileTypes: true }).catch(() => []);
|
|
1453
|
+
return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== SKIPPED_FOLDER).map((entry) => join2(start, entry.name)).filter((folder) => existsSync2(join2(folder, ".git"))).sort();
|
|
1454
|
+
}
|
|
1455
|
+
async function findCodeFolders(start, home) {
|
|
1456
|
+
const folder = resolve(start);
|
|
1457
|
+
if (!existsSync2(folder)) {
|
|
1458
|
+
return [];
|
|
1202
1459
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1460
|
+
const top = await gitText(["rev-parse", "--show-toplevel"], folder);
|
|
1461
|
+
const candidates = top && samePath(top, folder) ? [folder] : await childRepositories(folder);
|
|
1462
|
+
const found = [];
|
|
1463
|
+
for (const candidate of candidates) {
|
|
1464
|
+
const report = await inspectFolder(candidate, home);
|
|
1465
|
+
if (report.ok) {
|
|
1466
|
+
const { currentBranch, defaultBranch, remoteUrl } = report;
|
|
1467
|
+
found.push({ path: candidate, name: basename(candidate), repository: remoteRepository(remoteUrl), currentBranch, defaultBranch, remoteUrl });
|
|
1468
|
+
}
|
|
1205
1469
|
}
|
|
1206
|
-
|
|
1207
|
-
return { name, title: title.trim() || name, contentType: TYPES_BY_EXTENSION[extname(full).toLowerCase()] ?? "application/octet-stream", data: (await readFile2(full)).toString("base64") };
|
|
1470
|
+
return found;
|
|
1208
1471
|
}
|
|
1209
|
-
function
|
|
1210
|
-
|
|
1472
|
+
function foundCodeLines(start, found) {
|
|
1473
|
+
const lines = found.length > 0 ? found.map((folder) => `Found code: ${folder.name} (${folder.path})`) : [`No code found in ${resolve(start)}.`];
|
|
1474
|
+
return [...lines, MORE_CODE_TEXT];
|
|
1211
1475
|
}
|
|
1212
|
-
async function
|
|
1476
|
+
async function reportFoundFolders(tools, start) {
|
|
1213
1477
|
try {
|
|
1214
|
-
|
|
1478
|
+
const found = await findCodeFolders(start, tools.home);
|
|
1479
|
+
foundCodeLines(start, found).forEach((line) => tools.print(line));
|
|
1480
|
+
await tools.api.request("PUT", FOUND_FOLDERS_PATH, { folders: found });
|
|
1215
1481
|
} catch (error) {
|
|
1216
|
-
|
|
1482
|
+
if (!(error instanceof ApiError && error.status === 404)) {
|
|
1483
|
+
tools.print(`Could not tell TAMsense about the code on this computer: ${error instanceof Error ? error.message : String(error)}`);
|
|
1484
|
+
}
|
|
1217
1485
|
}
|
|
1218
1486
|
}
|
|
1219
|
-
function resultToolset(tools) {
|
|
1220
|
-
return [
|
|
1221
|
-
tool2(
|
|
1222
|
-
SAVE_DOCUMENT_TOOL_NAME,
|
|
1223
|
-
"Save a document as the result of this step on the TAMsense task: Markdown text such as a letter, a reply, a brief or a report. Saving again with the same title makes a new version of that document; every version is kept and people compare them.",
|
|
1224
|
-
{
|
|
1225
|
-
title: z2.string().describe("short name of the document, the same for every version of it"),
|
|
1226
|
-
content: z2.string().describe("the full text in Markdown, never a diff or a part"),
|
|
1227
|
-
note: z2.string().describe('one line on what this version is or what changed: "First draft", "Fixed the prices after the check"')
|
|
1228
|
-
},
|
|
1229
|
-
async (args) => await sent(() => tools.save({ kind: "document", ...args }))
|
|
1230
|
-
),
|
|
1231
|
-
tool2(
|
|
1232
|
-
SAVE_TABLE_TOOL_NAME,
|
|
1233
|
-
"Save a table as the result of this step, as CSV with a header row. Saving again with the same title makes a new version of that table.",
|
|
1234
|
-
{
|
|
1235
|
-
title: z2.string().describe("short name of the table, the same for every version of it"),
|
|
1236
|
-
csv: z2.string().describe("the full table as CSV, the first row holds the column names"),
|
|
1237
|
-
note: z2.string().describe("one line on what this version is or what changed")
|
|
1238
|
-
},
|
|
1239
|
-
async (args) => await sent(() => tools.save({ kind: "table", title: args.title, content: args.csv, note: args.note }))
|
|
1240
|
-
),
|
|
1241
|
-
tool2(
|
|
1242
|
-
ATTACH_FILE_TOOL_NAME,
|
|
1243
|
-
`Attach a file you made in the task folder to the TAMsense task as part of the result: a PDF, a spreadsheet, an image. Up to ${MAX_RESULT_FILE_BYTES / 1024 / 1024} MB.`,
|
|
1244
|
-
{
|
|
1245
|
-
path: z2.string().describe("path of the file, from the task folder or absolute"),
|
|
1246
|
-
title: z2.string().describe("what the file is, in a few words")
|
|
1247
|
-
},
|
|
1248
|
-
async (args) => {
|
|
1249
|
-
const body = await readResultFile(tools.root, args.path, args.title);
|
|
1250
|
-
return typeof body === "string" ? reply(body, true) : await sent(() => tools.attach(body));
|
|
1251
|
-
}
|
|
1252
|
-
),
|
|
1253
|
-
tool2(
|
|
1254
|
-
ADD_LINK_TOOL_NAME,
|
|
1255
|
-
"Attach a link to the TAMsense task as part of the result: a page or a file the result relies on.",
|
|
1256
|
-
{
|
|
1257
|
-
title: z2.string().describe("what the link is, in a few words"),
|
|
1258
|
-
url: z2.string().describe("the full address starting with https://")
|
|
1259
|
-
},
|
|
1260
|
-
async (args) => await sent(() => tools.save({ kind: "link", ...args }))
|
|
1261
|
-
)
|
|
1262
|
-
];
|
|
1263
|
-
}
|
|
1264
|
-
function handOverTool(tools) {
|
|
1265
|
-
return tool2(
|
|
1266
|
-
HAND_OVER_TOOL_NAME,
|
|
1267
|
-
`Hand the task to another board of the project as a linked task, when part of the work belongs to that board. Boards you may hand to: ${tools.boards.map((board) => board.name).join(", ")}. Without autopilot a person confirms it first.`,
|
|
1268
|
-
{
|
|
1269
|
-
board: z2.string().describe("name of the board"),
|
|
1270
|
-
title: z2.string().describe("title of the new task on that board, one plain sentence"),
|
|
1271
|
-
note: z2.string().describe("what you found and what that board should do; never names, emails or other personal data of clients"),
|
|
1272
|
-
wait: z2.boolean().describe("true when this task should wait on its step until the linked task is done")
|
|
1273
|
-
},
|
|
1274
|
-
async (args) => await sent(() => tools.send(args))
|
|
1275
|
-
);
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// src/specText.ts
|
|
1279
|
-
function isRecord(value) {
|
|
1280
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1281
|
-
}
|
|
1282
|
-
function text(value) {
|
|
1283
|
-
return typeof value === "string" ? value.trim() : "";
|
|
1284
|
-
}
|
|
1285
|
-
function texts(value) {
|
|
1286
|
-
return Array.isArray(value) ? value.map(text).filter(Boolean) : [];
|
|
1287
|
-
}
|
|
1288
|
-
function records(value) {
|
|
1289
|
-
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
1290
|
-
}
|
|
1291
|
-
function section(title, lines) {
|
|
1292
|
-
return lines.length > 0 ? `${title}
|
|
1293
|
-
${lines.join("\n")}` : null;
|
|
1294
|
-
}
|
|
1295
|
-
function criterionLine(value) {
|
|
1296
|
-
if (typeof value === "string") {
|
|
1297
|
-
return value.trim() ? `- ${value.trim()}` : null;
|
|
1298
|
-
}
|
|
1299
|
-
if (!isRecord(value) || !text(value.text)) {
|
|
1300
|
-
return null;
|
|
1301
|
-
}
|
|
1302
|
-
const kind = ["main", "edge", "keep"].includes(value.kind) ? `(${value.kind})` : "";
|
|
1303
|
-
const head = [text(value.id), kind].filter(Boolean).join(" ");
|
|
1304
|
-
return `- ${head ? `${head}: ` : ""}${text(value.text)}`;
|
|
1305
|
-
}
|
|
1306
|
-
function assumptionLine(value) {
|
|
1307
|
-
if (typeof value === "string") {
|
|
1308
|
-
return value.trim() ? `- ${value.trim()}` : null;
|
|
1309
|
-
}
|
|
1310
|
-
if (!isRecord(value) || !text(value.text)) {
|
|
1311
|
-
return null;
|
|
1312
|
-
}
|
|
1313
|
-
return `- ${text(value.text)}${text(value.reason) ? ` (${text(value.reason)})` : ""}`;
|
|
1314
|
-
}
|
|
1315
|
-
function changeLines(change, index) {
|
|
1316
|
-
const action = ["create", "modify", "delete"].includes(change.action) ? ` (${change.action})` : "";
|
|
1317
|
-
const [produces, consumes, acceptance] = [texts(change.produces), texts(change.consumes), texts(change.acceptance)];
|
|
1318
|
-
return [
|
|
1319
|
-
`${index + 1}. ${text(change.repo) ? `${text(change.repo)} ` : ""}${text(change.file)}${action}: ${text(change.description)}`,
|
|
1320
|
-
...produces.length > 0 ? [` Produces: ${produces.join("; ")}`] : [],
|
|
1321
|
-
...consumes.length > 0 ? [` Consumes: ${consumes.join("; ")}`] : [],
|
|
1322
|
-
...acceptance.length > 0 ? [` Makes true: ${acceptance.join(", ")}`] : []
|
|
1323
|
-
];
|
|
1324
|
-
}
|
|
1325
|
-
var CODE_USE_TEXT = { reuse: "reuse", change: "change", keep: "do not touch" };
|
|
1326
|
-
var RISK_LEVELS = ["low", "medium", "high"];
|
|
1327
|
-
function present(lines) {
|
|
1328
|
-
return lines.filter((line) => line !== null);
|
|
1329
|
-
}
|
|
1330
|
-
function decisionLines(spec) {
|
|
1331
|
-
return records(spec.decisions).filter((item) => text(item.question) && text(item.answer)).map((item) => `- ${text(item.question)} \u2192 ${text(item.answer)} (${item.by === "agent" ? "agent's call" : "owner"})`);
|
|
1332
|
-
}
|
|
1333
|
-
function briefPromptText(brief) {
|
|
1334
|
-
return [
|
|
1335
|
-
text(brief.goal) && `Goal: ${text(brief.goal)}`,
|
|
1336
|
-
text(brief.context) && `Summary: ${text(brief.context)}`,
|
|
1337
|
-
section("The result must contain:", texts(brief.contains).map((item) => `- ${item}`)),
|
|
1338
|
-
section("Acceptance criteria:", present(Array.isArray(brief.acceptance) ? brief.acceptance.map(criterionLine) : [])),
|
|
1339
|
-
section("Constraints:", texts(brief.constraints).map((item) => `- ${item}`)),
|
|
1340
|
-
section("Out of scope (do not make):", texts(brief.outOfScope).map((item) => `- ${item}`)),
|
|
1341
|
-
section("Sources to use:", records(brief.sources).filter((item) => text(item.source)).map((item) => `- ${text(item.source)}${text(item.use) ? `: ${text(item.use)}` : ""}`)),
|
|
1342
|
-
text(brief.approver) && `Approved by: ${text(brief.approver)}`,
|
|
1343
|
-
section("Decisions:", decisionLines(brief)),
|
|
1344
|
-
section("Assumptions:", present(Array.isArray(brief.assumptions) ? brief.assumptions.map(assumptionLine) : [])),
|
|
1345
|
-
section("Open questions (not answered yet):", records(brief.openQuestions).filter((item) => text(item.question)).map((item) => `- ${text(item.question)}`))
|
|
1346
|
-
].filter(Boolean).join("\n\n");
|
|
1347
|
-
}
|
|
1348
|
-
function specPromptText(spec) {
|
|
1349
|
-
if (!isRecord(spec)) {
|
|
1350
|
-
return null;
|
|
1351
|
-
}
|
|
1352
|
-
if (spec.format === "brief") {
|
|
1353
|
-
return briefPromptText(spec);
|
|
1354
|
-
}
|
|
1355
|
-
const structured = Array.isArray(spec.outOfScope) || isRecord(spec.testPlan);
|
|
1356
|
-
const plan = isRecord(spec.testPlan) ? spec.testPlan : {};
|
|
1357
|
-
const risks = records(spec.risks).filter((item) => text(item.risk));
|
|
1358
|
-
const riskLevel = RISK_LEVELS.includes(spec.riskLevel) ? spec.riskLevel : null;
|
|
1359
|
-
return [
|
|
1360
|
-
text(spec.goal) && `Goal: ${text(spec.goal)}`,
|
|
1361
|
-
text(spec.context) && `${structured ? "Summary" : "Context"}: ${text(spec.context)}`,
|
|
1362
|
-
section("In scope:", texts(spec.inScope).map((item) => `- ${item}`)),
|
|
1363
|
-
section("Out of scope (do not build):", texts(spec.outOfScope).map((item) => `- ${item}`)),
|
|
1364
|
-
section("Decisions:", decisionLines(spec)),
|
|
1365
|
-
section("Code map:", records(spec.codeMap).filter((item) => text(item.file)).map((item) => `- ${text(item.repo) ? `${text(item.repo)} ` : ""}${text(item.file)}: ${text(item.role)}${CODE_USE_TEXT[item.use] ? ` [${CODE_USE_TEXT[item.use]}]` : ""}`)),
|
|
1366
|
-
section("Changes, in order:", records(spec.changes).filter((item) => text(item.file)).flatMap(changeLines)),
|
|
1367
|
-
section("Acceptance criteria:", present(Array.isArray(spec.acceptance) ? spec.acceptance.map(criterionLine) : [])),
|
|
1368
|
-
section("Test plan:", [
|
|
1369
|
-
...records(plan.automated).filter((item) => text(item.command)).map((item) => `- Run in ${text(item.repo) || "the repository"}: ${text(item.command)}${text(item.expect) ? `. Passing: ${text(item.expect)}` : ""}`),
|
|
1370
|
-
...records(plan.newTests).filter((item) => text(item.seam)).map((item) => `- New test at ${text(item.seam)}${texts(item.covers).length > 0 ? `, covers ${texts(item.covers).join(", ")}` : ""}`),
|
|
1371
|
-
...records(plan.manual).filter((item) => text(item.steps)).map((item) => `- By hand${text(item.page) ? ` on ${text(item.page)}` : ""}: ${text(item.steps)}`)
|
|
1372
|
-
]),
|
|
1373
|
-
risks.length > 0 ? section(`Risks${riskLevel ? ` (risk level ${riskLevel})` : ""}:`, risks.map((item) => `- ${text(item.risk)}${text(item.mitigation) ? `. Mitigation: ${text(item.mitigation)}` : ""}`)) : riskLevel && `Risk level: ${riskLevel}`,
|
|
1374
|
-
section("Assumptions:", present(Array.isArray(spec.assumptions) ? spec.assumptions.map(assumptionLine) : [])),
|
|
1375
|
-
section("Open questions (not answered yet):", records(spec.openQuestions).filter((item) => text(item.question)).map((item) => `- ${text(item.question)}`))
|
|
1376
|
-
].filter(Boolean).join("\n\n");
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
// src/workspace.ts
|
|
1380
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1381
|
-
import { rm as rm3 } from "fs/promises";
|
|
1382
|
-
import { join as join3, resolve as resolve2 } from "path";
|
|
1383
|
-
|
|
1384
|
-
// src/git.ts
|
|
1385
|
-
import { Buffer as Buffer2 } from "buffer";
|
|
1386
|
-
import { spawn as spawn2 } from "child_process";
|
|
1387
|
-
var GITHUB_ORIGIN = "https://github.com/";
|
|
1388
|
-
function githubAuthEnv(token) {
|
|
1389
|
-
const credentials = Buffer2.from(`x-access-token:${token}`).toString("base64");
|
|
1390
|
-
return {
|
|
1391
|
-
GIT_CONFIG_COUNT: "1",
|
|
1392
|
-
GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
|
|
1393
|
-
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
|
|
1394
|
-
GIT_TERMINAL_PROMPT: "0"
|
|
1395
|
-
};
|
|
1396
|
-
}
|
|
1397
|
-
function runGit(args, options) {
|
|
1398
|
-
const env = { ...process.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
|
|
1399
|
-
return new Promise((resolve3, reject) => {
|
|
1400
|
-
const child = spawn2("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1401
|
-
let stdout = "";
|
|
1402
|
-
let stderr = "";
|
|
1403
|
-
child.stdout.on("data", (chunk) => {
|
|
1404
|
-
stdout += chunk.toString();
|
|
1405
|
-
});
|
|
1406
|
-
child.stderr.on("data", (chunk) => {
|
|
1407
|
-
stderr += chunk.toString();
|
|
1408
|
-
});
|
|
1409
|
-
child.on("error", reject);
|
|
1410
|
-
child.on("close", (code) => resolve3({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
|
|
1411
|
-
});
|
|
1412
|
-
}
|
|
1413
|
-
async function git(args, options) {
|
|
1414
|
-
const result = await runGit(args, options);
|
|
1415
|
-
if (result.code !== 0) {
|
|
1416
|
-
throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
|
|
1417
|
-
}
|
|
1418
|
-
return result.stdout;
|
|
1419
|
-
}
|
|
1420
1487
|
|
|
1421
1488
|
// src/lock.ts
|
|
1422
1489
|
function keyedLock() {
|
|
@@ -1426,8 +1493,8 @@ function keyedLock() {
|
|
|
1426
1493
|
const previous = tails.get(key) ?? Promise.resolve();
|
|
1427
1494
|
let release = () => {
|
|
1428
1495
|
};
|
|
1429
|
-
const done2 = new Promise((
|
|
1430
|
-
release =
|
|
1496
|
+
const done2 = new Promise((resolve5) => {
|
|
1497
|
+
release = resolve5;
|
|
1431
1498
|
});
|
|
1432
1499
|
const tail = previous.then(() => done2);
|
|
1433
1500
|
tails.set(key, tail);
|
|
@@ -1445,6 +1512,9 @@ function keyedLock() {
|
|
|
1445
1512
|
}
|
|
1446
1513
|
|
|
1447
1514
|
// src/workspace.ts
|
|
1515
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1516
|
+
import { rm as rm2 } from "fs/promises";
|
|
1517
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
1448
1518
|
var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
|
|
1449
1519
|
var cacheLock = keyedLock();
|
|
1450
1520
|
function githubRemoteUrl(fullName) {
|
|
@@ -1461,122 +1531,756 @@ function folderNames(repositories) {
|
|
|
1461
1531
|
}
|
|
1462
1532
|
return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
|
|
1463
1533
|
}
|
|
1464
|
-
async function refreshCache(cache, repository, token, tools) {
|
|
1465
|
-
if (
|
|
1466
|
-
await git(["fetch", "--prune", "origin"], { cwd: cache, token });
|
|
1467
|
-
return;
|
|
1468
|
-
}
|
|
1469
|
-
await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
|
|
1534
|
+
async function refreshCache(cache, repository, token, tools) {
|
|
1535
|
+
if (existsSync3(join3(cache, ".git"))) {
|
|
1536
|
+
await git(["fetch", "--prune", "origin"], { cwd: cache, token });
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
|
|
1540
|
+
}
|
|
1541
|
+
async function removeWorktree(cache, path) {
|
|
1542
|
+
await runGit(["worktree", "remove", "--force", path], { cwd: cache });
|
|
1543
|
+
await rm2(path, { recursive: true, force: true });
|
|
1544
|
+
await git(["worktree", "prune"], { cwd: cache });
|
|
1545
|
+
}
|
|
1546
|
+
function stepFolder(job) {
|
|
1547
|
+
const task = `task-${job.taskId}`;
|
|
1548
|
+
return typeof job.stage.columnId === "number" ? [task, `step-${job.stage.columnId}`] : [task];
|
|
1549
|
+
}
|
|
1550
|
+
function isReadOnlyCheck(job) {
|
|
1551
|
+
return job.stage.kind === "review" || job.stage.kind === "test";
|
|
1552
|
+
}
|
|
1553
|
+
function branchHolders(porcelain, branch) {
|
|
1554
|
+
const holders = [];
|
|
1555
|
+
let path = null;
|
|
1556
|
+
for (const line of porcelain.split("\n").map((text2) => text2.trim())) {
|
|
1557
|
+
if (line.startsWith("worktree ")) {
|
|
1558
|
+
path = line.slice("worktree ".length);
|
|
1559
|
+
} else if (path && line === `branch refs/heads/${branch}`) {
|
|
1560
|
+
holders.push(path);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return holders;
|
|
1564
|
+
}
|
|
1565
|
+
async function releaseBranch(cache, branch, keep) {
|
|
1566
|
+
const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: cache });
|
|
1567
|
+
for (const path of branchHolders(listed.stdout, branch).filter((holder) => resolve2(holder) !== resolve2(keep))) {
|
|
1568
|
+
await removeWorktree(cache, path);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
async function baseRef(cache, job, repository) {
|
|
1572
|
+
const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
|
|
1573
|
+
return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
|
|
1574
|
+
}
|
|
1575
|
+
async function keptBase(cache, path, job, repository) {
|
|
1576
|
+
if (!job.keepWorkspace || !existsSync3(join3(path, ".git"))) {
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
|
|
1580
|
+
if (branch.code !== 0 || branch.stdout !== (isReadOnlyCheck(job) ? "HEAD" : job.branch)) {
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
|
|
1584
|
+
}
|
|
1585
|
+
function taskFolder(home, job) {
|
|
1586
|
+
return tamsenseFolder(home, "work", ...stepFolder(job));
|
|
1587
|
+
}
|
|
1588
|
+
async function prepareWorkspace(job, token, tools) {
|
|
1589
|
+
const root = taskFolder(tools.home, job);
|
|
1590
|
+
const folders = folderNames(job.repositories);
|
|
1591
|
+
const repositories = [];
|
|
1592
|
+
for (const [index, repository] of job.repositories.entries()) {
|
|
1593
|
+
const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
|
|
1594
|
+
const folder = folders[index] ?? repository.fullName;
|
|
1595
|
+
const path = join3(root, folder);
|
|
1596
|
+
const baseSha = await cacheLock.run(repository.fullName, async () => {
|
|
1597
|
+
await refreshCache(cache, repository, token, tools);
|
|
1598
|
+
const kept = await keptBase(cache, path, job, repository);
|
|
1599
|
+
if (kept) {
|
|
1600
|
+
return kept;
|
|
1601
|
+
}
|
|
1602
|
+
await removeWorktree(cache, path);
|
|
1603
|
+
const base = await baseRef(cache, job, repository);
|
|
1604
|
+
if (isReadOnlyCheck(job)) {
|
|
1605
|
+
await git(["worktree", "add", "--force", "--detach", path, base], { cwd: cache });
|
|
1606
|
+
} else {
|
|
1607
|
+
await releaseBranch(cache, job.branch, path);
|
|
1608
|
+
await git(["worktree", "add", "--force", "-B", job.branch, path, base], { cwd: cache });
|
|
1609
|
+
}
|
|
1610
|
+
return git(["rev-parse", "HEAD"], { cwd: path });
|
|
1611
|
+
});
|
|
1612
|
+
repositories.push({ fullName: repository.fullName, folder, path, baseSha });
|
|
1613
|
+
}
|
|
1614
|
+
return { root, repositories };
|
|
1615
|
+
}
|
|
1616
|
+
async function commitIdentityEnv(home) {
|
|
1617
|
+
const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
|
|
1618
|
+
if (configured.code === 0 && configured.stdout) {
|
|
1619
|
+
return {};
|
|
1620
|
+
}
|
|
1621
|
+
return {
|
|
1622
|
+
GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
|
|
1623
|
+
GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
|
|
1624
|
+
GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
|
|
1625
|
+
GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
async function publishWorkspace(workspace, job, token, identity) {
|
|
1629
|
+
const pushed = [];
|
|
1630
|
+
for (const repository of workspace.repositories) {
|
|
1631
|
+
const options = { cwd: repository.path, env: identity };
|
|
1632
|
+
const sha = await cacheLock.run(repository.fullName, async () => {
|
|
1633
|
+
await git(["add", "--all"], options);
|
|
1634
|
+
const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
|
|
1635
|
+
if (hasStaged) {
|
|
1636
|
+
await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
|
|
1637
|
+
}
|
|
1638
|
+
const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
|
|
1639
|
+
if (newCommits === 0) {
|
|
1640
|
+
return null;
|
|
1641
|
+
}
|
|
1642
|
+
await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
|
|
1643
|
+
return git(["rev-parse", "HEAD"], options);
|
|
1644
|
+
});
|
|
1645
|
+
if (sha) {
|
|
1646
|
+
pushed.push({ repository: repository.fullName, branch: job.branch, sha });
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return pushed;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// src/localWorkspace.ts
|
|
1653
|
+
var NETWORK_TIMEOUT_MS = 12e4;
|
|
1654
|
+
var sourceLock = keyedLock();
|
|
1655
|
+
function isLocalJob(job) {
|
|
1656
|
+
return job.workspace === "local";
|
|
1657
|
+
}
|
|
1658
|
+
function localBranchLine(branch) {
|
|
1659
|
+
const local = branch.local;
|
|
1660
|
+
if (!local) {
|
|
1661
|
+
return `Pushed ${branch.branch} in ${branch.repository}.`;
|
|
1662
|
+
}
|
|
1663
|
+
const where = `Branch ${branch.branch} has ${local.commits} commit(s) in ${local.folder}`;
|
|
1664
|
+
if (local.pushProblem) {
|
|
1665
|
+
return `${where}. It was not pushed: ${local.pushProblem}`;
|
|
1666
|
+
}
|
|
1667
|
+
return local.remoteUrl ? `${where}, pushed to ${local.remoteUrl}.` : `${where}.`;
|
|
1668
|
+
}
|
|
1669
|
+
function readsOnly(job) {
|
|
1670
|
+
return isReadOnlyCheck(job) || job.stage.kind === "interview" || job.stage.kind === "spec";
|
|
1671
|
+
}
|
|
1672
|
+
async function isAncestor(folder, older, newer) {
|
|
1673
|
+
return (await runGit(["merge-base", "--is-ancestor", older, newer], { cwd: folder })).code === 0;
|
|
1674
|
+
}
|
|
1675
|
+
async function fetchRemote(source, remote, print2) {
|
|
1676
|
+
const fetched = await runGit(["fetch", "--prune", remote], { cwd: source, env: personalGitEnv(), timeoutMs: NETWORK_TIMEOUT_MS });
|
|
1677
|
+
if (fetched.code !== 0) {
|
|
1678
|
+
print2(`Could not fetch from ${remote} in ${source}, going on with what the folder has: ${fetched.stderr || `exit code ${fetched.code}`}`);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
async function baseBranchRef(source, branch, remote) {
|
|
1682
|
+
const own = await hasRef(source, `refs/heads/${branch}`) ? branch : null;
|
|
1683
|
+
const fetched = remote && await hasRef(source, `refs/remotes/${remote}/${branch}`) ? `${remote}/${branch}` : null;
|
|
1684
|
+
if (own && fetched) {
|
|
1685
|
+
return await isAncestor(source, own, fetched) ? fetched : own;
|
|
1686
|
+
}
|
|
1687
|
+
const found = own ?? fetched;
|
|
1688
|
+
if (!found) {
|
|
1689
|
+
throw new Error(`The main branch ${branch} is not in ${source}. Check the folder again in the board settings, on the Code tab.`);
|
|
1690
|
+
}
|
|
1691
|
+
return found;
|
|
1692
|
+
}
|
|
1693
|
+
async function taskBranchRef(source, branch, remote, base) {
|
|
1694
|
+
if (await hasRef(source, `refs/heads/${branch}`) && Number(await git(["rev-list", "--count", `${base}..${branch}`], { cwd: source })) > 0) {
|
|
1695
|
+
return branch;
|
|
1696
|
+
}
|
|
1697
|
+
return remote && await hasRef(source, `refs/remotes/${remote}/${branch}`) ? `${remote}/${branch}` : null;
|
|
1698
|
+
}
|
|
1699
|
+
async function startRef(source, job, local) {
|
|
1700
|
+
const base = await baseBranchRef(source, local.baseBranch, local.remote);
|
|
1701
|
+
const task = await taskBranchRef(source, job.branch, local.remote, base);
|
|
1702
|
+
if (task || !isReadOnlyCheck(job)) {
|
|
1703
|
+
return task ?? base;
|
|
1704
|
+
}
|
|
1705
|
+
const where = local.remote ? `${source} or on its remote ${local.remote}` : source;
|
|
1706
|
+
throw new Error(`Branch ${job.branch} is not in ${where}. It was made on another computer: push it to a remote this folder can fetch, or run this check on that computer.`);
|
|
1707
|
+
}
|
|
1708
|
+
async function releaseBranch2(source, branch, keep, home) {
|
|
1709
|
+
const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: source });
|
|
1710
|
+
const work2 = tamsenseFolder(home, "work");
|
|
1711
|
+
for (const holder of branchHolders(listed.stdout, branch).filter((path) => resolve3(path) !== resolve3(keep))) {
|
|
1712
|
+
if (!isInside(holder, work2)) {
|
|
1713
|
+
throw new Error(`Branch ${branch} is checked out in ${resolve3(holder)}. Switch that folder to another branch, then start the step again.`);
|
|
1714
|
+
}
|
|
1715
|
+
await removeWorktree(source, holder);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
async function keptBase2(path, job) {
|
|
1719
|
+
if (!job.keepWorkspace || !existsSync4(join4(path, ".git"))) {
|
|
1720
|
+
return null;
|
|
1721
|
+
}
|
|
1722
|
+
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
|
|
1723
|
+
if (branch.code !== 0 || branch.stdout !== (readsOnly(job) ? "HEAD" : job.branch)) {
|
|
1724
|
+
return null;
|
|
1725
|
+
}
|
|
1726
|
+
return git(["rev-parse", "HEAD"], { cwd: path });
|
|
1727
|
+
}
|
|
1728
|
+
function sourceOf(repository) {
|
|
1729
|
+
if (!repository.folder) {
|
|
1730
|
+
throw new Error(`TAMsense did not say which folder holds ${repository.fullName} on this computer.`);
|
|
1731
|
+
}
|
|
1732
|
+
return repository.folder;
|
|
1733
|
+
}
|
|
1734
|
+
async function prepareRepository(job, repository, path, tools) {
|
|
1735
|
+
const source = sourceOf(repository);
|
|
1736
|
+
if ((await runGit(["rev-parse", "--show-toplevel"], { cwd: source }).catch(() => null))?.code !== 0) {
|
|
1737
|
+
throw new Error(`${source} is no longer a git repository on this computer. Check the folder again in the board settings, on the Code tab.`);
|
|
1738
|
+
}
|
|
1739
|
+
const local = { source, remote: await remoteOf(source), baseBranch: repository.defaultBranch };
|
|
1740
|
+
if (local.remote) {
|
|
1741
|
+
await fetchRemote(source, local.remote, tools.print);
|
|
1742
|
+
}
|
|
1743
|
+
const kept = await keptBase2(path, job);
|
|
1744
|
+
if (kept) {
|
|
1745
|
+
return { baseSha: kept, local };
|
|
1746
|
+
}
|
|
1747
|
+
await runGit(["worktree", "prune"], { cwd: source });
|
|
1748
|
+
if (existsSync4(path)) {
|
|
1749
|
+
await removeWorktree(source, path);
|
|
1750
|
+
}
|
|
1751
|
+
const start = await startRef(source, job, local);
|
|
1752
|
+
if (readsOnly(job)) {
|
|
1753
|
+
await git(["worktree", "add", "--detach", path, start], { cwd: source });
|
|
1754
|
+
} else {
|
|
1755
|
+
await releaseBranch2(source, job.branch, path, tools.home);
|
|
1756
|
+
await git(["worktree", "add", "-B", job.branch, path, start], { cwd: source });
|
|
1757
|
+
}
|
|
1758
|
+
return { baseSha: await git(["rev-parse", "HEAD"], { cwd: path }), local };
|
|
1759
|
+
}
|
|
1760
|
+
async function prepareLocalWorkspace(job, tools) {
|
|
1761
|
+
const root = taskFolder(tools.home, job);
|
|
1762
|
+
const folders = folderNames(job.repositories);
|
|
1763
|
+
const repositories = [];
|
|
1764
|
+
for (const [index, repository] of job.repositories.entries()) {
|
|
1765
|
+
const folder = folders[index] ?? repository.fullName;
|
|
1766
|
+
const path = join4(root, folder);
|
|
1767
|
+
const prepared = await sourceLock.run(resolve3(sourceOf(repository)), () => prepareRepository(job, repository, path, tools));
|
|
1768
|
+
repositories.push({ fullName: repository.fullName, folder, path, baseSha: prepared.baseSha, local: prepared.local });
|
|
1769
|
+
}
|
|
1770
|
+
return { root, repositories };
|
|
1771
|
+
}
|
|
1772
|
+
async function pushBranch(path, local, branch) {
|
|
1773
|
+
if (!local.remote) {
|
|
1774
|
+
return { remoteUrl: null, pushProblem: null };
|
|
1775
|
+
}
|
|
1776
|
+
if (branch === local.baseBranch) {
|
|
1777
|
+
return { remoteUrl: null, pushProblem: `TAMsense never pushes the main branch ${branch}.` };
|
|
1778
|
+
}
|
|
1779
|
+
const pushed = await runGit(["push", local.remote, `HEAD:refs/heads/${branch}`], { cwd: path, env: personalGitEnv(), timeoutMs: NETWORK_TIMEOUT_MS });
|
|
1780
|
+
if (pushed.code !== 0) {
|
|
1781
|
+
return { remoteUrl: null, pushProblem: pushed.stderr || `exit code ${pushed.code}` };
|
|
1782
|
+
}
|
|
1783
|
+
return { remoteUrl: await git(["remote", "get-url", local.remote], { cwd: path }), pushProblem: null };
|
|
1784
|
+
}
|
|
1785
|
+
async function commitAndPush(repository, local, job, identity) {
|
|
1786
|
+
const options = { cwd: repository.path, env: identity };
|
|
1787
|
+
await git(["add", "--all"], options);
|
|
1788
|
+
if ((await runGit(["diff", "--cached", "--quiet"], options)).code === 1) {
|
|
1789
|
+
await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
|
|
1790
|
+
}
|
|
1791
|
+
if (Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options)) === 0) {
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
const base = await baseBranchRef(repository.path, local.baseBranch, local.remote);
|
|
1795
|
+
const commits = Number(await git(["rev-list", "--count", `${base}..HEAD`], options));
|
|
1796
|
+
const sent2 = await pushBranch(repository.path, local, job.branch);
|
|
1797
|
+
return { sha: await git(["rev-parse", "HEAD"], options), commits, ...sent2 };
|
|
1798
|
+
}
|
|
1799
|
+
async function publishLocalWorkspace(workspace, job, identity) {
|
|
1800
|
+
const pushed = [];
|
|
1801
|
+
for (const repository of workspace.repositories) {
|
|
1802
|
+
const local = repository.local;
|
|
1803
|
+
if (!local) {
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
const branch = await sourceLock.run(resolve3(local.source), async () => {
|
|
1807
|
+
const published = await commitAndPush(repository, local, job, identity);
|
|
1808
|
+
await runGit(["checkout", "--quiet", "--detach"], { cwd: repository.path });
|
|
1809
|
+
return published;
|
|
1810
|
+
});
|
|
1811
|
+
if (branch) {
|
|
1812
|
+
pushed.push({
|
|
1813
|
+
repository: repository.fullName,
|
|
1814
|
+
branch: job.branch,
|
|
1815
|
+
sha: branch.sha,
|
|
1816
|
+
local: { folder: local.source, baseBranch: local.baseBranch, commits: branch.commits, remoteUrl: branch.remoteUrl && remoteWithoutSecrets(branch.remoteUrl), pushProblem: branch.pushProblem }
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
return pushed;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// src/results.ts
|
|
1824
|
+
import { mkdir as mkdir2, readFile as readFile2, rm as rm3, stat, writeFile as writeFile2 } from "fs/promises";
|
|
1825
|
+
import { basename as basename2, extname, isAbsolute as isAbsolute2, join as join5, resolve as resolve4 } from "path";
|
|
1826
|
+
import { tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
|
|
1827
|
+
import { z as z2 } from "zod";
|
|
1828
|
+
var SAVE_DOCUMENT_TOOL_NAME = "save_result_document";
|
|
1829
|
+
var SAVE_TABLE_TOOL_NAME = "save_result_table";
|
|
1830
|
+
var ATTACH_FILE_TOOL_NAME = "attach_result_file";
|
|
1831
|
+
var ADD_LINK_TOOL_NAME = "add_result_link";
|
|
1832
|
+
var HAND_OVER_TOOL_NAME = "hand_over";
|
|
1833
|
+
var RESULTS_FOLDER = "results";
|
|
1834
|
+
var MAX_NAME_LENGTH = 60;
|
|
1835
|
+
var TYPES_BY_EXTENSION = {
|
|
1836
|
+
".pdf": "application/pdf",
|
|
1837
|
+
".png": "image/png",
|
|
1838
|
+
".jpg": "image/jpeg",
|
|
1839
|
+
".jpeg": "image/jpeg",
|
|
1840
|
+
".webp": "image/webp",
|
|
1841
|
+
".gif": "image/gif",
|
|
1842
|
+
".csv": "text/csv",
|
|
1843
|
+
".txt": "text/plain",
|
|
1844
|
+
".md": "text/markdown",
|
|
1845
|
+
".json": "application/json",
|
|
1846
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1847
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1848
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
1849
|
+
".zip": "application/zip"
|
|
1850
|
+
};
|
|
1851
|
+
var EXTENSIONS = { document: ".md", table: ".csv", file: "", link: "" };
|
|
1852
|
+
function isFolderJob(job) {
|
|
1853
|
+
return job.workspace === "folder";
|
|
1854
|
+
}
|
|
1855
|
+
function resultTools(job, api, root) {
|
|
1856
|
+
if (!isFolderJob(job) || job.stage.kind !== "code") {
|
|
1857
|
+
return null;
|
|
1858
|
+
}
|
|
1859
|
+
return {
|
|
1860
|
+
root,
|
|
1861
|
+
save: (body) => api.request("POST", runPath(job.runId, "results"), body),
|
|
1862
|
+
attach: (body) => api.request("POST", runPath(job.runId, "result-files"), body)
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
function handOverTools(job, api) {
|
|
1866
|
+
const boards = job.handOverBoards ?? [];
|
|
1867
|
+
if (boards.length === 0 || job.stage.kind !== "code") {
|
|
1868
|
+
return null;
|
|
1869
|
+
}
|
|
1870
|
+
return { boards, send: (body) => api.request("POST", runPath(job.runId, "hand-over"), body) };
|
|
1871
|
+
}
|
|
1872
|
+
function resultFileName(result, taken) {
|
|
1873
|
+
const stem = (result.kind === "file" && result.fileName ? result.fileName.replace(/\.[^.]*$/, "") : result.title).normalize("NFKC").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, MAX_NAME_LENGTH) || "result";
|
|
1874
|
+
const extension = result.kind === "file" ? extname(result.fileName ?? "") : EXTENSIONS[result.kind];
|
|
1875
|
+
let name = `${stem}${extension}`;
|
|
1876
|
+
for (let index = 2; taken.has(name.toLowerCase()); index += 1) {
|
|
1877
|
+
name = `${stem}-${index}${extension}`;
|
|
1878
|
+
}
|
|
1879
|
+
taken.add(name.toLowerCase());
|
|
1880
|
+
return name;
|
|
1881
|
+
}
|
|
1882
|
+
function linksText(links) {
|
|
1883
|
+
return links.map((link) => `- ${link.title}: ${link.url ?? ""}`).join("\n");
|
|
1884
|
+
}
|
|
1885
|
+
async function writeResults(folder, job, api) {
|
|
1886
|
+
const results = job.results ?? [];
|
|
1887
|
+
await rm3(folder, { recursive: true, force: true });
|
|
1888
|
+
if (results.length === 0) {
|
|
1889
|
+
return [];
|
|
1890
|
+
}
|
|
1891
|
+
await mkdir2(folder, { recursive: true });
|
|
1892
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1893
|
+
const lines = [];
|
|
1894
|
+
const missing = [];
|
|
1895
|
+
for (const result of results.filter((item) => item.kind !== "link")) {
|
|
1896
|
+
const name = resultFileName(result, taken);
|
|
1897
|
+
if (result.kind === "file") {
|
|
1898
|
+
try {
|
|
1899
|
+
const file = await api.request("GET", resultFilePath(job.runId, result.id));
|
|
1900
|
+
await writeFile2(join5(folder, name), Buffer.from(file.data, "base64"));
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
missing.push(`${result.title}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
} else {
|
|
1906
|
+
await writeFile2(join5(folder, name), result.content ?? "");
|
|
1907
|
+
}
|
|
1908
|
+
lines.push(`${RESULTS_FOLDER}/${name}: ${result.title}${result.version ? `, version ${result.version}` : ""}`);
|
|
1909
|
+
}
|
|
1910
|
+
const links = results.filter((item) => item.kind === "link");
|
|
1911
|
+
if (links.length > 0) {
|
|
1912
|
+
await writeFile2(join5(folder, "links.md"), `${linksText(links)}
|
|
1913
|
+
`);
|
|
1914
|
+
lines.push(`${RESULTS_FOLDER}/links.md: ${links.length} link(s)`);
|
|
1915
|
+
}
|
|
1916
|
+
if (missing.length > 0) {
|
|
1917
|
+
await writeFile2(join5(folder, "notes.md"), `These files could not be downloaded:
|
|
1918
|
+
${missing.map((line) => `- ${line}`).join("\n")}
|
|
1919
|
+
`);
|
|
1920
|
+
}
|
|
1921
|
+
return lines;
|
|
1922
|
+
}
|
|
1923
|
+
async function prepareFolder(job, root, api) {
|
|
1924
|
+
if (!job.keepWorkspace) {
|
|
1925
|
+
await rm3(root, { recursive: true, force: true });
|
|
1926
|
+
}
|
|
1927
|
+
await mkdir2(root, { recursive: true });
|
|
1928
|
+
return { root, repositories: [], results: await writeResults(join5(root, RESULTS_FOLDER), job, api) };
|
|
1929
|
+
}
|
|
1930
|
+
async function readResultFile(root, path, title) {
|
|
1931
|
+
const full = isAbsolute2(path) ? path : resolve4(root, path);
|
|
1932
|
+
const info = await stat(full).catch(() => null);
|
|
1933
|
+
if (!info?.isFile()) {
|
|
1934
|
+
return `There is no file at ${full}.`;
|
|
1935
|
+
}
|
|
1936
|
+
if (info.size > MAX_RESULT_FILE_BYTES) {
|
|
1937
|
+
return `The file is larger than ${MAX_RESULT_FILE_BYTES / 1024 / 1024} MB. Attach a smaller one or split it.`;
|
|
1938
|
+
}
|
|
1939
|
+
const name = basename2(full);
|
|
1940
|
+
return { name, title: title.trim() || name, contentType: TYPES_BY_EXTENSION[extname(full).toLowerCase()] ?? "application/octet-stream", data: (await readFile2(full)).toString("base64") };
|
|
1941
|
+
}
|
|
1942
|
+
function reply(text2, isError = false) {
|
|
1943
|
+
return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
|
|
1944
|
+
}
|
|
1945
|
+
async function sent(send) {
|
|
1946
|
+
try {
|
|
1947
|
+
return reply((await send()).text);
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
return reply(`Could not save it on TAMsense: ${error instanceof Error ? error.message : String(error)}. Try again once; if it fails again, say so in your report.`, true);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
function resultToolset(tools) {
|
|
1953
|
+
return [
|
|
1954
|
+
tool2(
|
|
1955
|
+
SAVE_DOCUMENT_TOOL_NAME,
|
|
1956
|
+
"Save a document as the result of this step on the TAMsense task: Markdown text such as a letter, a reply, a brief or a report. Saving again with the same title makes a new version of that document; every version is kept and people compare them.",
|
|
1957
|
+
{
|
|
1958
|
+
title: z2.string().describe("short name of the document, the same for every version of it"),
|
|
1959
|
+
content: z2.string().describe("the full text in Markdown, never a diff or a part"),
|
|
1960
|
+
note: z2.string().describe('one line on what this version is or what changed: "First draft", "Fixed the prices after the check"')
|
|
1961
|
+
},
|
|
1962
|
+
async (args) => await sent(() => tools.save({ kind: "document", ...args }))
|
|
1963
|
+
),
|
|
1964
|
+
tool2(
|
|
1965
|
+
SAVE_TABLE_TOOL_NAME,
|
|
1966
|
+
"Save a table as the result of this step, as CSV with a header row. Saving again with the same title makes a new version of that table.",
|
|
1967
|
+
{
|
|
1968
|
+
title: z2.string().describe("short name of the table, the same for every version of it"),
|
|
1969
|
+
csv: z2.string().describe("the full table as CSV, the first row holds the column names"),
|
|
1970
|
+
note: z2.string().describe("one line on what this version is or what changed")
|
|
1971
|
+
},
|
|
1972
|
+
async (args) => await sent(() => tools.save({ kind: "table", title: args.title, content: args.csv, note: args.note }))
|
|
1973
|
+
),
|
|
1974
|
+
tool2(
|
|
1975
|
+
ATTACH_FILE_TOOL_NAME,
|
|
1976
|
+
`Attach a file you made in the task folder to the TAMsense task as part of the result: a PDF, a spreadsheet, an image. Up to ${MAX_RESULT_FILE_BYTES / 1024 / 1024} MB.`,
|
|
1977
|
+
{
|
|
1978
|
+
path: z2.string().describe("path of the file, from the task folder or absolute"),
|
|
1979
|
+
title: z2.string().describe("what the file is, in a few words")
|
|
1980
|
+
},
|
|
1981
|
+
async (args) => {
|
|
1982
|
+
const body = await readResultFile(tools.root, args.path, args.title);
|
|
1983
|
+
return typeof body === "string" ? reply(body, true) : await sent(() => tools.attach(body));
|
|
1984
|
+
}
|
|
1985
|
+
),
|
|
1986
|
+
tool2(
|
|
1987
|
+
ADD_LINK_TOOL_NAME,
|
|
1988
|
+
"Attach a link to the TAMsense task as part of the result: a page or a file the result relies on.",
|
|
1989
|
+
{
|
|
1990
|
+
title: z2.string().describe("what the link is, in a few words"),
|
|
1991
|
+
url: z2.string().describe("the full address starting with https://")
|
|
1992
|
+
},
|
|
1993
|
+
async (args) => await sent(() => tools.save({ kind: "link", ...args }))
|
|
1994
|
+
)
|
|
1995
|
+
];
|
|
1996
|
+
}
|
|
1997
|
+
function handOverTool(tools) {
|
|
1998
|
+
return tool2(
|
|
1999
|
+
HAND_OVER_TOOL_NAME,
|
|
2000
|
+
`Hand the task to another board of the project as a linked task, when part of the work belongs to that board. Boards you may hand to: ${tools.boards.map((board) => board.name).join(", ")}. Without autopilot a person confirms it first.`,
|
|
2001
|
+
{
|
|
2002
|
+
board: z2.string().describe("name of the board"),
|
|
2003
|
+
title: z2.string().describe("title of the new task on that board, one plain sentence"),
|
|
2004
|
+
note: z2.string().describe("what you found and what that board should do; never names, emails or other personal data of clients"),
|
|
2005
|
+
wait: z2.boolean().describe("true when this task should wait on its step until the linked task is done")
|
|
2006
|
+
},
|
|
2007
|
+
async (args) => await sent(() => tools.send(args))
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// src/schedules.ts
|
|
2012
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2013
|
+
import { mkdir as mkdir3 } from "fs/promises";
|
|
2014
|
+
import { createSdkMcpServer, query as query2, tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
|
|
2015
|
+
import { z as z3 } from "zod";
|
|
2016
|
+
var PROPOSE_SCHEDULE_TOOL_NAME = "propose_schedule";
|
|
2017
|
+
var REPORT_CHECK_TOOL_NAME = "report_check";
|
|
2018
|
+
var SCHEDULE_RULE = `Some waits are long: a local stand or a database copy that takes half an hour to come up, a slow build or import, a service that answers only after a while, logs that show a problem only over hours. Do not sleep or poll in a loop for that. Propose a schedule with the ${PROPOSE_SCHEDULE_TOOL_NAME} tool: a short check repeated on a timer by a cheap model that reads the notes of earlier runs and reports one line in the task chat. Give it a clear condition that ends it, the longest interval that still helps, and when to call a person. With wait true this run ends its turn and continues here when the schedule ends. Never use a schedule for work you can finish now, for a check you can do once, or to watch something "just in case". Without autopilot a person starts it first; on autopilot it starts at once within the project limits.`;
|
|
2019
|
+
var ALREADY_WAITING_TEXT2 = "This run already waits for something. End your turn now: the conversation continues when it comes.";
|
|
2020
|
+
function reply2(text2, isError = false) {
|
|
2021
|
+
return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
|
|
2022
|
+
}
|
|
2023
|
+
function proposeScheduleTool(tools, waiting) {
|
|
2024
|
+
return tool3(
|
|
2025
|
+
PROPOSE_SCHEDULE_TOOL_NAME,
|
|
2026
|
+
"Repeat a short check on a timer while the task waits for something slow. Each run is a short cheap run that reads the notes of earlier runs and reports one line in the task chat; it ends when its condition comes, its time runs out or it made its runs.",
|
|
2027
|
+
{
|
|
2028
|
+
title: z3.string().describe('a short name, at most 80 characters: "Wait for the local stand"'),
|
|
2029
|
+
instruction: z3.string().describe("what each run does, in plain words, as a note to yourself: what to look at and what counts as new"),
|
|
2030
|
+
everyMinutes: z3.number().int().nullable().describe("repeat every N minutes (60 for every hour); null when dailyAt is set"),
|
|
2031
|
+
dailyAt: z3.string().nullable().describe("repeat every day at this time, HH:MM in the project time zone; null when everyMinutes is set"),
|
|
2032
|
+
days: z3.array(z3.number().int().min(0).max(6)).describe("with dailyAt: days of the week, 0 is Sunday; empty means every day"),
|
|
2033
|
+
until: z3.string().describe('the condition each run checks, for example "the stand answers on localhost:3100"; when it comes, the schedule ends. Empty when it simply runs for its time'),
|
|
2034
|
+
forMinutes: z3.number().int().nullable().describe("how long it runs at most, in minutes; null takes the longest the project allows"),
|
|
2035
|
+
maxRuns: z3.number().int().nullable().describe("the number of runs at most, or null"),
|
|
2036
|
+
where: z3.enum(["computer", "cloud"]).describe("computer: this computer, free, skipped while it sleeps; cloud: any time, for credits"),
|
|
2037
|
+
callWhen: z3.string().describe('when to call a person, in plain words: "a container keeps restarting"'),
|
|
2038
|
+
callOnTimeout: z3.boolean().describe("true: call a person when the condition did not come in time"),
|
|
2039
|
+
keepAfterStep: z3.boolean().describe("true only when it must keep running after the task leaves this step"),
|
|
2040
|
+
wait: z3.boolean().describe("true: this run ends its turn now and continues here when the schedule ends")
|
|
2041
|
+
},
|
|
2042
|
+
async (args) => {
|
|
2043
|
+
if (args.wait && (waiting.waitingFor !== null || waiting.waitingForSchedule !== null)) {
|
|
2044
|
+
return reply2(ALREADY_WAITING_TEXT2, true);
|
|
2045
|
+
}
|
|
2046
|
+
let answer;
|
|
2047
|
+
try {
|
|
2048
|
+
answer = await tools.send({ ...args, title: args.title.trim(), instruction: args.instruction.trim(), until: args.until.trim(), callWhen: args.callWhen.trim(), model: null });
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
return reply2(`Could not send the schedule: ${error instanceof Error ? error.message : String(error)}. Wait another way or say so in your report.`, true);
|
|
2051
|
+
}
|
|
2052
|
+
if (answer.id === null) {
|
|
2053
|
+
return reply2(answer.text, true);
|
|
2054
|
+
}
|
|
2055
|
+
if (answer.waits) {
|
|
2056
|
+
waiting.waitingForSchedule = answer.id;
|
|
2057
|
+
}
|
|
2058
|
+
return reply2(answer.text);
|
|
2059
|
+
}
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
function scheduleTools(api, path) {
|
|
2063
|
+
return { send: (body) => api.request("POST", path, body) };
|
|
2064
|
+
}
|
|
2065
|
+
async function checkFolder(home, job) {
|
|
2066
|
+
if (job.taskId !== null) {
|
|
2067
|
+
const step = taskFolder(home, { taskId: job.taskId, stage: { columnId: job.stepColumnId } });
|
|
2068
|
+
if (existsSync5(step)) {
|
|
2069
|
+
return step;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
const own = tamsenseFolder(home, "schedules", `schedule-${job.scheduleId}`);
|
|
2073
|
+
await mkdir3(own, { recursive: true });
|
|
2074
|
+
return own;
|
|
2075
|
+
}
|
|
2076
|
+
var CHECK_RULES = [
|
|
2077
|
+
"You run one check of a TAMsense schedule on the user's own computer: a short check repeated on a timer while a task waits for something. Nobody answers during the run.",
|
|
2078
|
+
"You only look. Never change, fix, restart, stop or delete anything, never commit, and never send anything to people. Reading commands are fine: listing containers, requesting a local address, reading logs, git log.",
|
|
2079
|
+
"This is the person's own computer. Never touch programs you did not start, and stop the processes you started yourself by their process id before you finish.",
|
|
2080
|
+
"Keep it short: a few commands at most. If you cannot check something, say so in the summary instead of guessing.",
|
|
2081
|
+
`Finish by calling ${REPORT_CHECK_TOOL_NAME} exactly once: summary is one short line for the task chat with the numbers that matter; changed is true only when something differs from the last note; note is the full facts only when something changed; conditionMet is true when the condition of the schedule came; callPerson is true when the schedule says to call a person for what you saw.`,
|
|
2082
|
+
"Write in the language of the task. No em dashes and no en dashes."
|
|
2083
|
+
];
|
|
2084
|
+
function noteLine(note) {
|
|
2085
|
+
const head = `- ${note.at.slice(0, 16).replace("T", " ")} (${note.outcome}): ${note.summary}`;
|
|
2086
|
+
return note.note ? `${head}
|
|
2087
|
+
${note.note.replace(/\n/g, "\n ")}` : head;
|
|
2088
|
+
}
|
|
2089
|
+
function buildCheckPrompt(job, folder) {
|
|
2090
|
+
const prompt = [
|
|
2091
|
+
`Schedule "${job.title}", run ${job.runNumber}${job.taskId === null ? "" : `, on TAMsense task #${job.taskId}: ${job.taskTitle}`}`,
|
|
2092
|
+
`What to check on each run:
|
|
2093
|
+
${job.instruction}`,
|
|
2094
|
+
job.until ? `The schedule ends when: ${job.until}` : "The schedule runs for its time; there is no condition to wait for.",
|
|
2095
|
+
job.callWhen ? `Call a person when: ${job.callWhen}` : "Call a person only when something is clearly wrong.",
|
|
2096
|
+
job.earlier.length > 0 ? `Notes of the earlier runs, the newest last:
|
|
2097
|
+
${job.earlier.map(noteLine).join("\n")}` : "This is the first run: there are no earlier notes."
|
|
2098
|
+
].join("\n\n");
|
|
2099
|
+
const instructions = [
|
|
2100
|
+
CHECK_RULES.join("\n"),
|
|
2101
|
+
workplaceText(job.computer, folder),
|
|
2102
|
+
job.safetyRules.trim() || null
|
|
2103
|
+
].filter(Boolean).join("\n\n");
|
|
2104
|
+
return { prompt, instructions };
|
|
2105
|
+
}
|
|
2106
|
+
var SERVER_NAME = "tamsense";
|
|
2107
|
+
var MAX_CHECK_TURNS = 30;
|
|
2108
|
+
function reportCheckTool(onReport) {
|
|
2109
|
+
return tool3(
|
|
2110
|
+
REPORT_CHECK_TOOL_NAME,
|
|
2111
|
+
"Hand in the result of this check. Call it exactly once, at the very end.",
|
|
2112
|
+
{
|
|
2113
|
+
changed: z3.boolean().describe("true when something changed since the earlier runs"),
|
|
2114
|
+
summary: z3.string().describe("one short line for the task chat: what you saw"),
|
|
2115
|
+
note: z3.string().nullable().describe("when something changed: the full facts, the exact lines and what they mean; null otherwise"),
|
|
2116
|
+
conditionMet: z3.boolean().describe("true when the condition of the schedule came"),
|
|
2117
|
+
callPerson: z3.boolean().describe("true when a person must look now, as the schedule says")
|
|
2118
|
+
},
|
|
2119
|
+
async (args) => {
|
|
2120
|
+
onReport({ changed: args.changed, summary: args.summary.trim(), note: args.note?.trim() || null, conditionMet: args.conditionMet, callPerson: args.callPerson });
|
|
2121
|
+
return reply2("Result received. You can stop now.");
|
|
2122
|
+
}
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
function claudeCheckRunner(startQuery) {
|
|
2126
|
+
return async (request) => {
|
|
2127
|
+
request.signal.throwIfAborted();
|
|
2128
|
+
const abortController = new AbortController();
|
|
2129
|
+
request.signal.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
2130
|
+
let result = null;
|
|
2131
|
+
const report = reportCheckTool((found) => {
|
|
2132
|
+
result = found;
|
|
2133
|
+
});
|
|
2134
|
+
const messages = startQuery({
|
|
2135
|
+
prompt: request.prompt.prompt,
|
|
2136
|
+
options: {
|
|
2137
|
+
cwd: request.cwd,
|
|
2138
|
+
systemPrompt: { type: "preset", preset: "claude_code", append: request.prompt.instructions },
|
|
2139
|
+
settingSources: ["user", "project", "local"],
|
|
2140
|
+
permissionMode: "auto",
|
|
2141
|
+
canUseTool: denyWhenAsked,
|
|
2142
|
+
mcpServers: { [SERVER_NAME]: createSdkMcpServer({ name: SERVER_NAME, version: "1.0.0", tools: [report] }) },
|
|
2143
|
+
allowedTools: [`mcp__${SERVER_NAME}__${REPORT_CHECK_TOOL_NAME}`],
|
|
2144
|
+
maxTurns: MAX_CHECK_TURNS,
|
|
2145
|
+
abortController,
|
|
2146
|
+
env: request.env,
|
|
2147
|
+
...request.model === null ? {} : { model: request.model }
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
let outcome = { completed: false, report: "Claude finished without a result.", model: null, usage: null, result: null };
|
|
2151
|
+
for await (const message of messages) {
|
|
2152
|
+
if (message.type === "result") {
|
|
2153
|
+
const finished = outcomeOf(message, "");
|
|
2154
|
+
outcome = { completed: finished.completed, report: finished.report, model: finished.model, usage: finished.usage, result: null };
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
return { ...outcome, result };
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
function checkHeartbeat(api, job, abort) {
|
|
2161
|
+
return async () => {
|
|
2162
|
+
try {
|
|
2163
|
+
const answer = await api.request("POST", checkPath(job.checkId, "heartbeat"), void 0, { safeToRepeat: true });
|
|
2164
|
+
if (!answer.keepGoing) {
|
|
2165
|
+
abort();
|
|
2166
|
+
}
|
|
2167
|
+
} catch {
|
|
2168
|
+
}
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
function checkFinishBody(outcome) {
|
|
2172
|
+
return { outcome: outcome.completed && outcome.result ? "succeeded" : "failed", report: outcome.report, model: outcome.model, usage: outcome.usage, result: outcome.result };
|
|
1470
2173
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
2174
|
+
function checkLine(body) {
|
|
2175
|
+
if (!body.result) {
|
|
2176
|
+
return `Check failed: ${body.report}`;
|
|
2177
|
+
}
|
|
2178
|
+
if (body.result.conditionMet) {
|
|
2179
|
+
return `Check: ${body.result.summary}. The condition came, the schedule ends.`;
|
|
2180
|
+
}
|
|
2181
|
+
return `Check: ${body.result.summary}`;
|
|
1475
2182
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
2183
|
+
var runClaudeCheck = claudeCheckRunner(query2);
|
|
2184
|
+
|
|
2185
|
+
// src/specText.ts
|
|
2186
|
+
function isRecord(value) {
|
|
2187
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1479
2188
|
}
|
|
1480
|
-
function
|
|
1481
|
-
return
|
|
2189
|
+
function text(value) {
|
|
2190
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1482
2191
|
}
|
|
1483
|
-
function
|
|
1484
|
-
|
|
1485
|
-
let path = null;
|
|
1486
|
-
for (const line of porcelain.split("\n").map((text2) => text2.trim())) {
|
|
1487
|
-
if (line.startsWith("worktree ")) {
|
|
1488
|
-
path = line.slice("worktree ".length);
|
|
1489
|
-
} else if (path && line === `branch refs/heads/${branch}`) {
|
|
1490
|
-
holders.push(path);
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
return holders;
|
|
2192
|
+
function texts(value) {
|
|
2193
|
+
return Array.isArray(value) ? value.map(text).filter(Boolean) : [];
|
|
1494
2194
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
for (const path of branchHolders(listed.stdout, branch).filter((holder) => resolve2(holder) !== resolve2(keep))) {
|
|
1498
|
-
await removeWorktree(cache, path);
|
|
1499
|
-
}
|
|
2195
|
+
function records(value) {
|
|
2196
|
+
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
1500
2197
|
}
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
2198
|
+
function section(title, lines) {
|
|
2199
|
+
return lines.length > 0 ? `${title}
|
|
2200
|
+
${lines.join("\n")}` : null;
|
|
1504
2201
|
}
|
|
1505
|
-
|
|
1506
|
-
if (
|
|
2202
|
+
function criterionLine(value) {
|
|
2203
|
+
if (typeof value === "string") {
|
|
2204
|
+
return value.trim() ? `- ${value.trim()}` : null;
|
|
2205
|
+
}
|
|
2206
|
+
if (!isRecord(value) || !text(value.text)) {
|
|
1507
2207
|
return null;
|
|
1508
2208
|
}
|
|
1509
|
-
const
|
|
1510
|
-
|
|
2209
|
+
const kind = ["main", "edge", "keep"].includes(value.kind) ? `(${value.kind})` : "";
|
|
2210
|
+
const head = [text(value.id), kind].filter(Boolean).join(" ");
|
|
2211
|
+
return `- ${head ? `${head}: ` : ""}${text(value.text)}`;
|
|
2212
|
+
}
|
|
2213
|
+
function assumptionLine(value) {
|
|
2214
|
+
if (typeof value === "string") {
|
|
2215
|
+
return value.trim() ? `- ${value.trim()}` : null;
|
|
2216
|
+
}
|
|
2217
|
+
if (!isRecord(value) || !text(value.text)) {
|
|
1511
2218
|
return null;
|
|
1512
2219
|
}
|
|
1513
|
-
return
|
|
2220
|
+
return `- ${text(value.text)}${text(value.reason) ? ` (${text(value.reason)})` : ""}`;
|
|
1514
2221
|
}
|
|
1515
|
-
function
|
|
1516
|
-
|
|
2222
|
+
function changeLines(change, index) {
|
|
2223
|
+
const action = ["create", "modify", "delete"].includes(change.action) ? ` (${change.action})` : "";
|
|
2224
|
+
const [produces, consumes, acceptance] = [texts(change.produces), texts(change.consumes), texts(change.acceptance)];
|
|
2225
|
+
return [
|
|
2226
|
+
`${index + 1}. ${text(change.repo) ? `${text(change.repo)} ` : ""}${text(change.file)}${action}: ${text(change.description)}`,
|
|
2227
|
+
...produces.length > 0 ? [` Produces: ${produces.join("; ")}`] : [],
|
|
2228
|
+
...consumes.length > 0 ? [` Consumes: ${consumes.join("; ")}`] : [],
|
|
2229
|
+
...acceptance.length > 0 ? [` Makes true: ${acceptance.join(", ")}`] : []
|
|
2230
|
+
];
|
|
1517
2231
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
for (const [index, repository] of job.repositories.entries()) {
|
|
1523
|
-
const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
|
|
1524
|
-
const folder = folders[index] ?? repository.fullName;
|
|
1525
|
-
const path = join3(root, folder);
|
|
1526
|
-
const baseSha = await cacheLock.run(repository.fullName, async () => {
|
|
1527
|
-
await refreshCache(cache, repository, token, tools);
|
|
1528
|
-
const kept = await keptBase(cache, path, job, repository);
|
|
1529
|
-
if (kept) {
|
|
1530
|
-
return kept;
|
|
1531
|
-
}
|
|
1532
|
-
await removeWorktree(cache, path);
|
|
1533
|
-
const base = await baseRef(cache, job, repository);
|
|
1534
|
-
if (isReadOnlyCheck(job)) {
|
|
1535
|
-
await git(["worktree", "add", "--force", "--detach", path, base], { cwd: cache });
|
|
1536
|
-
} else {
|
|
1537
|
-
await releaseBranch(cache, job.branch, path);
|
|
1538
|
-
await git(["worktree", "add", "--force", "-B", job.branch, path, base], { cwd: cache });
|
|
1539
|
-
}
|
|
1540
|
-
return git(["rev-parse", "HEAD"], { cwd: path });
|
|
1541
|
-
});
|
|
1542
|
-
repositories.push({ fullName: repository.fullName, folder, path, baseSha });
|
|
1543
|
-
}
|
|
1544
|
-
return { root, repositories };
|
|
2232
|
+
var CODE_USE_TEXT = { reuse: "reuse", change: "change", keep: "do not touch" };
|
|
2233
|
+
var RISK_LEVELS = ["low", "medium", "high"];
|
|
2234
|
+
function present(lines) {
|
|
2235
|
+
return lines.filter((line) => line !== null);
|
|
1545
2236
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
if (configured.code === 0 && configured.stdout) {
|
|
1549
|
-
return {};
|
|
1550
|
-
}
|
|
1551
|
-
return {
|
|
1552
|
-
GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
|
|
1553
|
-
GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
|
|
1554
|
-
GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
|
|
1555
|
-
GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
|
|
1556
|
-
};
|
|
2237
|
+
function decisionLines(spec) {
|
|
2238
|
+
return records(spec.decisions).filter((item) => text(item.question) && text(item.answer)).map((item) => `- ${text(item.question)} \u2192 ${text(item.answer)} (${item.by === "agent" ? "agent's call" : "owner"})`);
|
|
1557
2239
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
pushed.push({ repository: repository.fullName, branch: job.branch, sha });
|
|
1577
|
-
}
|
|
2240
|
+
function briefPromptText(brief) {
|
|
2241
|
+
return [
|
|
2242
|
+
text(brief.goal) && `Goal: ${text(brief.goal)}`,
|
|
2243
|
+
text(brief.context) && `Summary: ${text(brief.context)}`,
|
|
2244
|
+
section("The result must contain:", texts(brief.contains).map((item) => `- ${item}`)),
|
|
2245
|
+
section("Acceptance criteria:", present(Array.isArray(brief.acceptance) ? brief.acceptance.map(criterionLine) : [])),
|
|
2246
|
+
section("Constraints:", texts(brief.constraints).map((item) => `- ${item}`)),
|
|
2247
|
+
section("Out of scope (do not make):", texts(brief.outOfScope).map((item) => `- ${item}`)),
|
|
2248
|
+
section("Sources to use:", records(brief.sources).filter((item) => text(item.source)).map((item) => `- ${text(item.source)}${text(item.use) ? `: ${text(item.use)}` : ""}`)),
|
|
2249
|
+
text(brief.approver) && `Approved by: ${text(brief.approver)}`,
|
|
2250
|
+
section("Decisions:", decisionLines(brief)),
|
|
2251
|
+
section("Assumptions:", present(Array.isArray(brief.assumptions) ? brief.assumptions.map(assumptionLine) : [])),
|
|
2252
|
+
section("Open questions (not answered yet):", records(brief.openQuestions).filter((item) => text(item.question)).map((item) => `- ${text(item.question)}`))
|
|
2253
|
+
].filter(Boolean).join("\n\n");
|
|
2254
|
+
}
|
|
2255
|
+
function specPromptText(spec) {
|
|
2256
|
+
if (!isRecord(spec)) {
|
|
2257
|
+
return null;
|
|
1578
2258
|
}
|
|
1579
|
-
|
|
2259
|
+
if (spec.format === "brief") {
|
|
2260
|
+
return briefPromptText(spec);
|
|
2261
|
+
}
|
|
2262
|
+
const structured = Array.isArray(spec.outOfScope) || isRecord(spec.testPlan);
|
|
2263
|
+
const plan = isRecord(spec.testPlan) ? spec.testPlan : {};
|
|
2264
|
+
const risks = records(spec.risks).filter((item) => text(item.risk));
|
|
2265
|
+
const riskLevel = RISK_LEVELS.includes(spec.riskLevel) ? spec.riskLevel : null;
|
|
2266
|
+
return [
|
|
2267
|
+
text(spec.goal) && `Goal: ${text(spec.goal)}`,
|
|
2268
|
+
text(spec.context) && `${structured ? "Summary" : "Context"}: ${text(spec.context)}`,
|
|
2269
|
+
section("In scope:", texts(spec.inScope).map((item) => `- ${item}`)),
|
|
2270
|
+
section("Out of scope (do not build):", texts(spec.outOfScope).map((item) => `- ${item}`)),
|
|
2271
|
+
section("Decisions:", decisionLines(spec)),
|
|
2272
|
+
section("Code map:", records(spec.codeMap).filter((item) => text(item.file)).map((item) => `- ${text(item.repo) ? `${text(item.repo)} ` : ""}${text(item.file)}: ${text(item.role)}${CODE_USE_TEXT[item.use] ? ` [${CODE_USE_TEXT[item.use]}]` : ""}`)),
|
|
2273
|
+
section("Changes, in order:", records(spec.changes).filter((item) => text(item.file)).flatMap(changeLines)),
|
|
2274
|
+
section("Acceptance criteria:", present(Array.isArray(spec.acceptance) ? spec.acceptance.map(criterionLine) : [])),
|
|
2275
|
+
section("Test plan:", [
|
|
2276
|
+
...records(plan.automated).filter((item) => text(item.command)).map((item) => `- Run in ${text(item.repo) || "the repository"}: ${text(item.command)}${text(item.expect) ? `. Passing: ${text(item.expect)}` : ""}`),
|
|
2277
|
+
...records(plan.newTests).filter((item) => text(item.seam)).map((item) => `- New test at ${text(item.seam)}${texts(item.covers).length > 0 ? `, covers ${texts(item.covers).join(", ")}` : ""}`),
|
|
2278
|
+
...records(plan.manual).filter((item) => text(item.steps)).map((item) => `- By hand${text(item.page) ? ` on ${text(item.page)}` : ""}: ${text(item.steps)}`)
|
|
2279
|
+
]),
|
|
2280
|
+
risks.length > 0 ? section(`Risks${riskLevel ? ` (risk level ${riskLevel})` : ""}:`, risks.map((item) => `- ${text(item.risk)}${text(item.mitigation) ? `. Mitigation: ${text(item.mitigation)}` : ""}`)) : riskLevel && `Risk level: ${riskLevel}`,
|
|
2281
|
+
section("Assumptions:", present(Array.isArray(spec.assumptions) ? spec.assumptions.map(assumptionLine) : [])),
|
|
2282
|
+
section("Open questions (not answered yet):", records(spec.openQuestions).filter((item) => text(item.question)).map((item) => `- ${text(item.question)}`))
|
|
2283
|
+
].filter(Boolean).join("\n\n");
|
|
1580
2284
|
}
|
|
1581
2285
|
|
|
1582
2286
|
// src/prompt.ts
|
|
@@ -1599,6 +2303,8 @@ var CODE_RULES = [
|
|
|
1599
2303
|
"Check your work with the project's own tools: run the tests, type checks and linters that exist, and fix what you broke.",
|
|
1600
2304
|
"Commit your work in every repository you changed, on the current branch. Do not push, do not switch or create branches, and do not rewrite history: TAMsense pushes the branch and opens the pull request."
|
|
1601
2305
|
];
|
|
2306
|
+
var LOCAL_COMMIT_RULE = "Commit your work in every repository you changed, on the current branch. Do not push, do not switch or create branches, and do not rewrite history: TAMsense pushes the branch when the folder has a remote, and the person merges it.";
|
|
2307
|
+
var LOCAL_FOLDERS_RULE = "Each repository here is a git worktree of the person's own folder on this computer. Work only here: never open, change or check out branches in the person's original folder.";
|
|
1602
2308
|
var REWORK_RULE = "This is a rework. The branch already has the earlier work and maybe the reviewer's own commits: treat the current branch as the source of truth, keep the reviewer's changes unless a comment asks otherwise, and address every comment.";
|
|
1603
2309
|
var CODE_REPORT_RULE = "End with a short report for the reviewer: what you changed, how you checked it, and anything left open.";
|
|
1604
2310
|
var READ_ONLY_CODE_RULES = [
|
|
@@ -1606,7 +2312,7 @@ var READ_ONLY_CODE_RULES = [
|
|
|
1606
2312
|
"Check what you report with the project's own tools where they exist, and say how you checked it."
|
|
1607
2313
|
];
|
|
1608
2314
|
var READ_ONLY_REPORT_RULE = "End with a short report: what you found, how you checked it, and what is left open.";
|
|
1609
|
-
var
|
|
2315
|
+
var CHECK_RULES2 = [
|
|
1610
2316
|
"The work to check is on the current branch. See it with git diff against the base branch named for each repository below, and read the code around it.",
|
|
1611
2317
|
"Do not change, commit or push product code. You may run anything and create throwaway files outside the repositories.",
|
|
1612
2318
|
"Judge against the task, the spec and its acceptance criteria, checking each criterion by its id (AC-1 and so on) when the spec has ids. Be specific: every problem gets a file, a line and what to do about it.",
|
|
@@ -1614,7 +2320,7 @@ var CHECK_RULES = [
|
|
|
1614
2320
|
];
|
|
1615
2321
|
var SCREENSHOT_RULE = `If you render screens (screenshots of the interface), attach the ones that show the result with the ${SCREENSHOT_TOOL_NAME} tool. People see them on the task without opening the code.`;
|
|
1616
2322
|
var NAMING_RULE = 'Name everything you attach (screenshots, replays, videos) by what it shows and why it was made: a short title such as "Design option 1", "Feature testing" or "Feature testing, attempt 2", and a one-line purpose such as "Comparing two layouts for the empty orders state". Number design variants, name test runs by their goal and attempt, and never use generic names like "screenshot1".';
|
|
1617
|
-
var RECORDING_RULE = `Every browser you open with Playwright from Node is recorded for the task as a replay people can watch, requests from the tested site to the TAMsense tracker are blocked, so your runs never reach analytics, and requests other than GET, HEAD and OPTIONS to addresses outside this computer are aborted. In every browser script, load the recorder from the TAMSENSE_RECORDER environment variable (require(process.env.TAMSENSE_RECORDER), or createRequire(import.meta.url)(process.env.TAMSENSE_RECORDER) in an .mjs script), open each context with await recorder.newRecordedContext(browser, { title, purpose, context: { viewport } }), call await recorder.mark(page, 'what just happened') at every meaningful step, and end with await recorder.closeRecorded(context). Add video: true when the page has a canvas, a map, a video player or iframes from another site (a replay cannot show them), or when someone asks for a video. Recordings are attached to the task when the run ends; ${REPLAY_TOOL_NAME} and ${VIDEO_TOOL_NAME} attach one earlier with a better title.`;
|
|
2323
|
+
var RECORDING_RULE = `Every browser you open with Playwright from Node is recorded for the task as a replay people can watch, requests from the tested site to the TAMsense tracker are blocked, so your runs never reach analytics, and requests other than GET, HEAD and OPTIONS to addresses outside this computer are aborted. In every browser script, load the recorder from the TAMSENSE_RECORDER environment variable (require(process.env.TAMSENSE_RECORDER), or createRequire(import.meta.url)(process.env.TAMSENSE_RECORDER) in an .mjs script), open each context with await recorder.newRecordedContext(browser, { title, purpose, context: { viewport } }), call await recorder.mark(page, 'what just happened') at every meaningful step, and end with await recorder.closeRecorded(context, { notes }), where notes say in your own words what you tested in that browser and what happened: what you opened, what you clicked or typed, what you expected, and whether it passed or failed (at most 600 characters). People watching the replay read them first. Add video: true when the page has a canvas, a map, a video player or iframes from another site (a replay cannot show them), or when someone asks for a video. Recordings are attached to the task when the run ends; ${REPLAY_TOOL_NAME} and ${VIDEO_TOOL_NAME} attach one earlier with a better title.`;
|
|
1618
2324
|
var BROWSER_RULE = "A headless browser is installed for interface checks. If the project has no Playwright of its own, load playwright-core from the folder in the TAMSENSE_PLAYWRIGHT environment variable, with PLAYWRIGHT_BROWSERS_PATH set to the folder in TAMSENSE_PLAYWRIGHT_BROWSERS.";
|
|
1619
2325
|
var FOLDER_RULE = "This board works without code: there are no repositories. The current folder belongs to this task: work in it and keep your throwaway files here.";
|
|
1620
2326
|
var RESULT_HOME_RULE = "The result lives only on the TAMsense task: never save it in outside services such as Google Drive or a shared folder. Use connected services only to read. Sending anything to real people, such as an email or a message, is an action that needs approval like any other.";
|
|
@@ -1708,13 +2414,17 @@ function bulletList(lines) {
|
|
|
1708
2414
|
return lines.map((line) => `- ${line}`).join("\n");
|
|
1709
2415
|
}
|
|
1710
2416
|
function repositoryLines(job, workspace) {
|
|
1711
|
-
const
|
|
2417
|
+
const sourceOf2 = new Map(job.repositories.map((repository) => [repository.fullName, repository]));
|
|
1712
2418
|
return workspace.repositories.map((repository) => {
|
|
1713
|
-
const source =
|
|
2419
|
+
const source = sourceOf2.get(repository.fullName);
|
|
1714
2420
|
const pull = source?.pullRequestUrl ? `, pull request ${source.pullRequestUrl}` : "";
|
|
1715
|
-
|
|
2421
|
+
const base = repository.local ? repository.local.baseBranch : `origin/${source?.defaultBranch ?? "main"}`;
|
|
2422
|
+
return `${repository.folder} (${repository.fullName}) at commit ${repository.baseSha.slice(0, 7)}, base branch ${base}${pull}`;
|
|
1716
2423
|
});
|
|
1717
2424
|
}
|
|
2425
|
+
function codeRules(job) {
|
|
2426
|
+
return isLocalJob(job) ? [...CODE_RULES.slice(0, -1), LOCAL_COMMIT_RULE] : CODE_RULES;
|
|
2427
|
+
}
|
|
1718
2428
|
function stepLine(stage) {
|
|
1719
2429
|
return `This step of the board is "${stage.name}"${stage.helper ? `, and you work as the ${stage.helper}` : ""}.`;
|
|
1720
2430
|
}
|
|
@@ -1873,6 +2583,7 @@ ${JSON.stringify(job.spec)}` : null,
|
|
|
1873
2583
|
contextText(job.projectContext),
|
|
1874
2584
|
`The repositories for this task are checked out in the current folder, one subfolder each:
|
|
1875
2585
|
${bulletList(repositoryLines(job, workspace))}`,
|
|
2586
|
+
isLocalJob(job) && LOCAL_FOLDERS_RULE,
|
|
1876
2587
|
READ_ONLY_RULE,
|
|
1877
2588
|
`How to work:
|
|
1878
2589
|
${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
|
|
@@ -1899,6 +2610,7 @@ function buildFolderPrompt(job, workspace, options) {
|
|
|
1899
2610
|
job.handoffNote && `Note from the previous step: ${job.handoffNote}`,
|
|
1900
2611
|
job.review && `Notes to address:
|
|
1901
2612
|
${job.review}`,
|
|
2613
|
+
job.scheduleNote,
|
|
1902
2614
|
teamMessagesText(job.messages)
|
|
1903
2615
|
].filter(Boolean).join("\n\n");
|
|
1904
2616
|
const rules = isCheck ? FOLDER_CHECK_RULES : [...FOLDER_DO_RULES, ...job.review ? [FOLDER_REWORK_RULE] : [], FOLDER_REPORT_RULE];
|
|
@@ -1918,6 +2630,7 @@ ${bulletList(rules)}`,
|
|
|
1918
2630
|
peopleText(people),
|
|
1919
2631
|
isCheck ? null : handOverText(job.handOverBoards),
|
|
1920
2632
|
handoffText(job.allowedHandoffs, isCheck),
|
|
2633
|
+
job.canSchedule && SCHEDULE_RULE,
|
|
1921
2634
|
options.browser && BROWSER_RULE
|
|
1922
2635
|
].filter(Boolean).join("\n\n");
|
|
1923
2636
|
return { prompt, instructions };
|
|
@@ -1944,11 +2657,12 @@ function buildTaskPrompt(job, workspace, options = {}) {
|
|
|
1944
2657
|
job.handoffNote && `Note from the previous step: ${job.handoffNote}`,
|
|
1945
2658
|
job.review && `Review comments to address:
|
|
1946
2659
|
${job.review}`,
|
|
2660
|
+
job.scheduleNote,
|
|
1947
2661
|
teamMessagesText(job.messages)
|
|
1948
2662
|
].filter(Boolean).join("\n\n");
|
|
1949
2663
|
const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
|
|
1950
2664
|
const localApp = localAppRule(job, isCheck, canAsk);
|
|
1951
|
-
const rules = isCheck ? [...
|
|
2665
|
+
const rules = isCheck ? [...CHECK_RULES2, DEPENDENCIES_RULE, localApp, ...evidence] : readOnly ? [...READ_ONLY_CODE_RULES, DEPENDENCIES_RULE, localApp, ...evidence, READ_ONLY_REPORT_RULE] : [...codeRules(job), ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
|
|
1952
2666
|
const instructions = [
|
|
1953
2667
|
canAsk ? ASKING_RULE : UNATTENDED_RULE,
|
|
1954
2668
|
MACHINE_RULE,
|
|
@@ -1958,6 +2672,7 @@ ${job.review}`,
|
|
|
1958
2672
|
contextText(job.projectContext),
|
|
1959
2673
|
`The repositories for this task are checked out in the current folder, one subfolder each, ${isCheck ? "at the latest commit of branch" : "on branch"} ${job.branch}:
|
|
1960
2674
|
${bulletList(repositoryLines(job, workspace))}`,
|
|
2675
|
+
isLocalJob(job) && LOCAL_FOLDERS_RULE,
|
|
1961
2676
|
`How to work:
|
|
1962
2677
|
${bulletList(rules)}`,
|
|
1963
2678
|
safetyText(job),
|
|
@@ -1965,141 +2680,142 @@ ${bulletList(rules)}`,
|
|
|
1965
2680
|
APPROVAL_RULE,
|
|
1966
2681
|
peopleText(people),
|
|
1967
2682
|
handoffText(job.allowedHandoffs, isCheck),
|
|
2683
|
+
job.canSchedule && SCHEDULE_RULE,
|
|
1968
2684
|
options.browser && BROWSER_RULE
|
|
1969
2685
|
].filter(Boolean).join("\n\n");
|
|
1970
2686
|
return { prompt, instructions };
|
|
1971
2687
|
}
|
|
1972
2688
|
|
|
1973
2689
|
// src/specTools.ts
|
|
1974
|
-
import { tool as
|
|
1975
|
-
import { z as
|
|
2690
|
+
import { tool as tool4 } from "@anthropic-ai/claude-agent-sdk";
|
|
2691
|
+
import { z as z4 } from "zod";
|
|
1976
2692
|
var MAX_FIRST_ROUND = 4;
|
|
1977
|
-
var questionSchema =
|
|
1978
|
-
id:
|
|
1979
|
-
question:
|
|
1980
|
-
whyItMatters:
|
|
1981
|
-
options:
|
|
1982
|
-
label:
|
|
1983
|
-
description:
|
|
1984
|
-
recommended:
|
|
2693
|
+
var questionSchema = z4.object({
|
|
2694
|
+
id: z4.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
|
|
2695
|
+
question: z4.string().describe("One plain sentence the owner can answer without reading code"),
|
|
2696
|
+
whyItMatters: z4.string().describe("One line: what changes in the result depending on the answer"),
|
|
2697
|
+
options: z4.array(z4.object({
|
|
2698
|
+
label: z4.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
|
|
2699
|
+
description: z4.string().describe("One sentence: what happens if this option is chosen"),
|
|
2700
|
+
recommended: z4.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
|
|
1985
2701
|
})).min(2).max(4).describe('2-4 mutually exclusive options grounded in the code and data. The UI always adds a free-text "Other" field, so never add "Other" yourself'),
|
|
1986
|
-
multiSelect:
|
|
2702
|
+
multiSelect: z4.boolean().describe("true only when the options are additive and several can apply together")
|
|
1987
2703
|
});
|
|
1988
|
-
var briefSchema =
|
|
1989
|
-
findings:
|
|
1990
|
-
fact:
|
|
1991
|
-
where:
|
|
2704
|
+
var briefSchema = z4.object({
|
|
2705
|
+
findings: z4.array(z4.object({
|
|
2706
|
+
fact: z4.string().describe("What the code does today that matters for this task"),
|
|
2707
|
+
where: z4.string().describe("Where it is: owner/repo path:line")
|
|
1992
2708
|
})).describe("Findings from the code"),
|
|
1993
|
-
dataFacts:
|
|
1994
|
-
approach:
|
|
1995
|
-
codeMap:
|
|
1996
|
-
repo:
|
|
1997
|
-
file:
|
|
1998
|
-
role:
|
|
2709
|
+
dataFacts: z4.array(z4.string()).describe("Numbers and facts from the events or the business data, each with its source. Empty when the task needs no data"),
|
|
2710
|
+
approach: z4.string().describe("The approach you would take, in 2-4 sentences"),
|
|
2711
|
+
codeMap: z4.array(z4.object({
|
|
2712
|
+
repo: z4.string().describe("Repository full name (owner/name)"),
|
|
2713
|
+
file: z4.string().describe("Path relative to the repository root"),
|
|
2714
|
+
role: z4.string().describe("Why it matters: what to change or reuse there")
|
|
1999
2715
|
})).describe("The files the change will touch or must reuse"),
|
|
2000
|
-
assumptions:
|
|
2001
|
-
text:
|
|
2002
|
-
reason:
|
|
2716
|
+
assumptions: z4.array(z4.object({
|
|
2717
|
+
text: z4.string().describe("The default you chose"),
|
|
2718
|
+
reason: z4.string().describe("Why it is safe or why it was chosen")
|
|
2003
2719
|
})).describe("Corners settled with a default instead of a question"),
|
|
2004
|
-
pendingDecisions:
|
|
2720
|
+
pendingDecisions: z4.array(z4.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
|
|
2005
2721
|
});
|
|
2006
|
-
var questionsPayloadSchema =
|
|
2722
|
+
var questionsPayloadSchema = z4.object({
|
|
2007
2723
|
brief: briefSchema,
|
|
2008
|
-
questions:
|
|
2724
|
+
questions: z4.array(questionSchema).max(MAX_FIRST_ROUND).describe(`0-${MAX_FIRST_ROUND} questions that pass the question test, most important first. An empty list when research settled everything`)
|
|
2009
2725
|
});
|
|
2010
|
-
var specSchema =
|
|
2011
|
-
goal:
|
|
2012
|
-
context:
|
|
2013
|
-
inScope:
|
|
2014
|
-
outOfScope:
|
|
2015
|
-
decisions:
|
|
2016
|
-
question:
|
|
2017
|
-
answer:
|
|
2018
|
-
by:
|
|
2726
|
+
var specSchema = z4.object({
|
|
2727
|
+
goal: z4.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
|
|
2728
|
+
context: z4.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
|
|
2729
|
+
inScope: z4.array(z4.string()).describe("What this task delivers, one item each"),
|
|
2730
|
+
outOfScope: z4.array(z4.string()).describe("What this task does not do: nearby additions a coder might be tempted to make and approaches that are ruled out. Never empty"),
|
|
2731
|
+
decisions: z4.array(z4.object({
|
|
2732
|
+
question: z4.string(),
|
|
2733
|
+
answer: z4.string(),
|
|
2734
|
+
by: z4.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
|
|
2019
2735
|
})).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
|
|
2020
|
-
codeMap:
|
|
2021
|
-
repo:
|
|
2022
|
-
file:
|
|
2023
|
-
role:
|
|
2024
|
-
use:
|
|
2736
|
+
codeMap: z4.array(z4.object({
|
|
2737
|
+
repo: z4.string().describe("Repository full name (owner/name)"),
|
|
2738
|
+
file: z4.string().describe("Path relative to the repository root"),
|
|
2739
|
+
role: z4.string().describe("What the coder needs to know about this file"),
|
|
2740
|
+
use: z4.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
|
|
2025
2741
|
})).describe("The files a coder must read before starting"),
|
|
2026
|
-
changes:
|
|
2027
|
-
repo:
|
|
2028
|
-
file:
|
|
2029
|
-
action:
|
|
2030
|
-
description:
|
|
2031
|
-
produces:
|
|
2032
|
-
consumes:
|
|
2033
|
-
acceptance:
|
|
2742
|
+
changes: z4.array(z4.object({
|
|
2743
|
+
repo: z4.string().describe("Repository full name (owner/name) where the change happens"),
|
|
2744
|
+
file: z4.string().describe("Path to the file, relative to the repository root"),
|
|
2745
|
+
action: z4.enum(["create", "modify", "delete"]),
|
|
2746
|
+
description: z4.string().describe("The behaviour the file has after the change and which existing functions or patterns to extend. No code snippets or diffs"),
|
|
2747
|
+
produces: z4.array(z4.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
|
|
2748
|
+
consumes: z4.array(z4.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
|
|
2749
|
+
acceptance: z4.array(z4.string()).describe("Ids of the criteria this change makes true, like AC-1")
|
|
2034
2750
|
})).min(1).describe("File changes in dependency order: each uses only what earlier changes or the code already provide"),
|
|
2035
|
-
acceptance:
|
|
2036
|
-
id:
|
|
2037
|
-
kind:
|
|
2038
|
-
text:
|
|
2751
|
+
acceptance: z4.array(z4.object({
|
|
2752
|
+
id: z4.string().describe("AC-1, AC-2 and so on, in order"),
|
|
2753
|
+
kind: z4.enum(["main", "edge", "keep"]).describe("main: the main path; edge: an error, empty or limit case; keep: existing behaviour that must keep working"),
|
|
2754
|
+
text: z4.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
|
|
2039
2755
|
})).min(1).describe("Acceptance criteria: main path first, then edge cases, then what must keep working"),
|
|
2040
|
-
testPlan:
|
|
2041
|
-
automated:
|
|
2042
|
-
repo:
|
|
2043
|
-
command:
|
|
2044
|
-
expect:
|
|
2756
|
+
testPlan: z4.object({
|
|
2757
|
+
automated: z4.array(z4.object({
|
|
2758
|
+
repo: z4.string(),
|
|
2759
|
+
command: z4.string().describe("The exact command to run"),
|
|
2760
|
+
expect: z4.string().describe("What passing looks like")
|
|
2045
2761
|
})).describe("At least one command"),
|
|
2046
|
-
newTests:
|
|
2047
|
-
seam:
|
|
2048
|
-
covers:
|
|
2762
|
+
newTests: z4.array(z4.object({
|
|
2763
|
+
seam: z4.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
|
|
2764
|
+
covers: z4.array(z4.string()).describe("Ids of the criteria it covers")
|
|
2049
2765
|
})),
|
|
2050
|
-
manual:
|
|
2051
|
-
page:
|
|
2052
|
-
steps:
|
|
2766
|
+
manual: z4.array(z4.object({
|
|
2767
|
+
page: z4.string().describe("The page or screen"),
|
|
2768
|
+
steps: z4.string().describe("What a person does and sees")
|
|
2053
2769
|
}))
|
|
2054
2770
|
}),
|
|
2055
|
-
risks:
|
|
2056
|
-
risk:
|
|
2057
|
-
mitigation:
|
|
2771
|
+
risks: z4.array(z4.object({
|
|
2772
|
+
risk: z4.string(),
|
|
2773
|
+
mitigation: z4.string()
|
|
2058
2774
|
})).describe("What could go wrong: migrations, existing data, access, performance, compatibility"),
|
|
2059
|
-
riskLevel:
|
|
2060
|
-
assumptions:
|
|
2061
|
-
text:
|
|
2062
|
-
reason:
|
|
2775
|
+
riskLevel: z4.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
|
|
2776
|
+
assumptions: z4.array(z4.object({
|
|
2777
|
+
text: z4.string().describe("The default you chose"),
|
|
2778
|
+
reason: z4.string().describe("Why, so the owner can confirm or correct it quickly")
|
|
2063
2779
|
})).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
|
|
2064
|
-
openQuestions:
|
|
2065
|
-
split:
|
|
2780
|
+
openQuestions: z4.array(questionSchema).describe("Questions only the owner can settle; the spec cannot be approved until each is answered or dismissed. Empty when none"),
|
|
2781
|
+
split: z4.array(z4.string()).describe("When the task holds more than one shippable goal or more than about 8 files: the separate tasks you propose, one line each. Empty otherwise")
|
|
2066
2782
|
});
|
|
2067
|
-
var resultBriefSchema =
|
|
2068
|
-
format:
|
|
2069
|
-
goal:
|
|
2070
|
-
context:
|
|
2071
|
-
contains:
|
|
2072
|
-
acceptance:
|
|
2073
|
-
id:
|
|
2074
|
-
text:
|
|
2783
|
+
var resultBriefSchema = z4.object({
|
|
2784
|
+
format: z4.literal("brief").describe('Always "brief"'),
|
|
2785
|
+
goal: z4.string().describe("What the work is for, for whom, and the outcome people will notice"),
|
|
2786
|
+
context: z4.string().describe("Summary: the situation in 1-2 sentences, then how the work will be done in 1-2 sentences"),
|
|
2787
|
+
contains: z4.array(z4.string()).min(1).describe("What the result must contain, one item each: the parts, facts and attachments a reader expects"),
|
|
2788
|
+
acceptance: z4.array(z4.object({
|
|
2789
|
+
id: z4.string().describe("AC-1, AC-2 and so on, in order"),
|
|
2790
|
+
text: z4.string().describe("One observable property of the result that a checker can confirm by reading it")
|
|
2075
2791
|
})).min(1).describe("Acceptance criteria the checks judge the result by, the most important first"),
|
|
2076
|
-
constraints:
|
|
2077
|
-
outOfScope:
|
|
2078
|
-
sources:
|
|
2079
|
-
source:
|
|
2080
|
-
use:
|
|
2792
|
+
constraints: z4.array(z4.string()).describe("Rules the result must keep: length, tone, language, deadlines, what must not be promised. Empty when none"),
|
|
2793
|
+
outOfScope: z4.array(z4.string()).describe("What this work does not do: nearby things someone might be tempted to add. Empty when nothing is tempting"),
|
|
2794
|
+
sources: z4.array(z4.object({
|
|
2795
|
+
source: z4.string().describe("Where the facts come from: a project knowledge note, a linked item, a file of the task, a connected service read only"),
|
|
2796
|
+
use: z4.string().describe("What to take from it")
|
|
2081
2797
|
})).describe("Sources the work must use. Empty when the task itself is enough"),
|
|
2082
|
-
approver:
|
|
2083
|
-
decisions:
|
|
2084
|
-
question:
|
|
2085
|
-
answer:
|
|
2086
|
-
by:
|
|
2798
|
+
approver: z4.string().describe("Who approves the result, as the task or the board names them"),
|
|
2799
|
+
decisions: z4.array(z4.object({
|
|
2800
|
+
question: z4.string(),
|
|
2801
|
+
answer: z4.string(),
|
|
2802
|
+
by: z4.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
|
|
2087
2803
|
})).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
|
|
2088
|
-
assumptions:
|
|
2089
|
-
text:
|
|
2090
|
-
reason:
|
|
2804
|
+
assumptions: z4.array(z4.object({
|
|
2805
|
+
text: z4.string().describe("The default you chose"),
|
|
2806
|
+
reason: z4.string().describe("Why, so the owner can confirm or correct it quickly")
|
|
2091
2807
|
})).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
|
|
2092
|
-
openQuestions:
|
|
2808
|
+
openQuestions: z4.array(questionSchema).describe("Questions only the owner can settle; the brief cannot be approved until each is answered or dismissed. Empty when none")
|
|
2093
2809
|
});
|
|
2094
|
-
function
|
|
2810
|
+
function reply3(text2, isError = false) {
|
|
2095
2811
|
return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
|
|
2096
2812
|
}
|
|
2097
2813
|
function unreachable(error, toolName) {
|
|
2098
2814
|
const message = error instanceof Error ? error.message : String(error);
|
|
2099
|
-
return
|
|
2815
|
+
return reply3(`Could not reach TAMsense to check this: ${message}. Call ${toolName} again in a moment.`, true);
|
|
2100
2816
|
}
|
|
2101
2817
|
function questionsTool(review, onSubmit) {
|
|
2102
|
-
return
|
|
2818
|
+
return tool4(
|
|
2103
2819
|
SUBMIT_QUESTIONS_TOOL_NAME,
|
|
2104
2820
|
"Hand in the brief and the clarifying questions of this round. Call it once, after the research. Pass an empty questions list when research settled everything: the spec is then written next. If it returns problems, the questions were not saved: fix them and call it again.",
|
|
2105
2821
|
questionsPayloadSchema.shape,
|
|
@@ -2111,10 +2827,10 @@ function questionsTool(review, onSubmit) {
|
|
|
2111
2827
|
return unreachable(error, SUBMIT_QUESTIONS_TOOL_NAME);
|
|
2112
2828
|
}
|
|
2113
2829
|
if (!verdict.accepted) {
|
|
2114
|
-
return
|
|
2830
|
+
return reply3(verdict.feedback, true);
|
|
2115
2831
|
}
|
|
2116
2832
|
onSubmit(args);
|
|
2117
|
-
return
|
|
2833
|
+
return reply3(args.questions.length > 0 ? "Questions saved. The owner answers them on the task page after this run. Finish now with a reply of one or two sentences." : "Saved with no questions: the spec is written next. Finish now with a reply of one or two sentences.");
|
|
2118
2834
|
}
|
|
2119
2835
|
);
|
|
2120
2836
|
}
|
|
@@ -2122,7 +2838,7 @@ var BUILDER_CHECK_FIRST = "The spec passed the lint but was not saved yet: the b
|
|
|
2122
2838
|
function specTool(review, onSubmit, builderCheckRan) {
|
|
2123
2839
|
let repairs = 0;
|
|
2124
2840
|
let reminded = false;
|
|
2125
|
-
return
|
|
2841
|
+
return tool4(
|
|
2126
2842
|
SUBMIT_SPEC_TOOL_NAME,
|
|
2127
2843
|
"Hand in the implementation spec. It replaces the previous version entirely, so always send the FULL spec. TAMsense lints it and checks the files first; if it returns feedback, the spec was not saved: fix it and call submit_spec again.",
|
|
2128
2844
|
specSchema.shape,
|
|
@@ -2135,20 +2851,20 @@ function specTool(review, onSubmit, builderCheckRan) {
|
|
|
2135
2851
|
}
|
|
2136
2852
|
if (!verdict.accepted) {
|
|
2137
2853
|
repairs += 1;
|
|
2138
|
-
return
|
|
2854
|
+
return reply3(verdict.feedback, true);
|
|
2139
2855
|
}
|
|
2140
2856
|
if (!builderCheckRan() && !reminded) {
|
|
2141
2857
|
reminded = true;
|
|
2142
|
-
return
|
|
2858
|
+
return reply3(BUILDER_CHECK_FIRST, true);
|
|
2143
2859
|
}
|
|
2144
2860
|
onSubmit({ spec: args, problems: verdict.problems });
|
|
2145
|
-
return
|
|
2861
|
+
return reply3(verdict.problems.length > 0 ? `Spec saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Spec saved.");
|
|
2146
2862
|
}
|
|
2147
2863
|
);
|
|
2148
2864
|
}
|
|
2149
2865
|
function briefTool(review, onSubmit) {
|
|
2150
2866
|
let repairs = 0;
|
|
2151
|
-
return
|
|
2867
|
+
return tool4(
|
|
2152
2868
|
SUBMIT_SPEC_TOOL_NAME,
|
|
2153
2869
|
"Hand in the brief of the result. It replaces the previous version entirely, so always send the FULL brief. TAMsense lints it first; if it returns feedback, the brief was not saved: fix it and call submit_spec again.",
|
|
2154
2870
|
resultBriefSchema.shape,
|
|
@@ -2161,10 +2877,10 @@ function briefTool(review, onSubmit) {
|
|
|
2161
2877
|
}
|
|
2162
2878
|
if (!verdict.accepted) {
|
|
2163
2879
|
repairs += 1;
|
|
2164
|
-
return
|
|
2880
|
+
return reply3(verdict.feedback, true);
|
|
2165
2881
|
}
|
|
2166
2882
|
onSubmit({ spec: args, problems: verdict.problems });
|
|
2167
|
-
return
|
|
2883
|
+
return reply3(verdict.problems.length > 0 ? `Brief saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Brief saved.");
|
|
2168
2884
|
}
|
|
2169
2885
|
);
|
|
2170
2886
|
}
|
|
@@ -2265,10 +2981,13 @@ function usageLimitWatch() {
|
|
|
2265
2981
|
|
|
2266
2982
|
// src/claude.ts
|
|
2267
2983
|
var NO_RESULT_TEXT = "Claude finished without a result.";
|
|
2268
|
-
var
|
|
2984
|
+
var SERVER_NAME2 = "tamsense";
|
|
2269
2985
|
var CAPTION_FIELDS = {
|
|
2270
|
-
title:
|
|
2271
|
-
purpose:
|
|
2986
|
+
title: z5.string().describe('short name of what it shows, at most 60 characters: "Design option 1", "Feature testing", "Feature testing, attempt 2". Never generic names like "screenshot1"'),
|
|
2987
|
+
purpose: z5.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
|
|
2988
|
+
};
|
|
2989
|
+
var NOTES_FIELD = {
|
|
2990
|
+
notes: z5.string().optional().describe('what you tested in it and what happened, at most 600 characters: what you opened, what you clicked or typed, what you expected, and whether it passed or failed. "Opened the disposed requests on the warehouse page, filtered by December, expected 3 rows: got 3, passed. Clicked Export: the file was empty, failed."')
|
|
2272
2991
|
};
|
|
2273
2992
|
var ATTACH_TOOLS = {
|
|
2274
2993
|
screenshot: {
|
|
@@ -2278,32 +2997,32 @@ var ATTACH_TOOLS = {
|
|
|
2278
2997
|
},
|
|
2279
2998
|
replay: {
|
|
2280
2999
|
name: REPLAY_TOOL_NAME,
|
|
2281
|
-
description: "Attach a browser replay (the .rrweb.ndjson file the TAMsense recorder wrote) to the task now, with your own title and
|
|
3000
|
+
description: "Attach a browser replay (the .rrweb.ndjson file the TAMsense recorder wrote) to the task now, with your own title, purpose and notes on what you tested and what happened. Every recording is attached at the end of the run anyway; use this to attach one earlier or to name it better. Close the browser context first so the file is complete.",
|
|
2282
3001
|
path: "absolute path of the .rrweb.ndjson replay file"
|
|
2283
3002
|
},
|
|
2284
3003
|
video: {
|
|
2285
3004
|
name: VIDEO_TOOL_NAME,
|
|
2286
|
-
description: "Attach a browser video (.webm or .mp4, up to 200 MB) to the task now, with your own title and
|
|
3005
|
+
description: "Attach a browser video (.webm or .mp4, up to 200 MB) to the task now, with your own title, purpose and notes on what you tested and what happened. Videos recorded with video: true are attached at the end of the run anyway. Close the browser context first so the file is complete.",
|
|
2287
3006
|
path: "absolute path of the video file"
|
|
2288
3007
|
}
|
|
2289
3008
|
};
|
|
2290
3009
|
function attachTool(kind, attach) {
|
|
2291
3010
|
const spec = ATTACH_TOOLS[kind];
|
|
2292
|
-
return
|
|
3011
|
+
return tool5(
|
|
2293
3012
|
spec.name,
|
|
2294
3013
|
spec.description,
|
|
2295
|
-
{ path:
|
|
3014
|
+
{ path: z5.string().describe(spec.path), ...CAPTION_FIELDS, ...kind === "screenshot" ? {} : NOTES_FIELD },
|
|
2296
3015
|
async (args) => ({ content: [{ type: "text", text: await attach({ kind, ...args }) }] })
|
|
2297
3016
|
);
|
|
2298
3017
|
}
|
|
2299
3018
|
function verdictTool(onVerdict) {
|
|
2300
|
-
return
|
|
3019
|
+
return tool5(
|
|
2301
3020
|
VERDICT_TOOL_NAME,
|
|
2302
3021
|
"Hand in the result of this check. Call it exactly once, at the very end.",
|
|
2303
3022
|
{
|
|
2304
|
-
passed:
|
|
2305
|
-
report:
|
|
2306
|
-
couldNotRun:
|
|
3023
|
+
passed: z5.boolean().describe("true only if nothing must be fixed"),
|
|
3024
|
+
report: z5.string().describe("what you checked and, if it failed, exactly what to fix with file and line"),
|
|
3025
|
+
couldNotRun: z5.boolean().optional().describe("true when the check could not run at all because of this computer: Docker not running, no local setup, or settings that point outside this computer; passed must be false and the report gives the exact reason and what a person should do")
|
|
2307
3026
|
},
|
|
2308
3027
|
async (args) => {
|
|
2309
3028
|
onVerdict(args.couldNotRun ? { passed: false, report: args.report, couldNotRun: true } : { passed: args.passed, report: args.report });
|
|
@@ -2312,13 +3031,13 @@ function verdictTool(onVerdict) {
|
|
|
2312
3031
|
);
|
|
2313
3032
|
}
|
|
2314
3033
|
function handoffTool(targets, onHandoff) {
|
|
2315
|
-
return
|
|
3034
|
+
return tool5(
|
|
2316
3035
|
HANDOFF_TOOL_NAME,
|
|
2317
3036
|
"Hand the task to the next step of the TAMsense board when you finish. The next agent and the task owner read your note. Only one handoff counts: calling it again replaces the earlier one.",
|
|
2318
3037
|
{
|
|
2319
|
-
to:
|
|
2320
|
-
outcome:
|
|
2321
|
-
note:
|
|
3038
|
+
to: z5.string().describe("name or id of the step to hand the task to"),
|
|
3039
|
+
outcome: z5.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
|
|
3040
|
+
note: z5.string().describe("for the next agent and the owner: what was done, what to check, what is open")
|
|
2322
3041
|
},
|
|
2323
3042
|
async (args) => {
|
|
2324
3043
|
const choice = chooseHandoff(targets, args);
|
|
@@ -2331,25 +3050,25 @@ function handoffTool(targets, onHandoff) {
|
|
|
2331
3050
|
);
|
|
2332
3051
|
}
|
|
2333
3052
|
function askPersonTool(asking, waiting) {
|
|
2334
|
-
return
|
|
3053
|
+
return tool5(
|
|
2335
3054
|
ASK_PERSON_TOOL_NAME,
|
|
2336
3055
|
"Ask a person on this TAMsense task when you are stuck or the decision belongs to a person: intent, design, access, or how the team runs something locally. Never ask what the code or the project knowledge already answers. With blocking true the run pauses and frees the computer until the answer comes; with blocking false you keep working on your assumption.",
|
|
2337
3056
|
{
|
|
2338
|
-
to:
|
|
2339
|
-
why:
|
|
2340
|
-
question:
|
|
2341
|
-
context:
|
|
2342
|
-
tried:
|
|
2343
|
-
did:
|
|
2344
|
-
error:
|
|
2345
|
-
result:
|
|
3057
|
+
to: z5.union([z5.number(), z5.string()]).describe("id of the person from the people list in your instructions: the assignee first; a topic question goes to the person whose role fits"),
|
|
3058
|
+
why: z5.string().describe('one line on why this person: "assignee, and the run is on her computer", "Designer, owns empty states"'),
|
|
3059
|
+
question: z5.string().describe("one plain question the person can answer without reading the code"),
|
|
3060
|
+
context: z5.string().optional().describe("one line on what you saw that led to the question"),
|
|
3061
|
+
tried: z5.array(z5.object({
|
|
3062
|
+
did: z5.string().describe("the command or step"),
|
|
3063
|
+
error: z5.string().optional().describe("the exact error line, if there was one"),
|
|
3064
|
+
result: z5.string().describe("what it showed, one line")
|
|
2346
3065
|
})).max(MAX_ASK_TRIES).optional().describe("what you already tried, the most telling first"),
|
|
2347
|
-
options:
|
|
2348
|
-
label:
|
|
2349
|
-
detail:
|
|
3066
|
+
options: z5.array(z5.object({
|
|
3067
|
+
label: z5.string().describe("a short answer the person can pick"),
|
|
3068
|
+
detail: z5.string().describe("one line on what happens if it is picked")
|
|
2350
3069
|
})).max(MAX_ASK_OPTIONS).optional().describe("2 to 4 answers you suggest, the one you would pick first"),
|
|
2351
|
-
blocking:
|
|
2352
|
-
assumption:
|
|
3070
|
+
blocking: z5.boolean().describe("true when you cannot go on without the answer"),
|
|
3071
|
+
assumption: z5.string().optional().describe("when blocking is false: what you continue with until the answer comes")
|
|
2353
3072
|
},
|
|
2354
3073
|
async (args) => {
|
|
2355
3074
|
if (waiting.waitingFor !== null) {
|
|
@@ -2359,16 +3078,16 @@ function askPersonTool(asking, waiting) {
|
|
|
2359
3078
|
if ("error" in choice) {
|
|
2360
3079
|
return { content: [{ type: "text", text: choice.error }], isError: true };
|
|
2361
3080
|
}
|
|
2362
|
-
let
|
|
3081
|
+
let reply4;
|
|
2363
3082
|
try {
|
|
2364
|
-
|
|
3083
|
+
reply4 = await asking.send(choice.body);
|
|
2365
3084
|
} catch (error) {
|
|
2366
3085
|
return { content: [{ type: "text", text: `Could not send the question: ${error instanceof Error ? error.message : String(error)}. Decide yourself and say so in your report.` }], isError: true };
|
|
2367
3086
|
}
|
|
2368
3087
|
if (choice.body.blocking) {
|
|
2369
|
-
waiting.waitingFor =
|
|
3088
|
+
waiting.waitingFor = reply4.id;
|
|
2370
3089
|
}
|
|
2371
|
-
return { content: [{ type: "text", text: askedText(
|
|
3090
|
+
return { content: [{ type: "text", text: askedText(reply4.name, choice.body) }] };
|
|
2372
3091
|
}
|
|
2373
3092
|
);
|
|
2374
3093
|
}
|
|
@@ -2412,11 +3131,12 @@ function tamsenseServer(request, submitted) {
|
|
|
2412
3131
|
submitted.handoff = handoff;
|
|
2413
3132
|
})] : [],
|
|
2414
3133
|
...request.results ? resultToolset(request.results) : [],
|
|
2415
|
-
...request.handOver ? [handOverTool(request.handOver)] : []
|
|
3134
|
+
...request.handOver ? [handOverTool(request.handOver)] : [],
|
|
3135
|
+
...request.schedules ? [proposeScheduleTool(request.schedules, submitted)] : []
|
|
2416
3136
|
];
|
|
2417
3137
|
return {
|
|
2418
|
-
server:
|
|
2419
|
-
allowed: tools.map((item) => `mcp__${
|
|
3138
|
+
server: createSdkMcpServer2({ name: SERVER_NAME2, version: "1.0.0", tools }),
|
|
3139
|
+
allowed: tools.map((item) => `mcp__${SERVER_NAME2}__${item.name}`)
|
|
2420
3140
|
};
|
|
2421
3141
|
}
|
|
2422
3142
|
function modelOptions(request) {
|
|
@@ -2452,7 +3172,7 @@ async function attempt(request, context, resume) {
|
|
|
2452
3172
|
// режим auto сам решает, что можно; о чём он спросил бы человека, решают правила проекта или люди задачи
|
|
2453
3173
|
canUseTool: request.permissions ? askPermission(request.permissions, () => context.lastWords.text, request.cwd) : denyWhenAsked,
|
|
2454
3174
|
plugins: [{ type: "local", path: request.library }],
|
|
2455
|
-
mcpServers: { [
|
|
3175
|
+
mcpServers: { [SERVER_NAME2]: tamsense.server },
|
|
2456
3176
|
allowedTools: tamsense.allowed,
|
|
2457
3177
|
abortController: context.abortController,
|
|
2458
3178
|
env: request.env,
|
|
@@ -2505,7 +3225,7 @@ function claudeRunner(startQuery) {
|
|
|
2505
3225
|
request.input.close();
|
|
2506
3226
|
abortController.abort();
|
|
2507
3227
|
}, { once: true });
|
|
2508
|
-
const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false, waitingFor: null };
|
|
3228
|
+
const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false, waitingFor: null, waitingForSchedule: null };
|
|
2509
3229
|
const limit = usageLimitWatch();
|
|
2510
3230
|
const context = { startQuery, abortController, submitted, limit, lastWords: { text: "" } };
|
|
2511
3231
|
try {
|
|
@@ -2515,22 +3235,22 @@ function claudeRunner(startQuery) {
|
|
|
2515
3235
|
}
|
|
2516
3236
|
const pausedUntil = limit.pausedUntil(result.outcome.completed);
|
|
2517
3237
|
const outcome = pausedUntil ? { ...result.outcome, completed: false, report: USAGE_LIMIT_REPORT } : result.outcome;
|
|
2518
|
-
return { ...outcome, verdict: submitted.verdict, handoff: submitted.handoff, questions: submitted.questions, spec: submitted.spec, sessionId: result.sessionId, pausedUntil, waitingFor: submitted.waitingFor };
|
|
3238
|
+
return { ...outcome, verdict: submitted.verdict, handoff: submitted.handoff, questions: submitted.questions, spec: submitted.spec, sessionId: result.sessionId, pausedUntil, waitingFor: submitted.waitingFor, waitingForSchedule: submitted.waitingForSchedule };
|
|
2519
3239
|
} finally {
|
|
2520
3240
|
request.input.close();
|
|
2521
3241
|
}
|
|
2522
3242
|
};
|
|
2523
3243
|
}
|
|
2524
|
-
var runClaude = claudeRunner(
|
|
3244
|
+
var runClaude = claudeRunner(query3);
|
|
2525
3245
|
|
|
2526
3246
|
// src/config.ts
|
|
2527
|
-
import { mkdir as
|
|
3247
|
+
import { mkdir as mkdir4, readFile as readFile3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
|
|
2528
3248
|
import { homedir as homedir2 } from "os";
|
|
2529
|
-
import { dirname as dirname2, join as
|
|
3249
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
2530
3250
|
var OWNER_ONLY_FILE = 384;
|
|
2531
3251
|
var OWNER_ONLY_FOLDER = 448;
|
|
2532
3252
|
function configPath(home = homedir2()) {
|
|
2533
|
-
return
|
|
3253
|
+
return join6(home, ".tamsense", "runner.json");
|
|
2534
3254
|
}
|
|
2535
3255
|
function isRecord2(value) {
|
|
2536
3256
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -2557,7 +3277,7 @@ async function readConfig(path = configPath()) {
|
|
|
2557
3277
|
}
|
|
2558
3278
|
}
|
|
2559
3279
|
async function writeConfig(config, path = configPath()) {
|
|
2560
|
-
await
|
|
3280
|
+
await mkdir4(dirname2(path), { recursive: true, mode: OWNER_ONLY_FOLDER });
|
|
2561
3281
|
await writeFile3(path, `${JSON.stringify(config, null, 4)}
|
|
2562
3282
|
`, { mode: OWNER_ONLY_FILE });
|
|
2563
3283
|
}
|
|
@@ -2579,14 +3299,14 @@ async function login(tools, request) {
|
|
|
2579
3299
|
tools.openBrowser(pageUrl);
|
|
2580
3300
|
while (tools.now() < expiresAt) {
|
|
2581
3301
|
await tools.sleep(pairing.intervalSeconds * MS_PER_SECOND);
|
|
2582
|
-
const
|
|
2583
|
-
if (
|
|
2584
|
-
return { token:
|
|
3302
|
+
const reply4 = await tools.api.request("POST", PAIRING_TOKEN_PATH, { deviceCode: pairing.deviceCode });
|
|
3303
|
+
if (reply4.status === "connected") {
|
|
3304
|
+
return { token: reply4.token, device: reply4.device };
|
|
2585
3305
|
}
|
|
2586
|
-
if (
|
|
3306
|
+
if (reply4.status === "denied") {
|
|
2587
3307
|
throw new Error(DENIED_TEXT);
|
|
2588
3308
|
}
|
|
2589
|
-
if (
|
|
3309
|
+
if (reply4.status === "expired") {
|
|
2590
3310
|
break;
|
|
2591
3311
|
}
|
|
2592
3312
|
}
|
|
@@ -2594,15 +3314,15 @@ async function login(tools, request) {
|
|
|
2594
3314
|
}
|
|
2595
3315
|
|
|
2596
3316
|
// src/recorderEnv.ts
|
|
2597
|
-
import { existsSync as
|
|
2598
|
-
import { join as
|
|
3317
|
+
import { existsSync as existsSync6 } from "fs";
|
|
3318
|
+
import { join as join8 } from "path";
|
|
2599
3319
|
import { fileURLToPath } from "url";
|
|
2600
3320
|
|
|
2601
3321
|
// src/recorder.ts
|
|
2602
3322
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2603
3323
|
import Module, { createRequire } from "module";
|
|
2604
|
-
import { basename as
|
|
2605
|
-
import
|
|
3324
|
+
import { basename as basename3, dirname as dirname3, join as join7 } from "path";
|
|
3325
|
+
import process4 from "process";
|
|
2606
3326
|
var RECORDINGS_DIR_ENV = "TAMSENSE_RECORDINGS_DIR";
|
|
2607
3327
|
var SERVER_ENV = "TAMSENSE_SERVER";
|
|
2608
3328
|
var DEFAULT_TITLE_ENV = "TAMSENSE_RECORDING_TITLE";
|
|
@@ -2612,6 +3332,7 @@ var REPLAY_SUFFIX = ".rrweb.ndjson";
|
|
|
2612
3332
|
var META_SUFFIX = ".json";
|
|
2613
3333
|
var MAX_TITLE_LENGTH = 60;
|
|
2614
3334
|
var MAX_PURPOSE_LENGTH = 160;
|
|
3335
|
+
var MAX_NOTES_LENGTH = 600;
|
|
2615
3336
|
var DEFAULT_TRACKER_ORIGINS = ["https://my.tamsense.com"];
|
|
2616
3337
|
var READ_METHODS = ["GET", "HEAD", "OPTIONS"];
|
|
2617
3338
|
var BINDING = "__tamsenseRecord";
|
|
@@ -2619,21 +3340,24 @@ var STATE_KEY = /* @__PURE__ */ Symbol.for("tamsense.recorder");
|
|
|
2619
3340
|
var FLUSH_MS = 100;
|
|
2620
3341
|
var MAX_SESSION_BYTES = 300 * 1024 * 1024;
|
|
2621
3342
|
var GAP_CHECK_MS = 1500;
|
|
3343
|
+
var RRWEB_INCREMENTAL = 3;
|
|
2622
3344
|
var RRWEB_META = 4;
|
|
3345
|
+
var RRWEB_SOURCE_MOUSE_INTERACTION = 2;
|
|
3346
|
+
var RRWEB_SOURCE_INPUT = 5;
|
|
2623
3347
|
function state() {
|
|
2624
3348
|
const holder = globalThis;
|
|
2625
3349
|
holder[STATE_KEY] ??= { contexts: /* @__PURE__ */ new WeakMap(), pages: /* @__PURE__ */ new WeakMap(), instrumented: /* @__PURE__ */ new WeakSet(), script: null, hooked: false };
|
|
2626
3350
|
return holder[STATE_KEY];
|
|
2627
3351
|
}
|
|
2628
3352
|
function recordingsDir() {
|
|
2629
|
-
const dir =
|
|
3353
|
+
const dir = process4.env[RECORDINGS_DIR_ENV];
|
|
2630
3354
|
if (!dir) {
|
|
2631
3355
|
throw new Error(`${RECORDINGS_DIR_ENV} is not set: recordings work only inside a TAMsense run`);
|
|
2632
3356
|
}
|
|
2633
3357
|
return dir;
|
|
2634
3358
|
}
|
|
2635
3359
|
function warn(text2) {
|
|
2636
|
-
|
|
3360
|
+
process4.stderr.write(`[tamsense recorder] ${text2}
|
|
2637
3361
|
`);
|
|
2638
3362
|
}
|
|
2639
3363
|
function isTrackerIngest(url, tamsenseOrigins) {
|
|
@@ -2649,7 +3373,7 @@ function isLocalHost(hostname2) {
|
|
|
2649
3373
|
function isExternalWrite(method, url) {
|
|
2650
3374
|
return !READ_METHODS.includes(method.toUpperCase()) && !isLocalHost(url.hostname);
|
|
2651
3375
|
}
|
|
2652
|
-
function trackerOrigins(env =
|
|
3376
|
+
function trackerOrigins(env = process4.env) {
|
|
2653
3377
|
const origins = new Set(DEFAULT_TRACKER_ORIGINS);
|
|
2654
3378
|
try {
|
|
2655
3379
|
if (env[SERVER_ENV]) {
|
|
@@ -2670,11 +3394,18 @@ function checkCaption(caption) {
|
|
|
2670
3394
|
}
|
|
2671
3395
|
return { title, purpose };
|
|
2672
3396
|
}
|
|
3397
|
+
function checkNotes(notes) {
|
|
3398
|
+
const text2 = typeof notes === "string" ? notes.trim() : "";
|
|
3399
|
+
if (text2.length > MAX_NOTES_LENGTH) {
|
|
3400
|
+
throw new Error(`Keep the notes within ${MAX_NOTES_LENGTH} characters: what you opened, clicked and expected, and whether it passed.`);
|
|
3401
|
+
}
|
|
3402
|
+
return text2;
|
|
3403
|
+
}
|
|
2673
3404
|
function clip2(text2, max) {
|
|
2674
3405
|
return text2.length > max ? `${text2.slice(0, max - 1).trimEnd()}\u2026` : text2;
|
|
2675
3406
|
}
|
|
2676
|
-
function automaticCaption(env =
|
|
2677
|
-
const from = script2 ? `, opened by ${
|
|
3407
|
+
function automaticCaption(env = process4.env, script2 = process4.argv[1]) {
|
|
3408
|
+
const from = script2 ? `, opened by ${basename3(script2)}` : "";
|
|
2678
3409
|
return {
|
|
2679
3410
|
title: clip2(env[DEFAULT_TITLE_ENV]?.trim() || "Browser session", MAX_TITLE_LENGTH),
|
|
2680
3411
|
purpose: clip2(`${env[DEFAULT_PURPOSE_ENV]?.trim() || "Recorded automatically while the agent used a browser"}${from}`, MAX_PURPOSE_LENGTH)
|
|
@@ -2682,7 +3413,7 @@ function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
|
|
|
2682
3413
|
}
|
|
2683
3414
|
function rrwebSource() {
|
|
2684
3415
|
const entry = createRequire(import.meta.url).resolve("rrweb");
|
|
2685
|
-
return readFileSync(
|
|
3416
|
+
return readFileSync(join7(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
|
|
2686
3417
|
}
|
|
2687
3418
|
function pageScript(rrweb) {
|
|
2688
3419
|
return `(() => {
|
|
@@ -2700,11 +3431,22 @@ const send = (extra) => {
|
|
|
2700
3431
|
buffer = [];
|
|
2701
3432
|
return window.${BINDING}(JSON.stringify({ events, ...extra })).catch(() => {});
|
|
2702
3433
|
};
|
|
3434
|
+
const urgent = (event) => event.type === ${RRWEB_INCREMENTAL} && (event.data.source === ${RRWEB_SOURCE_MOUSE_INTERACTION} || event.data.source === ${RRWEB_SOURCE_INPUT});
|
|
3435
|
+
const placeAtTarget = (interaction) => {
|
|
3436
|
+
if (interaction.x !== 0 || interaction.y !== 0) return;
|
|
3437
|
+
const node = rrweb.record.mirror.getNode(interaction.id);
|
|
3438
|
+
if (!node || typeof node.getBoundingClientRect !== 'function') return;
|
|
3439
|
+
const box = node.getBoundingClientRect();
|
|
3440
|
+
interaction.x = Math.round(box.left + box.width / 2);
|
|
3441
|
+
interaction.y = Math.round(box.top + box.height / 2);
|
|
3442
|
+
};
|
|
2703
3443
|
rrweb.record({
|
|
2704
3444
|
emit(event) {
|
|
2705
3445
|
buffer.push(event);
|
|
2706
|
-
if (
|
|
3446
|
+
if (urgent(event)) send();
|
|
3447
|
+
else if (!timer) timer = setTimeout(send, ${FLUSH_MS});
|
|
2707
3448
|
},
|
|
3449
|
+
hooks: { mouseInteraction: placeAtTarget },
|
|
2708
3450
|
maskInputOptions: { password: true },
|
|
2709
3451
|
});
|
|
2710
3452
|
const crossOrigin = (frame) => { try { return new URL(frame.src, location.href).origin !== location.origin; } catch { return false; } };
|
|
@@ -2717,6 +3459,7 @@ const checkGaps = () => {
|
|
|
2717
3459
|
};
|
|
2718
3460
|
window.__tamsenseFlush = () => send();
|
|
2719
3461
|
window.__tamsenseMark = (label) => { rrweb.record.addCustomEvent('${MARK_TAG}', { label }); return send(); };
|
|
3462
|
+
addEventListener('beforeunload', () => { send(); });
|
|
2720
3463
|
addEventListener('pagehide', () => { send(); });
|
|
2721
3464
|
addEventListener('load', () => setTimeout(checkGaps, ${GAP_CHECK_MS}));
|
|
2722
3465
|
})();`;
|
|
@@ -2733,9 +3476,9 @@ var Session = class {
|
|
|
2733
3476
|
bytes = 0;
|
|
2734
3477
|
constructor(dir, recording, url) {
|
|
2735
3478
|
const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
2736
|
-
this.file =
|
|
2737
|
-
this.metaFile =
|
|
2738
|
-
this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, blockedExternalWrites: 0, truncated: false, automatic: recording.automatic };
|
|
3479
|
+
this.file = join7(dir, `${id}${REPLAY_SUFFIX}`);
|
|
3480
|
+
this.metaFile = join7(dir, `${id}${META_SUFFIX}`);
|
|
3481
|
+
this.meta = { version: 1, id, ...recording.caption, startedAt: Date.now(), endedAt: null, url, marks: [], video: null, gaps: [], blockedTrackerRequests: 0, blockedExternalWrites: 0, truncated: false, automatic: recording.automatic, notes: "" };
|
|
2739
3482
|
writeFileSync(this.file, "");
|
|
2740
3483
|
this.save();
|
|
2741
3484
|
}
|
|
@@ -2779,8 +3522,8 @@ var Session = class {
|
|
|
2779
3522
|
}
|
|
2780
3523
|
}
|
|
2781
3524
|
summary() {
|
|
2782
|
-
const { title, purpose, video, marks, gaps, blockedTrackerRequests, blockedExternalWrites } = this.meta;
|
|
2783
|
-
return { title, purpose, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests, blockedExternalWrites };
|
|
3525
|
+
const { title, purpose, notes, video, marks, gaps, blockedTrackerRequests, blockedExternalWrites } = this.meta;
|
|
3526
|
+
return { title, purpose, notes, replay: this.file, video, marks: marks.length, gaps, blockedTrackerRequests, blockedExternalWrites };
|
|
2784
3527
|
}
|
|
2785
3528
|
};
|
|
2786
3529
|
function countBlocked(route, counter) {
|
|
@@ -2899,7 +3642,7 @@ function installAutoRecording() {
|
|
|
2899
3642
|
return exports;
|
|
2900
3643
|
};
|
|
2901
3644
|
}
|
|
2902
|
-
if (
|
|
3645
|
+
if (process4.env[RECORDINGS_DIR_ENV]) {
|
|
2903
3646
|
installAutoRecording();
|
|
2904
3647
|
}
|
|
2905
3648
|
|
|
@@ -2907,10 +3650,10 @@ if (process2.env[RECORDINGS_DIR_ENV]) {
|
|
|
2907
3650
|
var RECORDER_ENV = "TAMSENSE_RECORDER";
|
|
2908
3651
|
function bundledRecorder() {
|
|
2909
3652
|
const path = fileURLToPath(new URL("./recorder.cjs", import.meta.url));
|
|
2910
|
-
return
|
|
3653
|
+
return existsSync6(path) ? path : null;
|
|
2911
3654
|
}
|
|
2912
3655
|
function recordingsFolder(home, runId) {
|
|
2913
|
-
return
|
|
3656
|
+
return join8(home, ".tamsense", "recordings", `run-${runId}`);
|
|
2914
3657
|
}
|
|
2915
3658
|
function nodeOptionsWithRecorder(recorder, nodeOptions) {
|
|
2916
3659
|
return [nodeOptions?.trim(), `--require "${recorder.replaceAll("\\", "/")}"`].filter(Boolean).join(" ");
|
|
@@ -2940,7 +3683,7 @@ function describeDevice(identity) {
|
|
|
2940
3683
|
import { spawn as spawn3 } from "child_process";
|
|
2941
3684
|
import { hostname } from "os";
|
|
2942
3685
|
import { delimiter as delimiter2 } from "path";
|
|
2943
|
-
import { setTimeout as
|
|
3686
|
+
import { setTimeout as wait2 } from "timers/promises";
|
|
2944
3687
|
var PLATFORM_LABELS = {
|
|
2945
3688
|
win32: "Windows",
|
|
2946
3689
|
darwin: "macOS",
|
|
@@ -2976,14 +3719,14 @@ function withNodeOnPath(env, nodeDir, separator = delimiter2) {
|
|
|
2976
3719
|
return { ...env, [key]: [...entries, nodeDir].join(separator) };
|
|
2977
3720
|
}
|
|
2978
3721
|
async function sleep(ms, signal) {
|
|
2979
|
-
await
|
|
3722
|
+
await wait2(ms, void 0, { signal }).catch(() => {
|
|
2980
3723
|
});
|
|
2981
3724
|
}
|
|
2982
3725
|
|
|
2983
3726
|
// src/update.ts
|
|
2984
3727
|
import { spawn as spawn4 } from "child_process";
|
|
2985
3728
|
import { readFileSync as readFileSync2 } from "fs";
|
|
2986
|
-
import
|
|
3729
|
+
import process5 from "process";
|
|
2987
3730
|
var PACKAGE_NAME = "@tamsensedev/runner";
|
|
2988
3731
|
var RELAUNCHED_ENV = "TAMSENSE_RELAUNCHED";
|
|
2989
3732
|
var NO_UPDATE_ENV = "TAMSENSE_NO_UPDATE";
|
|
@@ -3035,19 +3778,19 @@ function relaunchArgs(argv) {
|
|
|
3035
3778
|
return ["--yes", `${PACKAGE_NAME}@latest`, ...argv];
|
|
3036
3779
|
}
|
|
3037
3780
|
function relaunch(argv, env) {
|
|
3038
|
-
const isWindows =
|
|
3039
|
-
return new Promise((
|
|
3781
|
+
const isWindows = process5.platform === "win32";
|
|
3782
|
+
return new Promise((resolve5, reject) => {
|
|
3040
3783
|
const child = spawn4(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
|
|
3041
3784
|
const ignoreInterrupt = () => {
|
|
3042
3785
|
};
|
|
3043
|
-
|
|
3786
|
+
process5.on("SIGINT", ignoreInterrupt);
|
|
3044
3787
|
child.on("error", (error) => {
|
|
3045
|
-
|
|
3788
|
+
process5.off("SIGINT", ignoreInterrupt);
|
|
3046
3789
|
reject(error);
|
|
3047
3790
|
});
|
|
3048
3791
|
child.on("close", (code) => {
|
|
3049
|
-
|
|
3050
|
-
|
|
3792
|
+
process5.off("SIGINT", ignoreInterrupt);
|
|
3793
|
+
resolve5(code ?? 1);
|
|
3051
3794
|
});
|
|
3052
3795
|
});
|
|
3053
3796
|
}
|
|
@@ -3055,7 +3798,7 @@ function relaunch(argv, env) {
|
|
|
3055
3798
|
// src/worker.ts
|
|
3056
3799
|
import { rm as rm6 } from "fs/promises";
|
|
3057
3800
|
import { dirname as dirname5 } from "path";
|
|
3058
|
-
import
|
|
3801
|
+
import process6 from "process";
|
|
3059
3802
|
|
|
3060
3803
|
// src/feedQueue.ts
|
|
3061
3804
|
var MAX_BATCH = 200;
|
|
@@ -3064,6 +3807,7 @@ function feedQueue(send) {
|
|
|
3064
3807
|
let seq = 0;
|
|
3065
3808
|
let waiting = [];
|
|
3066
3809
|
let sending = Promise.resolve();
|
|
3810
|
+
let queued = false;
|
|
3067
3811
|
async function drain() {
|
|
3068
3812
|
while (waiting.length > 0) {
|
|
3069
3813
|
const batch = waiting.slice(0, MAX_BATCH);
|
|
@@ -3087,16 +3831,22 @@ function feedQueue(send) {
|
|
|
3087
3831
|
return entry;
|
|
3088
3832
|
},
|
|
3089
3833
|
flush() {
|
|
3090
|
-
|
|
3834
|
+
if (!queued) {
|
|
3835
|
+
queued = true;
|
|
3836
|
+
sending = sending.then(() => {
|
|
3837
|
+
queued = false;
|
|
3838
|
+
return drain();
|
|
3839
|
+
});
|
|
3840
|
+
}
|
|
3091
3841
|
return sending;
|
|
3092
3842
|
}
|
|
3093
3843
|
};
|
|
3094
3844
|
}
|
|
3095
3845
|
|
|
3096
3846
|
// src/library.ts
|
|
3097
|
-
import { existsSync as
|
|
3098
|
-
import { cp, mkdir as
|
|
3099
|
-
import { join as
|
|
3847
|
+
import { existsSync as existsSync7 } from "fs";
|
|
3848
|
+
import { cp, mkdir as mkdir5, rm as rm5 } from "fs/promises";
|
|
3849
|
+
import { join as join9 } from "path";
|
|
3100
3850
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3101
3851
|
var LIBRARY_PATH = fileURLToPath2(new URL("../library", import.meta.url));
|
|
3102
3852
|
var SKILL_NAME = /^[a-z0-9-]+$/;
|
|
@@ -3109,18 +3859,18 @@ function allowedSkills(stage) {
|
|
|
3109
3859
|
return skills && [.../* @__PURE__ */ new Set([...skills, ...STAGE_SKILLS[stage.kind] ?? []])].filter((name) => SKILL_NAME.test(name));
|
|
3110
3860
|
}
|
|
3111
3861
|
function roleLibraryFolder(home, runId) {
|
|
3112
|
-
return
|
|
3862
|
+
return join9(home, ".tamsense", "libraries", `run-${runId}`);
|
|
3113
3863
|
}
|
|
3114
3864
|
async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
|
|
3115
3865
|
if (skills === null) {
|
|
3116
3866
|
return source;
|
|
3117
3867
|
}
|
|
3118
3868
|
await rm5(folder, { recursive: true, force: true });
|
|
3119
|
-
await
|
|
3120
|
-
await cp(
|
|
3121
|
-
await cp(
|
|
3122
|
-
for (const skill of skills.filter((name) => SKILL_NAME.test(name) &&
|
|
3123
|
-
await cp(
|
|
3869
|
+
await mkdir5(join9(folder, "skills"), { recursive: true });
|
|
3870
|
+
await cp(join9(source, ".claude-plugin"), join9(folder, ".claude-plugin"), { recursive: true });
|
|
3871
|
+
await cp(join9(source, "agents"), join9(folder, "agents"), { recursive: true });
|
|
3872
|
+
for (const skill of skills.filter((name) => SKILL_NAME.test(name) && existsSync7(join9(source, "skills", name)))) {
|
|
3873
|
+
await cp(join9(source, "skills", skill), join9(folder, "skills", skill), { recursive: true });
|
|
3124
3874
|
}
|
|
3125
3875
|
return folder;
|
|
3126
3876
|
}
|
|
@@ -3128,8 +3878,8 @@ async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
|
|
|
3128
3878
|
// src/recordings.ts
|
|
3129
3879
|
import { randomUUID } from "crypto";
|
|
3130
3880
|
import { createReadStream } from "fs";
|
|
3131
|
-
import { readdir, readFile as readFile4, stat as stat2, writeFile as writeFile4 } from "fs/promises";
|
|
3132
|
-
import { basename as
|
|
3881
|
+
import { readdir as readdir2, readFile as readFile4, stat as stat2, writeFile as writeFile4 } from "fs/promises";
|
|
3882
|
+
import { basename as basename4, dirname as dirname4, extname as extname2, isAbsolute as isAbsolute3, join as join10 } from "path";
|
|
3133
3883
|
import { createInterface as createInterface2 } from "readline";
|
|
3134
3884
|
import { Readable } from "stream";
|
|
3135
3885
|
import { promisify } from "util";
|
|
@@ -3186,11 +3936,19 @@ async function* replayChunks(lines, scan, targetBytes = CHUNK_TARGET_BYTES) {
|
|
|
3186
3936
|
`;
|
|
3187
3937
|
}
|
|
3188
3938
|
}
|
|
3939
|
+
function recordingNotes(meta) {
|
|
3940
|
+
const notes = meta?.notes?.trim();
|
|
3941
|
+
if (notes) {
|
|
3942
|
+
return notes;
|
|
3943
|
+
}
|
|
3944
|
+
const marks = meta?.marks ?? [];
|
|
3945
|
+
return marks.length > 0 ? clip2(`Steps marked: ${marks.map((mark) => mark.label).join("; ")}.`, MAX_NOTES_LENGTH) : "";
|
|
3946
|
+
}
|
|
3189
3947
|
function marksFrom(marks, startTs) {
|
|
3190
3948
|
return marks.map((mark) => ({ at: Math.max(0, mark.ts - startTs), label: mark.label })).sort((a, b) => a.at - b.at);
|
|
3191
3949
|
}
|
|
3192
3950
|
function sessionIdOf(file) {
|
|
3193
|
-
return
|
|
3951
|
+
return basename4(file).replace(REPLAY_SUFFIX, "");
|
|
3194
3952
|
}
|
|
3195
3953
|
async function readMeta(file) {
|
|
3196
3954
|
try {
|
|
@@ -3201,12 +3959,12 @@ async function readMeta(file) {
|
|
|
3201
3959
|
}
|
|
3202
3960
|
async function metaFor(kind, file) {
|
|
3203
3961
|
if (kind === "replay") {
|
|
3204
|
-
return readMeta(
|
|
3962
|
+
return readMeta(join10(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
|
|
3205
3963
|
}
|
|
3206
|
-
const folder =
|
|
3207
|
-
for (const name of await
|
|
3964
|
+
const folder = basename4(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
|
|
3965
|
+
for (const name of await readdir2(folder).catch(() => [])) {
|
|
3208
3966
|
if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
|
|
3209
|
-
const meta = await readMeta(
|
|
3967
|
+
const meta = await readMeta(join10(folder, name));
|
|
3210
3968
|
if (meta?.video === file) {
|
|
3211
3969
|
return meta;
|
|
3212
3970
|
}
|
|
@@ -3216,7 +3974,7 @@ async function metaFor(kind, file) {
|
|
|
3216
3974
|
}
|
|
3217
3975
|
async function ledger(dir) {
|
|
3218
3976
|
try {
|
|
3219
|
-
return new Set(JSON.parse(await readFile4(
|
|
3977
|
+
return new Set(JSON.parse(await readFile4(join10(dir, LEDGER_FILE), "utf8")));
|
|
3220
3978
|
} catch {
|
|
3221
3979
|
return /* @__PURE__ */ new Set();
|
|
3222
3980
|
}
|
|
@@ -3224,21 +3982,21 @@ async function ledger(dir) {
|
|
|
3224
3982
|
async function remember(dir, file) {
|
|
3225
3983
|
const done2 = await ledger(dir);
|
|
3226
3984
|
done2.add(file);
|
|
3227
|
-
await writeFile4(
|
|
3985
|
+
await writeFile4(join10(dir, LEDGER_FILE), JSON.stringify([...done2]));
|
|
3228
3986
|
}
|
|
3229
3987
|
async function nonEmpty(file) {
|
|
3230
3988
|
return file !== null && ((await stat2(file).catch(() => null))?.size ?? 0) > 0;
|
|
3231
3989
|
}
|
|
3232
3990
|
async function pendingRecordings(dir) {
|
|
3233
3991
|
const done2 = await ledger(dir);
|
|
3234
|
-
const names = (await
|
|
3992
|
+
const names = (await readdir2(dir).catch(() => [])).sort();
|
|
3235
3993
|
const pending = [];
|
|
3236
3994
|
for (const name of names) {
|
|
3237
3995
|
if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
|
|
3238
3996
|
continue;
|
|
3239
3997
|
}
|
|
3240
|
-
const meta = await readMeta(
|
|
3241
|
-
const replay =
|
|
3998
|
+
const meta = await readMeta(join10(dir, name));
|
|
3999
|
+
const replay = join10(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
|
|
3242
4000
|
if (!done2.has(replay) && await nonEmpty(replay)) {
|
|
3243
4001
|
pending.push({ kind: "replay", file: replay, meta });
|
|
3244
4002
|
}
|
|
@@ -3248,7 +4006,7 @@ async function pendingRecordings(dir) {
|
|
|
3248
4006
|
}
|
|
3249
4007
|
return pending;
|
|
3250
4008
|
}
|
|
3251
|
-
async function uploadReplay(upload, file,
|
|
4009
|
+
async function uploadReplay(upload, file, label) {
|
|
3252
4010
|
const id = randomUUID();
|
|
3253
4011
|
const scan = { startTs: Number.POSITIVE_INFINITY, snapshot: false, marks: [] };
|
|
3254
4012
|
const lines = createInterface2({ input: createReadStream(file, "utf8"), crlfDelay: Infinity });
|
|
@@ -3258,9 +4016,9 @@ async function uploadReplay(upload, file, caption) {
|
|
|
3258
4016
|
if (!scan.snapshot) {
|
|
3259
4017
|
throw new Error("This replay has no page snapshot, so it cannot be played. Was the page ever opened?");
|
|
3260
4018
|
}
|
|
3261
|
-
return finish(upload, { kind: "replay", upload: id, name:
|
|
4019
|
+
return finish(upload, { kind: "replay", upload: id, name: basename4(file), ...label, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
|
|
3262
4020
|
}
|
|
3263
|
-
async function uploadVideo(upload, file,
|
|
4021
|
+
async function uploadVideo(upload, file, label, meta) {
|
|
3264
4022
|
const contentType = VIDEO_TYPES_BY_EXTENSION[extname2(file).toLowerCase()];
|
|
3265
4023
|
const info = await stat2(file);
|
|
3266
4024
|
if (info.size > MAX_VIDEO_BYTES) {
|
|
@@ -3273,13 +4031,13 @@ async function uploadVideo(upload, file, caption, meta) {
|
|
|
3273
4031
|
const endedAt = meta?.endedAt ?? info.mtimeMs;
|
|
3274
4032
|
const durationMs = Math.max(0, Math.round(endedAt - startedAt));
|
|
3275
4033
|
const marks = marksFrom(meta?.marks ?? [], startedAt).filter((mark) => mark.at <= durationMs);
|
|
3276
|
-
return finish(upload, { kind: "video", upload: id, name:
|
|
4034
|
+
return finish(upload, { kind: "video", upload: id, name: basename4(file), ...label, contentType, durationMs, marks });
|
|
3277
4035
|
}
|
|
3278
4036
|
async function finish(upload, body) {
|
|
3279
4037
|
return (await upload.api.request("POST", runPath(upload.runId, "recordings"), body)).id;
|
|
3280
4038
|
}
|
|
3281
4039
|
async function attachRecording(api, runId, recordingsDir2, request) {
|
|
3282
|
-
if (!
|
|
4040
|
+
if (!isAbsolute3(request.path)) {
|
|
3283
4041
|
return `Give the absolute path of the ${request.kind === "replay" ? "replay (.rrweb.ndjson)" : "video (.webm or .mp4)"} file.`;
|
|
3284
4042
|
}
|
|
3285
4043
|
const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) : extname2(request.path).toLowerCase() in VIDEO_TYPES_BY_EXTENSION;
|
|
@@ -3290,15 +4048,18 @@ async function attachRecording(api, runId, recordingsDir2, request) {
|
|
|
3290
4048
|
return `There is no recording at ${request.path}. Close the browser context first (closeRecorded) so the file is complete.`;
|
|
3291
4049
|
}
|
|
3292
4050
|
let caption;
|
|
4051
|
+
let notes;
|
|
3293
4052
|
try {
|
|
3294
4053
|
caption = checkCaption(request);
|
|
4054
|
+
notes = checkNotes(request.notes);
|
|
3295
4055
|
} catch (error) {
|
|
3296
4056
|
return error instanceof Error ? error.message : String(error);
|
|
3297
4057
|
}
|
|
3298
4058
|
try {
|
|
3299
4059
|
const meta = await metaFor(request.kind, request.path);
|
|
4060
|
+
const label = { ...caption, notes: notes || recordingNotes(meta) };
|
|
3300
4061
|
const upload = { api, runId };
|
|
3301
|
-
await (request.kind === "replay" ? uploadReplay(upload, request.path,
|
|
4062
|
+
await (request.kind === "replay" ? uploadReplay(upload, request.path, label) : uploadVideo(upload, request.path, label, meta));
|
|
3302
4063
|
await remember(recordingsDir2, request.path);
|
|
3303
4064
|
} catch (error) {
|
|
3304
4065
|
return `Could not attach the ${request.kind}: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -3330,8 +4091,9 @@ async function uploadPendingRecordings(api, runId, dir) {
|
|
|
3330
4091
|
title: numbered(`${recording.kind}:${title}`, title, used),
|
|
3331
4092
|
purpose: recording.meta?.purpose || "Recorded automatically while the agent used a browser"
|
|
3332
4093
|
});
|
|
4094
|
+
const label = { ...caption, notes: recordingNotes(recording.meta) };
|
|
3333
4095
|
try {
|
|
3334
|
-
await (recording.kind === "replay" ? uploadReplay(upload, recording.file,
|
|
4096
|
+
await (recording.kind === "replay" ? uploadReplay(upload, recording.file, label) : uploadVideo(upload, recording.file, label, recording.meta));
|
|
3335
4097
|
await remember(dir, recording.file);
|
|
3336
4098
|
report.uploaded += 1;
|
|
3337
4099
|
} catch (error) {
|
|
@@ -3348,7 +4110,7 @@ async function uploadPendingRecordings(api, runId, dir) {
|
|
|
3348
4110
|
|
|
3349
4111
|
// src/screenshot.ts
|
|
3350
4112
|
import { readFile as readFile5, stat as stat3 } from "fs/promises";
|
|
3351
|
-
import { basename as
|
|
4113
|
+
import { basename as basename5, extname as extname3, isAbsolute as isAbsolute4 } from "path";
|
|
3352
4114
|
var TYPES_BY_EXTENSION2 = {
|
|
3353
4115
|
".png": "image/png",
|
|
3354
4116
|
".jpg": "image/jpeg",
|
|
@@ -3356,7 +4118,7 @@ var TYPES_BY_EXTENSION2 = {
|
|
|
3356
4118
|
".webp": "image/webp"
|
|
3357
4119
|
};
|
|
3358
4120
|
async function readScreenshot(request) {
|
|
3359
|
-
if (!
|
|
4121
|
+
if (!isAbsolute4(request.path)) {
|
|
3360
4122
|
return "Give the absolute path of the image file.";
|
|
3361
4123
|
}
|
|
3362
4124
|
const contentType = TYPES_BY_EXTENSION2[extname3(request.path).toLowerCase()];
|
|
@@ -3377,17 +4139,21 @@ async function readScreenshot(request) {
|
|
|
3377
4139
|
return "The image is larger than 5 MB. Take a smaller screenshot (viewport instead of full page, or JPEG).";
|
|
3378
4140
|
}
|
|
3379
4141
|
const data = (await readFile5(request.path)).toString("base64");
|
|
3380
|
-
return { name:
|
|
4142
|
+
return { name: basename5(request.path), ...caption, contentType, data };
|
|
3381
4143
|
}
|
|
3382
4144
|
|
|
3383
4145
|
// src/worker.ts
|
|
3384
4146
|
var POLL_INTERVAL_MS = 5e3;
|
|
3385
|
-
var
|
|
4147
|
+
var MAX_RETRY_WAIT_MS = 6e4;
|
|
4148
|
+
var POLL_DEADLINE_MS = 3 * 6e4;
|
|
3386
4149
|
var INBOX_INTERVAL_MS = 2e3;
|
|
3387
4150
|
var MAX_MESSAGE_LINE = 200;
|
|
3388
4151
|
var CAPABILITIES_INTERVAL_MS = 30 * 6e4;
|
|
3389
4152
|
var FEED_INTERVAL_MS = 1500;
|
|
3390
4153
|
var FATAL_STATUSES = /* @__PURE__ */ new Set([401, 403]);
|
|
4154
|
+
var MAX_CHECKS = 2;
|
|
4155
|
+
var CHECK_HEARTBEAT_MS = 3e4;
|
|
4156
|
+
var CHECK_DEADLINE_MS = 10 * 6e4;
|
|
3391
4157
|
var STOPPED_HERE = "stopped_here";
|
|
3392
4158
|
var CLOSED_ON_SITE = "closed_on_site";
|
|
3393
4159
|
function messageOf3(error) {
|
|
@@ -3419,7 +4185,7 @@ function sessionReporter(api, job, print2) {
|
|
|
3419
4185
|
let sending = null;
|
|
3420
4186
|
return {
|
|
3421
4187
|
report: (session) => {
|
|
3422
|
-
sending ??= api.request("POST", runPath(job.runId, "session"), session).then(() => {
|
|
4188
|
+
sending ??= api.request("POST", runPath(job.runId, "session"), session, { safeToRepeat: true }).then(() => {
|
|
3423
4189
|
}, (error) => print2(`Could not tell TAMsense which Claude took the task: ${messageOf3(error)}`));
|
|
3424
4190
|
},
|
|
3425
4191
|
sent: async () => {
|
|
@@ -3462,35 +4228,35 @@ function inbox(job, tools, record, closedOnSite) {
|
|
|
3462
4228
|
async function check() {
|
|
3463
4229
|
const ack = input.takeDelivered();
|
|
3464
4230
|
const body = { ack };
|
|
3465
|
-
let
|
|
4231
|
+
let reply4;
|
|
3466
4232
|
try {
|
|
3467
|
-
|
|
4233
|
+
reply4 = await tools.api.request("POST", runPath(job.runId, "inbox"), body, { safeToRepeat: true });
|
|
3468
4234
|
} catch (error) {
|
|
3469
4235
|
input.restoreDelivered(ack);
|
|
3470
|
-
if (!failing) {
|
|
4236
|
+
if (!failing && !alreadyReported(error)) {
|
|
3471
4237
|
tools.print(`Could not check for new messages on TAMsense, trying again: ${messageOf3(error)}`);
|
|
3472
4238
|
}
|
|
3473
4239
|
failing = true;
|
|
3474
4240
|
return;
|
|
3475
4241
|
}
|
|
3476
4242
|
failing = false;
|
|
3477
|
-
if (!
|
|
4243
|
+
if (!reply4.keepGoing) {
|
|
3478
4244
|
closedOnSite();
|
|
3479
4245
|
return;
|
|
3480
4246
|
}
|
|
3481
|
-
for (const message of
|
|
4247
|
+
for (const message of reply4.messages) {
|
|
3482
4248
|
if (input.push(message)) {
|
|
3483
4249
|
tools.print(` ${messageLine(message)}`);
|
|
3484
4250
|
}
|
|
3485
4251
|
}
|
|
3486
|
-
for (const stop of
|
|
4252
|
+
for (const stop of reply4.helperStops ?? []) {
|
|
3487
4253
|
if (!stopsSeen.has(stop.ref)) {
|
|
3488
4254
|
stopsSeen.add(stop.ref);
|
|
3489
4255
|
void stopHelper(input, stop, tools.print);
|
|
3490
4256
|
}
|
|
3491
4257
|
}
|
|
3492
|
-
if (
|
|
3493
|
-
const change = { model:
|
|
4258
|
+
if (reply4.model !== null || reply4.effort !== null) {
|
|
4259
|
+
const change = { model: reply4.model, effort: reply4.effort };
|
|
3494
4260
|
void input.switchModel(change).then(
|
|
3495
4261
|
(applied) => {
|
|
3496
4262
|
if (applied) {
|
|
@@ -3545,11 +4311,11 @@ function permissionTools(job, tools) {
|
|
|
3545
4311
|
}
|
|
3546
4312
|
return {
|
|
3547
4313
|
send: async (body) => {
|
|
3548
|
-
const
|
|
3549
|
-
if (
|
|
4314
|
+
const reply4 = await tools.api.request("POST", runPath(job.runId, "permissions"), body);
|
|
4315
|
+
if (reply4.decision === "wait") {
|
|
3550
4316
|
tools.print(` Waiting for a person on TAMsense to allow: ${body.title || body.action}`);
|
|
3551
4317
|
}
|
|
3552
|
-
return
|
|
4318
|
+
return reply4;
|
|
3553
4319
|
},
|
|
3554
4320
|
check: (id) => tools.api.request("GET", permissionPath(job.runId, id)),
|
|
3555
4321
|
sleep: tools.sleep
|
|
@@ -3577,10 +4343,18 @@ async function prepare(job, tools) {
|
|
|
3577
4343
|
const { results, ...workspace2 } = await prepareFolder(job, taskFolder(tools.home, job), tools.api);
|
|
3578
4344
|
return { workspace: workspace2, results, identity: {} };
|
|
3579
4345
|
}
|
|
4346
|
+
if (isLocalJob(job)) {
|
|
4347
|
+
tools.print("Preparing the code from the folders on this computer\u2026");
|
|
4348
|
+
const workspace2 = await prepareLocalWorkspace(job, tools);
|
|
4349
|
+
return { workspace: workspace2, results: null, identity: await commitIdentityEnv(job.repositories[0]?.folder ?? tools.home) };
|
|
4350
|
+
}
|
|
3580
4351
|
tools.print("Preparing the code\u2026");
|
|
3581
4352
|
const workspace = await prepareWorkspace(job, await gitToken(tools.api, job), tools);
|
|
3582
4353
|
return { workspace, results: null, identity: await commitIdentityEnv(tools.home) };
|
|
3583
4354
|
}
|
|
4355
|
+
async function publish(workspace, job, api, identity) {
|
|
4356
|
+
return isLocalJob(job) ? await publishLocalWorkspace(workspace, job, identity) : await publishWorkspace(workspace, job, await gitToken(api, job), identity);
|
|
4357
|
+
}
|
|
3584
4358
|
async function executeJob(job, tools, signal, input, record) {
|
|
3585
4359
|
const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
|
|
3586
4360
|
const { workspace, results, identity } = await prepare(job, tools);
|
|
@@ -3594,7 +4368,7 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
3594
4368
|
const attached = { count: 0 };
|
|
3595
4369
|
const session = sessionReporter(tools.api, job, tools.print);
|
|
3596
4370
|
const recordings = recordingsFolder(tools.home, job.runId);
|
|
3597
|
-
const baseEnv = withNodeOnPath(
|
|
4371
|
+
const baseEnv = withNodeOnPath(process6.env, dirname5(process6.execPath));
|
|
3598
4372
|
const recording = tools.recorder ? recorderEnv({ recorder: tools.recorder, folder: recordings, server: tools.api.server, job, nodeOptions: baseEnv.NODE_OPTIONS }) : {};
|
|
3599
4373
|
const outcome = await tools.runAgent({
|
|
3600
4374
|
cwd: workspace.root,
|
|
@@ -3616,13 +4390,15 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
3616
4390
|
onSession: session.report,
|
|
3617
4391
|
attach: (request) => request.kind === "screenshot" ? attachScreenshot(tools.api, job, request, attached) : attachRecording(tools.api, job.runId, recordings, request),
|
|
3618
4392
|
results: resultTools(job, tools.api, workspace.root),
|
|
3619
|
-
handOver: handOverTools(job, tools.api)
|
|
4393
|
+
handOver: handOverTools(job, tools.api),
|
|
4394
|
+
schedules: job.canSchedule ? scheduleTools(tools.api, runPath(job.runId, "schedules")) : null
|
|
3620
4395
|
});
|
|
3621
4396
|
await session.sent();
|
|
3622
4397
|
signal.throwIfAborted();
|
|
3623
4398
|
const waitingFor = outcome.waitingFor ?? null;
|
|
3624
|
-
const
|
|
3625
|
-
const
|
|
4399
|
+
const waitingForSchedule = outcome.waitingForSchedule ?? null;
|
|
4400
|
+
const shouldPublish = job.stage.kind === "code" && !isFolderJob(job) && !isReadOnlyCode(job.stage) && outcome.completed && waitingFor === null && waitingForSchedule === null;
|
|
4401
|
+
const pushed = shouldPublish ? await publish(workspace, job, tools.api, identity) : [];
|
|
3626
4402
|
return {
|
|
3627
4403
|
outcome: outcome.completed ? "succeeded" : "failed",
|
|
3628
4404
|
report: outcome.report,
|
|
@@ -3635,20 +4411,24 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
3635
4411
|
pausedUntil: outcome.pausedUntil ?? null,
|
|
3636
4412
|
questions: outcome.questions ?? null,
|
|
3637
4413
|
spec: outcome.spec ?? null,
|
|
3638
|
-
waitingFor
|
|
4414
|
+
waitingFor,
|
|
4415
|
+
waitingForSchedule
|
|
3639
4416
|
};
|
|
3640
4417
|
}
|
|
3641
4418
|
function clockOf(iso) {
|
|
3642
4419
|
const time = new Date(iso);
|
|
3643
4420
|
return `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
|
|
3644
4421
|
}
|
|
3645
|
-
function resultLine(finish2,
|
|
4422
|
+
function resultLine(finish2, workspace = "repositories") {
|
|
3646
4423
|
if (finish2.pausedUntil) {
|
|
3647
4424
|
return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
|
|
3648
4425
|
}
|
|
3649
4426
|
if (finish2.waitingFor !== null) {
|
|
3650
4427
|
return "Waiting for an answer on TAMsense. This computer is free for other tasks; the run continues here after the answer.";
|
|
3651
4428
|
}
|
|
4429
|
+
if (finish2.waitingForSchedule) {
|
|
4430
|
+
return "Waiting for a schedule on TAMsense. This computer is free for other tasks; the run continues here when the schedule ends.";
|
|
4431
|
+
}
|
|
3652
4432
|
if (finish2.verdict?.couldNotRun) {
|
|
3653
4433
|
return `Check could not run: ${finish2.verdict.report}`;
|
|
3654
4434
|
}
|
|
@@ -3664,7 +4444,13 @@ function resultLine(finish2, folder = false) {
|
|
|
3664
4444
|
if (finish2.outcome !== "succeeded") {
|
|
3665
4445
|
return `Failed: ${finish2.report}`;
|
|
3666
4446
|
}
|
|
3667
|
-
|
|
4447
|
+
if (workspace === "folder") {
|
|
4448
|
+
return "Done. The result is on the task on TAMsense.";
|
|
4449
|
+
}
|
|
4450
|
+
if (workspace === "local") {
|
|
4451
|
+
return finish2.pushed.length > 0 ? `Done. ${finish2.pushed.map(localBranchLine).join(" ")}` : "Done. Nothing new to commit.";
|
|
4452
|
+
}
|
|
4453
|
+
return `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.`;
|
|
3668
4454
|
}
|
|
3669
4455
|
async function sendRecordings(job, tools) {
|
|
3670
4456
|
const folder = recordingsFolder(tools.home, job.runId);
|
|
@@ -3694,7 +4480,7 @@ async function runJob(job, tools, stop) {
|
|
|
3694
4480
|
stopRun();
|
|
3695
4481
|
}
|
|
3696
4482
|
stop.addEventListener("abort", stopRun, { once: true });
|
|
3697
|
-
const feed = feedQueue((entries) => tools.api.request("POST", runPath(job.runId, "feed"), { entries }));
|
|
4483
|
+
const feed = feedQueue((entries) => tools.api.request("POST", runPath(job.runId, "feed"), { entries }, { safeToRepeat: true }));
|
|
3698
4484
|
const feeding = setInterval(() => void feed.flush(), FEED_INTERVAL_MS);
|
|
3699
4485
|
const record = journal(feed, tools.print);
|
|
3700
4486
|
const messages = inbox(job, tools, record, () => run2.abort(CLOSED_ON_SITE));
|
|
@@ -3727,11 +4513,49 @@ async function runJob(job, tools, stop) {
|
|
|
3727
4513
|
await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf3(error)}`));
|
|
3728
4514
|
try {
|
|
3729
4515
|
await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
|
|
3730
|
-
tools.print(resultLine(finish2,
|
|
4516
|
+
tools.print(resultLine(finish2, job.workspace));
|
|
3731
4517
|
} catch (error) {
|
|
3732
4518
|
tools.print(`Could not send the result to TAMsense: ${messageOf3(error)}`);
|
|
3733
4519
|
}
|
|
3734
4520
|
}
|
|
4521
|
+
var CHECK_TOO_LONG = "check_too_long";
|
|
4522
|
+
function checkFailure(error, signal) {
|
|
4523
|
+
return signal.reason === CHECK_TOO_LONG ? `The check took longer than ${CHECK_DEADLINE_MS / 6e4} minutes.` : failureReport(error, signal);
|
|
4524
|
+
}
|
|
4525
|
+
async function runCheckJob(check, runCheck, tools, stop) {
|
|
4526
|
+
tools.print(`Schedule check: ${check.title}${check.taskId === null ? "" : ` (task #${check.taskId})`}`);
|
|
4527
|
+
const run2 = new AbortController();
|
|
4528
|
+
const stopRun = () => run2.abort(STOPPED_HERE);
|
|
4529
|
+
if (stop.aborted) {
|
|
4530
|
+
stopRun();
|
|
4531
|
+
}
|
|
4532
|
+
stop.addEventListener("abort", stopRun, { once: true });
|
|
4533
|
+
const deadline = setTimeout(() => run2.abort(CHECK_TOO_LONG), CHECK_DEADLINE_MS);
|
|
4534
|
+
const beat = checkHeartbeat(tools.api, check, () => run2.abort(CLOSED_ON_SITE));
|
|
4535
|
+
const beating = setInterval(() => void beat(), CHECK_HEARTBEAT_MS);
|
|
4536
|
+
let body;
|
|
4537
|
+
try {
|
|
4538
|
+
const folder = await checkFolder(tools.home, check);
|
|
4539
|
+
const env = withNodeOnPath(process6.env, dirname5(process6.execPath));
|
|
4540
|
+
body = checkFinishBody(await runCheck({ cwd: folder, prompt: buildCheckPrompt(check, folder), model: check.model, env, signal: run2.signal }));
|
|
4541
|
+
} catch (error) {
|
|
4542
|
+
body = { outcome: "failed", report: checkFailure(error, run2.signal), model: null, usage: null, result: null };
|
|
4543
|
+
} finally {
|
|
4544
|
+
clearTimeout(deadline);
|
|
4545
|
+
clearInterval(beating);
|
|
4546
|
+
stop.removeEventListener("abort", stopRun);
|
|
4547
|
+
}
|
|
4548
|
+
if (run2.signal.reason === CLOSED_ON_SITE) {
|
|
4549
|
+
tools.print("TAMsense closed this check while it was in progress. The result was not sent.");
|
|
4550
|
+
return;
|
|
4551
|
+
}
|
|
4552
|
+
try {
|
|
4553
|
+
await tools.api.request("POST", checkPath(check.checkId, "finish"), body);
|
|
4554
|
+
tools.print(checkLine(body));
|
|
4555
|
+
} catch (error) {
|
|
4556
|
+
tools.print(`Could not send the check result to TAMsense: ${messageOf3(error)}`);
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
3735
4559
|
async function reportCapabilities(tools, stop) {
|
|
3736
4560
|
if (!tools.capabilities) {
|
|
3737
4561
|
return;
|
|
@@ -3747,7 +4571,7 @@ async function reportCapabilities(tools, stop) {
|
|
|
3747
4571
|
printed = true;
|
|
3748
4572
|
}
|
|
3749
4573
|
} catch (error) {
|
|
3750
|
-
if (!complained && !stop.aborted) {
|
|
4574
|
+
if (!complained && !stop.aborted && !alreadyReported(error)) {
|
|
3751
4575
|
tools.print(`Could not tell TAMsense which Claude models this computer has: ${messageOf3(error)}`);
|
|
3752
4576
|
complained = true;
|
|
3753
4577
|
}
|
|
@@ -3755,6 +4579,24 @@ async function reportCapabilities(tools, stop) {
|
|
|
3755
4579
|
await tools.sleep(CAPABILITIES_INTERVAL_MS, stop);
|
|
3756
4580
|
}
|
|
3757
4581
|
}
|
|
4582
|
+
async function withDeadline(work2, ms, sleep2) {
|
|
4583
|
+
const settled = new AbortController();
|
|
4584
|
+
const deadline = new Promise((_, reject) => {
|
|
4585
|
+
void sleep2(ms, settled.signal).then(() => {
|
|
4586
|
+
if (!settled.signal.aborted) {
|
|
4587
|
+
reject(new Error(`TAMsense did not answer for ${Math.round(ms / 6e4)} minutes`));
|
|
4588
|
+
}
|
|
4589
|
+
});
|
|
4590
|
+
});
|
|
4591
|
+
try {
|
|
4592
|
+
return await Promise.race([work2, deadline]);
|
|
4593
|
+
} finally {
|
|
4594
|
+
settled.abort();
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
function untilReconnect(api, stop) {
|
|
4598
|
+
return api.nextReconnect ? AbortSignal.any([stop, api.nextReconnect()]) : stop;
|
|
4599
|
+
}
|
|
3758
4600
|
function jobTools(job, tools, parallel) {
|
|
3759
4601
|
return parallel > 1 ? { ...tools, print: (line) => tools.print(`[#${job.taskId} ${job.stage.name}] ${line}`) } : tools;
|
|
3760
4602
|
}
|
|
@@ -3763,39 +4605,60 @@ async function work(tools, stop, parallel = 1) {
|
|
|
3763
4605
|
const halt = new AbortController();
|
|
3764
4606
|
const stopAll = AbortSignal.any([stop, halt.signal]);
|
|
3765
4607
|
const running = /* @__PURE__ */ new Set();
|
|
4608
|
+
const checks = /* @__PURE__ */ new Set();
|
|
3766
4609
|
const folderLock = keyedLock();
|
|
4610
|
+
const scheduleLock = keyedLock();
|
|
4611
|
+
const runCheck = tools.version ? tools.runCheck : void 0;
|
|
3767
4612
|
const reporting = reportCapabilities(tools, stopAll);
|
|
4613
|
+
const checking = tools.inspectFolder ? checkFolders({ ...tools, inspect: tools.inspectFolder }, stopAll) : Promise.resolve();
|
|
4614
|
+
if (tools.startFolder) {
|
|
4615
|
+
await reportFoundFolders(tools, tools.startFolder);
|
|
4616
|
+
}
|
|
3768
4617
|
const start = (job) => {
|
|
3769
4618
|
const runTools = jobTools(job, tools, parallel);
|
|
3770
4619
|
const task = folderLock.run(stepFolder(job).join("/"), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf3(error)}`)).finally(() => running.delete(task));
|
|
3771
4620
|
running.add(task);
|
|
3772
4621
|
};
|
|
4622
|
+
const startCheck = (check, runner) => {
|
|
4623
|
+
const task = scheduleLock.run(`schedule-${check.scheduleId}`, () => runCheckJob(check, runner, tools, stopAll)).catch((error) => tools.print(`The check stopped: ${messageOf3(error)}`)).finally(() => checks.delete(task));
|
|
4624
|
+
checks.add(task);
|
|
4625
|
+
};
|
|
4626
|
+
let failures = 0;
|
|
3773
4627
|
try {
|
|
3774
4628
|
while (!stopAll.aborted) {
|
|
3775
|
-
|
|
3776
|
-
|
|
4629
|
+
const jobsFull = running.size >= parallel;
|
|
4630
|
+
const checksFull = !runCheck || checks.size >= MAX_CHECKS;
|
|
4631
|
+
if (jobsFull && checksFull) {
|
|
4632
|
+
await Promise.race([...running, ...checks]);
|
|
3777
4633
|
continue;
|
|
3778
4634
|
}
|
|
3779
|
-
|
|
4635
|
+
const body = runCheck && tools.version ? { version: tools.version, jobs: !jobsFull, checks: !checksFull } : void 0;
|
|
4636
|
+
let reply4;
|
|
3780
4637
|
try {
|
|
3781
|
-
|
|
4638
|
+
reply4 = await withDeadline(tools.api.request("POST", NEXT_RUN_PATH, body, { safeToRepeat: true }), POLL_DEADLINE_MS, tools.sleep);
|
|
3782
4639
|
} catch (error) {
|
|
3783
4640
|
if (isFatal(error)) {
|
|
3784
4641
|
halt.abort();
|
|
3785
4642
|
throw error;
|
|
3786
4643
|
}
|
|
3787
|
-
|
|
3788
|
-
|
|
4644
|
+
failures += 1;
|
|
4645
|
+
if (failures === 1 && !alreadyReported(error)) {
|
|
4646
|
+
tools.print(`Could not ask TAMsense for tasks: ${messageOf3(error)}. Trying again.`);
|
|
4647
|
+
}
|
|
4648
|
+
await tools.sleep(backoffMs(failures, POLL_INTERVAL_MS, MAX_RETRY_WAIT_MS), untilReconnect(tools.api, stopAll));
|
|
3789
4649
|
continue;
|
|
3790
4650
|
}
|
|
3791
|
-
|
|
3792
|
-
|
|
4651
|
+
failures = 0;
|
|
4652
|
+
if (reply4.check && runCheck) {
|
|
4653
|
+
startCheck(reply4.check, runCheck);
|
|
4654
|
+
} else if (reply4.job) {
|
|
4655
|
+
start(reply4.job);
|
|
3793
4656
|
} else {
|
|
3794
4657
|
await tools.sleep(POLL_INTERVAL_MS, stopAll);
|
|
3795
4658
|
}
|
|
3796
4659
|
}
|
|
3797
4660
|
} finally {
|
|
3798
|
-
await Promise.allSettled([...running, reporting]);
|
|
4661
|
+
await Promise.allSettled([...running, ...checks, reporting, checking]);
|
|
3799
4662
|
}
|
|
3800
4663
|
}
|
|
3801
4664
|
|
|
@@ -3806,11 +4669,11 @@ var NO_TASKS_ACCESS_TEXT = "Your account no longer has access to Tasks. Ask the
|
|
|
3806
4669
|
var ACCESS_PROBLEMS = { 401: DISCONNECTED_TEXT, 403: NO_TASKS_ACCESS_TEXT };
|
|
3807
4670
|
var INTERRUPTED_EXIT_CODE = 130;
|
|
3808
4671
|
function print(line) {
|
|
3809
|
-
|
|
4672
|
+
process7.stdout.write(`${line}
|
|
3810
4673
|
`);
|
|
3811
4674
|
}
|
|
3812
4675
|
function serverFor(flag, saved) {
|
|
3813
|
-
return serverOrigin(flag ??
|
|
4676
|
+
return serverOrigin(flag ?? process7.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
|
|
3814
4677
|
}
|
|
3815
4678
|
function accessProblem(error) {
|
|
3816
4679
|
return error instanceof ApiError ? ACCESS_PROBLEMS[error.status] ?? null : null;
|
|
@@ -3832,19 +4695,19 @@ async function runLogin(server, deviceName) {
|
|
|
3832
4695
|
{ name, platform: platformLabel() }
|
|
3833
4696
|
);
|
|
3834
4697
|
await writeConfig({ server: api.server, token: result.token, deviceId: result.device.id, deviceName: result.device.name });
|
|
3835
|
-
print(`Connected: ${result.device.name}. To take your tasks, run ${START_COMMAND}`);
|
|
4698
|
+
print(`Connected: ${result.device.name}. To take your tasks, go to your code folder and run ${START_COMMAND}`);
|
|
3836
4699
|
}
|
|
3837
4700
|
function stopOnInterrupt() {
|
|
3838
4701
|
const stop = new AbortController();
|
|
3839
|
-
|
|
4702
|
+
process7.once("SIGINT", () => {
|
|
3840
4703
|
print("Stopping\u2026 Press Ctrl+C again to quit at once.");
|
|
3841
4704
|
stop.abort();
|
|
3842
|
-
|
|
4705
|
+
process7.once("SIGINT", () => process7.exit(INTERRUPTED_EXIT_CODE));
|
|
3843
4706
|
});
|
|
3844
4707
|
return stop.signal;
|
|
3845
4708
|
}
|
|
3846
4709
|
function installTools(home) {
|
|
3847
|
-
return { home, nodePath:
|
|
4710
|
+
return { home, nodePath: process7.execPath, env: process7.env, print, run: runProcess, exists: existsSync8 };
|
|
3848
4711
|
}
|
|
3849
4712
|
async function browserForStart(home, install) {
|
|
3850
4713
|
const installed = await installedBrowser(home);
|
|
@@ -3854,13 +4717,13 @@ async function browserForStart(home, install) {
|
|
|
3854
4717
|
return ensureBrowser({ print, install: () => installBrowser(installTools(home)) });
|
|
3855
4718
|
}
|
|
3856
4719
|
async function startedNewVersion() {
|
|
3857
|
-
const update = await updateToRun(
|
|
4720
|
+
const update = await updateToRun(process7.env, currentVersion(), () => latestVersion());
|
|
3858
4721
|
if (!update) {
|
|
3859
4722
|
return false;
|
|
3860
4723
|
}
|
|
3861
4724
|
print(`TAMsense runner ${update.latest} is out, this is ${update.current}. Starting the new version...`);
|
|
3862
4725
|
try {
|
|
3863
|
-
|
|
4726
|
+
process7.exitCode = await relaunch(process7.argv.slice(2), process7.env);
|
|
3864
4727
|
return true;
|
|
3865
4728
|
} catch {
|
|
3866
4729
|
print("Could not start the new version. Going on with this one.");
|
|
@@ -3876,16 +4739,16 @@ async function runStart(parallel, installsBrowser) {
|
|
|
3876
4739
|
print(NOT_CONNECTED_TEXT);
|
|
3877
4740
|
return;
|
|
3878
4741
|
}
|
|
3879
|
-
const
|
|
3880
|
-
const
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
throw new Error(problem);
|
|
4742
|
+
const identity = await createApi(saved.server, saved.token).request("GET", IDENTITY_PATH);
|
|
4743
|
+
const problem2 = teamKeyProblem(identity.device.team, process7.env);
|
|
4744
|
+
if (problem2) {
|
|
4745
|
+
throw new Error(problem2);
|
|
3884
4746
|
}
|
|
3885
4747
|
print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
|
|
3886
4748
|
const home = homedir3();
|
|
3887
4749
|
const browser = await browserForStart(home, installsBrowser);
|
|
3888
|
-
|
|
4750
|
+
const api = createApi(saved.server, saved.token, { watch: connectionWatch(print) });
|
|
4751
|
+
await work({ api, runAgent: runClaude, runCheck: runClaudeCheck, version: currentVersion() ?? void 0, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder(), inspectFolder: (path) => inspectFolder(path, home), startFolder: process7.cwd() }, stopOnInterrupt(), parallel);
|
|
3889
4752
|
}
|
|
3890
4753
|
async function runBrowser(remove) {
|
|
3891
4754
|
const home = homedir3();
|
|
@@ -3937,10 +4800,10 @@ async function run(command) {
|
|
|
3937
4800
|
}
|
|
3938
4801
|
}
|
|
3939
4802
|
try {
|
|
3940
|
-
await run(parseCommand(
|
|
4803
|
+
await run(parseCommand(process7.argv.slice(2), process7.env));
|
|
3941
4804
|
} catch (error) {
|
|
3942
|
-
|
|
4805
|
+
process7.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
|
|
3943
4806
|
`);
|
|
3944
|
-
|
|
4807
|
+
process7.exitCode = 1;
|
|
3945
4808
|
}
|
|
3946
4809
|
//# sourceMappingURL=cli.js.map
|