@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/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 existsSync5 } from "fs";
4
+ import { existsSync as existsSync8 } from "fs";
5
5
  import { homedir as homedir3 } from "os";
6
- import process5 from "process";
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 reply3 = await response.json().catch(() => ({}));
37
- const code = textOf(reply3.data?.code);
38
- const message = textOf(reply3.message) ?? textOf(reply3.statusMessage) ?? `TAMsense answered with status ${response.status}`;
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 createApi(server, token = null, fetchImpl = fetch) {
42
- async function send(method, path, headers, body) {
43
- const all = { accept: "application/json", ...headers };
44
- if (token) {
45
- all.authorization = `Bearer ${token}`;
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: all, body, ...body instanceof ReadableStream ? { duplex: "half" } : {} });
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
- throw new Error(`Could not reach ${server}. Check your internet connection.`, { cause: error });
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
- return await response.json();
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) => send(method, path, body ? { "content-type": "application/json" } : {}, body && JSON.stringify(body)),
61
- upload: (method, path, body, contentType) => send(method, path, { "content-type": contentType }, body)
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((resolve3, reject) => {
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) => resolve3({ code: code ?? 1, output: tail.join("\n") }));
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((resolve3) => {
318
- release = resolve3;
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 query2, tool as tool4 } from "@anthropic-ai/claude-agent-sdk";
344
- import { z as z4 } from "zod";
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(tool5, input) {
581
+ function activityLine(tool6, input) {
405
582
  const fields = typeof input === "object" && input !== null ? input : {};
406
- if (tool5 === "Bash" && typeof fields.command === "string") {
583
+ if (tool6 === "Bash" && typeof fields.command === "string") {
407
584
  return `$ ${shorten(fields.command)}`;
408
585
  }
409
- if (FILE_TOOLS.has(tool5) && typeof fields.file_path === "string") {
410
- return `${tool5} ${shorten(fields.file_path)}`;
586
+ if (FILE_TOOLS.has(tool6) && typeof fields.file_path === "string") {
587
+ return `${tool6} ${shorten(fields.file_path)}`;
411
588
  }
412
- if (tool5 === "Skill" && typeof fields.skill === "string") {
589
+ if (tool6 === "Skill" && typeof fields.skill === "string") {
413
590
  return `Skill ${shorten(fields.skill)}`;
414
591
  }
415
- if (HELPER_TOOLS.has(tool5) && typeof fields.subagent_type === "string") {
592
+ if (HELPER_TOOLS.has(tool6) && typeof fields.subagent_type === "string") {
416
593
  return `Agent ${shorten(fields.subagent_type)}`;
417
594
  }
418
- return tool5;
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 tool5 = (block.name ?? "tool").replace(MCP_PREFIX, "");
673
+ const tool6 = (block.name ?? "tool").replace(MCP_PREFIX, "");
489
674
  const fields = fieldsOf(block.input);
490
- const base = { tool: tool5, ref: block.id ?? null, parent, failed: false };
491
- if (tool5 === "Skill" && textField(fields, "skill")) {
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(tool5)) {
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 (tool5 === "submit_verdict") {
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 (tool5 === "hand_off") {
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 (tool5 in ATTACH_LABELS) {
507
- return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool5]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
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 = tool5 === "Bash" && command ? `$ ${command}` : activityLine(tool5, block.input);
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((resolve3) => {
762
- wake = resolve3;
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: resolve3 } = waitingSwitch;
1007
+ const { change, resolve: resolve5 } = waitingSwitch;
823
1008
  waitingSwitch = null;
824
- void apply(change).then(() => resolve3(true), () => resolve3(false));
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((resolve3) => {
886
- waitingSwitch = { change: merged, resolve: resolve3 };
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 reply3 = first;
1050
- while (reply3.decision === "wait") {
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
- reply3 = await tools.check(reply3.id).catch(() => reply3);
1243
+ reply4 = await tools.check(reply4.id).catch(() => reply4);
1059
1244
  }
1060
- return reply3;
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 reply3 = await decisionOf(tools, first, options.signal);
1082
- if (typeof reply3 === "string") {
1083
- return deny(reply3);
1266
+ const reply4 = await decisionOf(tools, first, options.signal);
1267
+ if (typeof reply4 === "string") {
1268
+ return deny(reply4);
1084
1269
  }
1085
- return reply3.decision === "allow" ? { behavior: "allow", updatedInput: input } : deny(reply3.message);
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/results.ts
1091
- import { mkdir as mkdir2, readFile as readFile2, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
1092
- import { basename, extname, isAbsolute, join as join2, resolve } from "path";
1093
- import { tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
1094
- import { z as z2 } from "zod";
1095
- var SAVE_DOCUMENT_TOOL_NAME = "save_result_document";
1096
- var SAVE_TABLE_TOOL_NAME = "save_result_table";
1097
- var ATTACH_FILE_TOOL_NAME = "attach_result_file";
1098
- var ADD_LINK_TOOL_NAME = "add_result_link";
1099
- var HAND_OVER_TOOL_NAME = "hand_over";
1100
- var RESULTS_FOLDER = "results";
1101
- var MAX_NAME_LENGTH = 60;
1102
- var TYPES_BY_EXTENSION = {
1103
- ".pdf": "application/pdf",
1104
- ".png": "image/png",
1105
- ".jpg": "image/jpeg",
1106
- ".jpeg": "image/jpeg",
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
- root,
1128
- save: (body) => api.request("POST", runPath(job.runId, "results"), body),
1129
- attach: (body) => api.request("POST", runPath(job.runId, "result-files"), body)
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 handOverTools(job, api) {
1133
- const boards = job.handOverBoards ?? [];
1134
- if (boards.length === 0 || job.stage.kind !== "code") {
1135
- return null;
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 { boards, send: (body) => api.request("POST", runPath(job.runId, "hand-over"), body) };
1323
+ return result.stdout;
1138
1324
  }
1139
- function resultFileName(result, taken) {
1140
- 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";
1141
- const extension = result.kind === "file" ? extname(result.fileName ?? "") : EXTENSIONS[result.kind];
1142
- let name = `${stem}${extension}`;
1143
- for (let index = 2; taken.has(name.toLowerCase()); index += 1) {
1144
- name = `${stem}-${index}${extension}`;
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 linksText(links) {
1150
- return links.map((link) => `- ${link.title}: ${link.url ?? ""}`).join("\n");
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
- async function writeResults(folder, job, api) {
1153
- const results = job.results ?? [];
1154
- await rm2(folder, { recursive: true, force: true });
1155
- if (results.length === 0) {
1156
- return [];
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
- await mkdir2(folder, { recursive: true });
1159
- const taken = /* @__PURE__ */ new Set();
1160
- const lines = [];
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
- const links = results.filter((item) => item.kind === "link");
1178
- if (links.length > 0) {
1179
- await writeFile2(join2(folder, "links.md"), `${linksText(links)}
1180
- `);
1181
- lines.push(`${RESULTS_FOLDER}/links.md: ${links.length} link(s)`);
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 (missing.length > 0) {
1184
- await writeFile2(join2(folder, "notes.md"), `These files could not be downloaded:
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
- return lines;
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
- async function prepareFolder(job, root, api) {
1191
- if (!job.keepWorkspace) {
1192
- await rm2(root, { recursive: true, force: true });
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
- async function readResultFile(root, path, title) {
1198
- const full = isAbsolute(path) ? path : resolve(root, path);
1199
- const info = await stat(full).catch(() => null);
1200
- if (!info?.isFile()) {
1201
- return `There is no file at ${full}.`;
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
- if (info.size > MAX_RESULT_FILE_BYTES) {
1204
- return `The file is larger than ${MAX_RESULT_FILE_BYTES / 1024 / 1024} MB. Attach a smaller one or split it.`;
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
- const name = basename(full);
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 reply(text2, isError = false) {
1210
- return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
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 sent(send) {
1476
+ async function reportFoundFolders(tools, start) {
1213
1477
  try {
1214
- return reply((await send()).text);
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
- 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);
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((resolve3) => {
1430
- release = resolve3;
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 (existsSync2(join3(cache, ".git"))) {
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
- async function removeWorktree(cache, path) {
1472
- await runGit(["worktree", "remove", "--force", path], { cwd: cache });
1473
- await rm3(path, { recursive: true, force: true });
1474
- await git(["worktree", "prune"], { cwd: cache });
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
- function stepFolder(job) {
1477
- const task = `task-${job.taskId}`;
1478
- return typeof job.stage.columnId === "number" ? [task, `step-${job.stage.columnId}`] : [task];
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 isReadOnlyCheck(job) {
1481
- return job.stage.kind === "review" || job.stage.kind === "test";
2189
+ function text(value) {
2190
+ return typeof value === "string" ? value.trim() : "";
1482
2191
  }
1483
- function branchHolders(porcelain, branch) {
1484
- const holders = [];
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
- async function releaseBranch(cache, branch, keep) {
1496
- const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: cache });
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
- async function baseRef(cache, job, repository) {
1502
- const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
1503
- return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
2198
+ function section(title, lines) {
2199
+ return lines.length > 0 ? `${title}
2200
+ ${lines.join("\n")}` : null;
1504
2201
  }
1505
- async function keptBase(cache, path, job, repository) {
1506
- if (!job.keepWorkspace || !existsSync2(join3(path, ".git"))) {
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 branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
1510
- if (branch.code !== 0 || branch.stdout !== (isReadOnlyCheck(job) ? "HEAD" : job.branch)) {
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 git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
2220
+ return `- ${text(value.text)}${text(value.reason) ? ` (${text(value.reason)})` : ""}`;
1514
2221
  }
1515
- function taskFolder(home, job) {
1516
- return tamsenseFolder(home, "work", ...stepFolder(job));
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
- async function prepareWorkspace(job, token, tools) {
1519
- const root = taskFolder(tools.home, job);
1520
- const folders = folderNames(job.repositories);
1521
- const repositories = [];
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
- async function commitIdentityEnv(home) {
1547
- const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
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
- async function publishWorkspace(workspace, job, token, identity) {
1559
- const pushed = [];
1560
- for (const repository of workspace.repositories) {
1561
- const options = { cwd: repository.path, env: identity };
1562
- const sha = await cacheLock.run(repository.fullName, async () => {
1563
- await git(["add", "--all"], options);
1564
- const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
1565
- if (hasStaged) {
1566
- await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
1567
- }
1568
- const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
1569
- if (newCommits === 0) {
1570
- return null;
1571
- }
1572
- await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
1573
- return git(["rev-parse", "HEAD"], options);
1574
- });
1575
- if (sha) {
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
- return pushed;
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 CHECK_RULES = [
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 sourceOf = new Map(job.repositories.map((repository) => [repository.fullName, repository]));
2417
+ const sourceOf2 = new Map(job.repositories.map((repository) => [repository.fullName, repository]));
1712
2418
  return workspace.repositories.map((repository) => {
1713
- const source = sourceOf.get(repository.fullName);
2419
+ const source = sourceOf2.get(repository.fullName);
1714
2420
  const pull = source?.pullRequestUrl ? `, pull request ${source.pullRequestUrl}` : "";
1715
- return `${repository.folder} (${repository.fullName}) at commit ${repository.baseSha.slice(0, 7)}, base branch origin/${source?.defaultBranch ?? "main"}${pull}`;
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 ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : readOnly ? [...READ_ONLY_CODE_RULES, DEPENDENCIES_RULE, localApp, ...evidence, READ_ONLY_REPORT_RULE] : [...CODE_RULES, ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
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 tool3 } from "@anthropic-ai/claude-agent-sdk";
1975
- import { z as z3 } from "zod";
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 = z3.object({
1978
- id: z3.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
1979
- question: z3.string().describe("One plain sentence the owner can answer without reading code"),
1980
- whyItMatters: z3.string().describe("One line: what changes in the result depending on the answer"),
1981
- options: z3.array(z3.object({
1982
- label: z3.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
1983
- description: z3.string().describe("One sentence: what happens if this option is chosen"),
1984
- recommended: z3.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
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: z3.boolean().describe("true only when the options are additive and several can apply together")
2702
+ multiSelect: z4.boolean().describe("true only when the options are additive and several can apply together")
1987
2703
  });
1988
- var briefSchema = z3.object({
1989
- findings: z3.array(z3.object({
1990
- fact: z3.string().describe("What the code does today that matters for this task"),
1991
- where: z3.string().describe("Where it is: owner/repo path:line")
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: z3.array(z3.string()).describe("Numbers and facts from the events or the business data, each with its source. Empty when the task needs no data"),
1994
- approach: z3.string().describe("The approach you would take, in 2-4 sentences"),
1995
- codeMap: z3.array(z3.object({
1996
- repo: z3.string().describe("Repository full name (owner/name)"),
1997
- file: z3.string().describe("Path relative to the repository root"),
1998
- role: z3.string().describe("Why it matters: what to change or reuse there")
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: z3.array(z3.object({
2001
- text: z3.string().describe("The default you chose"),
2002
- reason: z3.string().describe("Why it is safe or why it was chosen")
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: z3.array(z3.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
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 = z3.object({
2722
+ var questionsPayloadSchema = z4.object({
2007
2723
  brief: briefSchema,
2008
- questions: z3.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`)
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 = z3.object({
2011
- goal: z3.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
2012
- context: z3.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
2013
- inScope: z3.array(z3.string()).describe("What this task delivers, one item each"),
2014
- outOfScope: z3.array(z3.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"),
2015
- decisions: z3.array(z3.object({
2016
- question: z3.string(),
2017
- answer: z3.string(),
2018
- by: z3.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
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: z3.array(z3.object({
2021
- repo: z3.string().describe("Repository full name (owner/name)"),
2022
- file: z3.string().describe("Path relative to the repository root"),
2023
- role: z3.string().describe("What the coder needs to know about this file"),
2024
- use: z3.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
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: z3.array(z3.object({
2027
- repo: z3.string().describe("Repository full name (owner/name) where the change happens"),
2028
- file: z3.string().describe("Path to the file, relative to the repository root"),
2029
- action: z3.enum(["create", "modify", "delete"]),
2030
- description: z3.string().describe("The behaviour the file has after the change and which existing functions or patterns to extend. No code snippets or diffs"),
2031
- produces: z3.array(z3.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
2032
- consumes: z3.array(z3.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
2033
- acceptance: z3.array(z3.string()).describe("Ids of the criteria this change makes true, like AC-1")
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: z3.array(z3.object({
2036
- id: z3.string().describe("AC-1, AC-2 and so on, in order"),
2037
- kind: z3.enum(["main", "edge", "keep"]).describe("main: the main path; edge: an error, empty or limit case; keep: existing behaviour that must keep working"),
2038
- text: z3.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
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: z3.object({
2041
- automated: z3.array(z3.object({
2042
- repo: z3.string(),
2043
- command: z3.string().describe("The exact command to run"),
2044
- expect: z3.string().describe("What passing looks like")
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: z3.array(z3.object({
2047
- seam: z3.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
2048
- covers: z3.array(z3.string()).describe("Ids of the criteria it 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: z3.array(z3.object({
2051
- page: z3.string().describe("The page or screen"),
2052
- steps: z3.string().describe("What a person does and sees")
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: z3.array(z3.object({
2056
- risk: z3.string(),
2057
- mitigation: z3.string()
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: z3.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
2060
- assumptions: z3.array(z3.object({
2061
- text: z3.string().describe("The default you chose"),
2062
- reason: z3.string().describe("Why, so the owner can confirm or correct it quickly")
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: z3.array(questionSchema).describe("Questions only the owner can settle; the spec cannot be approved until each is answered or dismissed. Empty when none"),
2065
- split: z3.array(z3.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")
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 = z3.object({
2068
- format: z3.literal("brief").describe('Always "brief"'),
2069
- goal: z3.string().describe("What the work is for, for whom, and the outcome people will notice"),
2070
- context: z3.string().describe("Summary: the situation in 1-2 sentences, then how the work will be done in 1-2 sentences"),
2071
- contains: z3.array(z3.string()).min(1).describe("What the result must contain, one item each: the parts, facts and attachments a reader expects"),
2072
- acceptance: z3.array(z3.object({
2073
- id: z3.string().describe("AC-1, AC-2 and so on, in order"),
2074
- text: z3.string().describe("One observable property of the result that a checker can confirm by reading it")
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: z3.array(z3.string()).describe("Rules the result must keep: length, tone, language, deadlines, what must not be promised. Empty when none"),
2077
- outOfScope: z3.array(z3.string()).describe("What this work does not do: nearby things someone might be tempted to add. Empty when nothing is tempting"),
2078
- sources: z3.array(z3.object({
2079
- source: z3.string().describe("Where the facts come from: a project knowledge note, a linked item, a file of the task, a connected service read only"),
2080
- use: z3.string().describe("What to take from it")
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: z3.string().describe("Who approves the result, as the task or the board names them"),
2083
- decisions: z3.array(z3.object({
2084
- question: z3.string(),
2085
- answer: z3.string(),
2086
- by: z3.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
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: z3.array(z3.object({
2089
- text: z3.string().describe("The default you chose"),
2090
- reason: z3.string().describe("Why, so the owner can confirm or correct it quickly")
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: z3.array(questionSchema).describe("Questions only the owner can settle; the brief cannot be approved until each is answered or dismissed. Empty when none")
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 reply2(text2, isError = false) {
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 reply2(`Could not reach TAMsense to check this: ${message}. Call ${toolName} again in a moment.`, true);
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 tool3(
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 reply2(verdict.feedback, true);
2830
+ return reply3(verdict.feedback, true);
2115
2831
  }
2116
2832
  onSubmit(args);
2117
- return reply2(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.");
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 tool3(
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 reply2(verdict.feedback, true);
2854
+ return reply3(verdict.feedback, true);
2139
2855
  }
2140
2856
  if (!builderCheckRan() && !reminded) {
2141
2857
  reminded = true;
2142
- return reply2(BUILDER_CHECK_FIRST, true);
2858
+ return reply3(BUILDER_CHECK_FIRST, true);
2143
2859
  }
2144
2860
  onSubmit({ spec: args, problems: verdict.problems });
2145
- return reply2(verdict.problems.length > 0 ? `Spec saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Spec saved.");
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 tool3(
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 reply2(verdict.feedback, true);
2880
+ return reply3(verdict.feedback, true);
2165
2881
  }
2166
2882
  onSubmit({ spec: args, problems: verdict.problems });
2167
- return reply2(verdict.problems.length > 0 ? `Brief saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Brief saved.");
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 SERVER_NAME = "tamsense";
2984
+ var SERVER_NAME2 = "tamsense";
2269
2985
  var CAPTION_FIELDS = {
2270
- title: z4.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"'),
2271
- purpose: z4.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
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 purpose. 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.",
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 purpose. Videos recorded with video: true are attached at the end of the run anyway. Close the browser context first so the file is complete.",
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 tool4(
3011
+ return tool5(
2293
3012
  spec.name,
2294
3013
  spec.description,
2295
- { path: z4.string().describe(spec.path), ...CAPTION_FIELDS },
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 tool4(
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: z4.boolean().describe("true only if nothing must be fixed"),
2305
- report: z4.string().describe("what you checked and, if it failed, exactly what to fix with file and line"),
2306
- couldNotRun: z4.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")
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 tool4(
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: z4.string().describe("name or id of the step to hand the task to"),
2320
- outcome: z4.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
2321
- note: z4.string().describe("for the next agent and the owner: what was done, what to check, what is open")
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 tool4(
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: z4.union([z4.number(), z4.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"),
2339
- why: z4.string().describe('one line on why this person: "assignee, and the run is on her computer", "Designer, owns empty states"'),
2340
- question: z4.string().describe("one plain question the person can answer without reading the code"),
2341
- context: z4.string().optional().describe("one line on what you saw that led to the question"),
2342
- tried: z4.array(z4.object({
2343
- did: z4.string().describe("the command or step"),
2344
- error: z4.string().optional().describe("the exact error line, if there was one"),
2345
- result: z4.string().describe("what it showed, one line")
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: z4.array(z4.object({
2348
- label: z4.string().describe("a short answer the person can pick"),
2349
- detail: z4.string().describe("one line on what happens if it is picked")
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: z4.boolean().describe("true when you cannot go on without the answer"),
2352
- assumption: z4.string().optional().describe("when blocking is false: what you continue with until the answer comes")
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 reply3;
3081
+ let reply4;
2363
3082
  try {
2364
- reply3 = await asking.send(choice.body);
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 = reply3.id;
3088
+ waiting.waitingFor = reply4.id;
2370
3089
  }
2371
- return { content: [{ type: "text", text: askedText(reply3.name, choice.body) }] };
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: createSdkMcpServer({ name: SERVER_NAME, version: "1.0.0", tools }),
2419
- allowed: tools.map((item) => `mcp__${SERVER_NAME}__${item.name}`)
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: { [SERVER_NAME]: tamsense.server },
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(query2);
3244
+ var runClaude = claudeRunner(query3);
2525
3245
 
2526
3246
  // src/config.ts
2527
- import { mkdir as mkdir3, readFile as readFile3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
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 join4 } from "path";
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 join4(home, ".tamsense", "runner.json");
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 mkdir3(dirname2(path), { recursive: true, mode: OWNER_ONLY_FOLDER });
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 reply3 = await tools.api.request("POST", PAIRING_TOKEN_PATH, { deviceCode: pairing.deviceCode });
2583
- if (reply3.status === "connected") {
2584
- return { token: reply3.token, device: reply3.device };
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 (reply3.status === "denied") {
3306
+ if (reply4.status === "denied") {
2587
3307
  throw new Error(DENIED_TEXT);
2588
3308
  }
2589
- if (reply3.status === "expired") {
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 existsSync3 } from "fs";
2598
- import { join as join6 } from "path";
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 basename2, dirname as dirname3, join as join5 } from "path";
2605
- import process2 from "process";
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 = process2.env[RECORDINGS_DIR_ENV];
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
- process2.stderr.write(`[tamsense recorder] ${text2}
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 = process2.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 = process2.env, script2 = process2.argv[1]) {
2677
- const from = script2 ? `, opened by ${basename2(script2)}` : "";
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(join5(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
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 (!timer) timer = setTimeout(send, ${FLUSH_MS});
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 = join5(dir, `${id}${REPLAY_SUFFIX}`);
2737
- this.metaFile = join5(dir, `${id}${META_SUFFIX}`);
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 (process2.env[RECORDINGS_DIR_ENV]) {
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 existsSync3(path) ? path : null;
3653
+ return existsSync6(path) ? path : null;
2911
3654
  }
2912
3655
  function recordingsFolder(home, runId) {
2913
- return join6(home, ".tamsense", "recordings", `run-${runId}`);
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 wait } from "timers/promises";
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 wait(ms, void 0, { signal }).catch(() => {
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 process3 from "process";
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 = process3.platform === "win32";
3039
- return new Promise((resolve3, reject) => {
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
- process3.on("SIGINT", ignoreInterrupt);
3786
+ process5.on("SIGINT", ignoreInterrupt);
3044
3787
  child.on("error", (error) => {
3045
- process3.off("SIGINT", ignoreInterrupt);
3788
+ process5.off("SIGINT", ignoreInterrupt);
3046
3789
  reject(error);
3047
3790
  });
3048
3791
  child.on("close", (code) => {
3049
- process3.off("SIGINT", ignoreInterrupt);
3050
- resolve3(code ?? 1);
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 process4 from "process";
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
- sending = sending.then(drain);
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 existsSync4 } from "fs";
3098
- import { cp, mkdir as mkdir4, rm as rm5 } from "fs/promises";
3099
- import { join as join7 } from "path";
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 join7(home, ".tamsense", "libraries", `run-${runId}`);
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 mkdir4(join7(folder, "skills"), { recursive: true });
3120
- await cp(join7(source, ".claude-plugin"), join7(folder, ".claude-plugin"), { recursive: true });
3121
- await cp(join7(source, "agents"), join7(folder, "agents"), { recursive: true });
3122
- for (const skill of skills.filter((name) => SKILL_NAME.test(name) && existsSync4(join7(source, "skills", name)))) {
3123
- await cp(join7(source, "skills", skill), join7(folder, "skills", skill), { recursive: true });
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 basename3, dirname as dirname4, extname as extname2, isAbsolute as isAbsolute2, join as join8 } from "path";
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 basename3(file).replace(REPLAY_SUFFIX, "");
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(join8(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
3962
+ return readMeta(join10(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
3205
3963
  }
3206
- const folder = basename3(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
3207
- for (const name of await readdir(folder).catch(() => [])) {
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(join8(folder, name));
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(join8(dir, LEDGER_FILE), "utf8")));
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(join8(dir, LEDGER_FILE), JSON.stringify([...done2]));
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 readdir(dir).catch(() => [])).sort();
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(join8(dir, name));
3241
- const replay = join8(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
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, caption) {
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: basename3(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
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, caption, meta) {
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: basename3(file), ...caption, contentType, durationMs, marks });
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 (!isAbsolute2(request.path)) {
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, caption) : uploadVideo(upload, request.path, caption, meta));
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, caption) : uploadVideo(upload, recording.file, caption, recording.meta));
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 basename4, extname as extname3, isAbsolute as isAbsolute3 } from "path";
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 (!isAbsolute3(request.path)) {
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: basename4(request.path), ...caption, contentType, data };
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 RETRY_AFTER_ERROR_MS = 15e3;
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 reply3;
4231
+ let reply4;
3466
4232
  try {
3467
- reply3 = await tools.api.request("POST", runPath(job.runId, "inbox"), body);
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 (!reply3.keepGoing) {
4243
+ if (!reply4.keepGoing) {
3478
4244
  closedOnSite();
3479
4245
  return;
3480
4246
  }
3481
- for (const message of reply3.messages) {
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 reply3.helperStops ?? []) {
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 (reply3.model !== null || reply3.effort !== null) {
3493
- const change = { model: reply3.model, effort: reply3.effort };
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 reply3 = await tools.api.request("POST", runPath(job.runId, "permissions"), body);
3549
- if (reply3.decision === "wait") {
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 reply3;
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(process4.env, dirname5(process4.execPath));
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 shouldPublish = job.stage.kind === "code" && !isFolderJob(job) && !isReadOnlyCode(job.stage) && outcome.completed && waitingFor === null;
3625
- const pushed = shouldPublish ? await publishWorkspace(workspace, job, await gitToken(tools.api, job), identity) : [];
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, folder = false) {
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
- return folder ? "Done. The result is on the task on TAMsense." : `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.`;
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, isFolderJob(job)));
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
- if (running.size >= parallel) {
3776
- await Promise.race(running);
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
- let reply3;
4635
+ const body = runCheck && tools.version ? { version: tools.version, jobs: !jobsFull, checks: !checksFull } : void 0;
4636
+ let reply4;
3780
4637
  try {
3781
- reply3 = await tools.api.request("POST", NEXT_RUN_PATH);
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
- tools.print(`Could not reach TAMsense: ${messageOf3(error)}. Trying again soon.`);
3788
- await tools.sleep(RETRY_AFTER_ERROR_MS, stopAll);
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
- if (reply3.job) {
3792
- start(reply3.job);
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
- process5.stdout.write(`${line}
4672
+ process7.stdout.write(`${line}
3810
4673
  `);
3811
4674
  }
3812
4675
  function serverFor(flag, saved) {
3813
- return serverOrigin(flag ?? process5.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
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
- process5.once("SIGINT", () => {
4702
+ process7.once("SIGINT", () => {
3840
4703
  print("Stopping\u2026 Press Ctrl+C again to quit at once.");
3841
4704
  stop.abort();
3842
- process5.once("SIGINT", () => process5.exit(INTERRUPTED_EXIT_CODE));
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: process5.execPath, env: process5.env, print, run: runProcess, exists: existsSync5 };
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(process5.env, currentVersion(), () => latestVersion());
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
- process5.exitCode = await relaunch(process5.argv.slice(2), process5.env);
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 api = createApi(saved.server, saved.token);
3880
- const identity = await api.request("GET", IDENTITY_PATH);
3881
- const problem = teamKeyProblem(identity.device.team, process5.env);
3882
- if (problem) {
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
- await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder() }, stopOnInterrupt(), parallel);
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(process5.argv.slice(2), process5.env));
4803
+ await run(parseCommand(process7.argv.slice(2), process7.env));
3941
4804
  } catch (error) {
3942
- process5.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
4805
+ process7.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
3943
4806
  `);
3944
- process5.exitCode = 1;
4807
+ process7.exitCode = 1;
3945
4808
  }
3946
4809
  //# sourceMappingURL=cli.js.map