@tamsensedev/runner 0.5.1 → 0.9.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.
Files changed (3) hide show
  1. package/dist/cli.js +1077 -447
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { existsSync as existsSync4 } from "fs";
4
+ import { existsSync as existsSync5 } from "fs";
5
5
  import { homedir as homedir3 } from "os";
6
6
  import process5 from "process";
7
7
 
@@ -33,9 +33,9 @@ function textOf(value) {
33
33
  return typeof value === "string" && value.trim() ? value : null;
34
34
  }
35
35
  async function errorOf(response) {
36
- const reply2 = await response.json().catch(() => ({}));
37
- const code = textOf(reply2.data?.code);
38
- const message = textOf(reply2.message) ?? textOf(reply2.statusMessage) ?? `TAMsense answered with status ${response.status}`;
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}`;
39
39
  return new ApiError(response.status, code, message);
40
40
  }
41
41
  function createApi(server, token = null, fetchImpl = fetch) {
@@ -187,7 +187,7 @@ function findNpm(nodePath, env, exists) {
187
187
  }
188
188
  return null;
189
189
  }
190
- var runProcess = (command, args, options) => new Promise((resolve, reject) => {
190
+ var runProcess = (command, args, options) => new Promise((resolve3, reject) => {
191
191
  const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
192
192
  const tail = [];
193
193
  for (const stream of [child.stdout, child.stderr]) {
@@ -201,7 +201,7 @@ var runProcess = (command, args, options) => new Promise((resolve, reject) => {
201
201
  });
202
202
  }
203
203
  child.on("error", reject);
204
- child.on("close", (code) => resolve({ code: code ?? 1, output: tail.join("\n") }));
204
+ child.on("close", (code) => resolve3({ code: code ?? 1, output: tail.join("\n") }));
205
205
  });
206
206
  function failureText(what, result) {
207
207
  if (OFFLINE_PATTERN.test(result.output)) {
@@ -314,8 +314,8 @@ async function probeCapabilities(stop) {
314
314
  stop?.throwIfAborted();
315
315
  let release = () => {
316
316
  };
317
- const released = new Promise((resolve) => {
318
- release = resolve;
317
+ const released = new Promise((resolve3) => {
318
+ release = resolve3;
319
319
  });
320
320
  const nothing = {
321
321
  [Symbol.asyncIterator]: () => ({
@@ -340,9 +340,8 @@ async function probeCapabilities(stop) {
340
340
  }
341
341
 
342
342
  // src/claude.ts
343
- import { fileURLToPath } from "url";
344
- import { createSdkMcpServer, query as query2, tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
345
- import { z as z3 } from "zod";
343
+ import { createSdkMcpServer, query as query2, tool as tool4 } from "@anthropic-ai/claude-agent-sdk";
344
+ import { z as z4 } from "zod";
346
345
 
347
346
  // src/askPerson.ts
348
347
  var MAX_ASK_TRIES = 5;
@@ -402,21 +401,21 @@ function shorten(text2) {
402
401
  const line = text2.replace(/\s+/g, " ").trim();
403
402
  return line.length > MAX_ACTIVITY_LENGTH ? `${line.slice(0, MAX_ACTIVITY_LENGTH - 1)}\u2026` : line;
404
403
  }
405
- function activityLine(tool4, input) {
404
+ function activityLine(tool5, input) {
406
405
  const fields = typeof input === "object" && input !== null ? input : {};
407
- if (tool4 === "Bash" && typeof fields.command === "string") {
406
+ if (tool5 === "Bash" && typeof fields.command === "string") {
408
407
  return `$ ${shorten(fields.command)}`;
409
408
  }
410
- if (FILE_TOOLS.has(tool4) && typeof fields.file_path === "string") {
411
- return `${tool4} ${shorten(fields.file_path)}`;
409
+ if (FILE_TOOLS.has(tool5) && typeof fields.file_path === "string") {
410
+ return `${tool5} ${shorten(fields.file_path)}`;
412
411
  }
413
- if (tool4 === "Skill" && typeof fields.skill === "string") {
412
+ if (tool5 === "Skill" && typeof fields.skill === "string") {
414
413
  return `Skill ${shorten(fields.skill)}`;
415
414
  }
416
- if (HELPER_TOOLS.has(tool4) && typeof fields.subagent_type === "string") {
415
+ if (HELPER_TOOLS.has(tool5) && typeof fields.subagent_type === "string") {
417
416
  return `Agent ${shorten(fields.subagent_type)}`;
418
417
  }
419
- return tool4;
418
+ return tool5;
420
419
  }
421
420
  function usageOf(modelUsage) {
422
421
  const entries = Object.values(modelUsage);
@@ -440,6 +439,33 @@ function outcomeOf(result, lastText) {
440
439
  return { completed: false, report: result.errors.join("\n") || `Claude stopped early: ${result.subtype}`, ...measured };
441
440
  }
442
441
 
442
+ // src/protocol.ts
443
+ var MAX_RESULT_FILE_BYTES = 10 * 1024 * 1024;
444
+ var HELPER_STOPPED = "stopped";
445
+ var MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
446
+ var MAX_ATTACHMENTS_PER_RUN = 20;
447
+ var MAX_VIDEO_BYTES = 200 * 1024 * 1024;
448
+ var PAIRINGS_PATH = "/api/runner/pairings";
449
+ var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
450
+ var IDENTITY_PATH = "/api/runner/me";
451
+ var NEXT_RUN_PATH = "/api/runner/runs/next";
452
+ var CAPABILITIES_PATH = "/api/runner/me/capabilities";
453
+ function runPath(runId, action) {
454
+ return `/api/runner/runs/${runId}/${action}`;
455
+ }
456
+ function resultFilePath(runId, resultId) {
457
+ return `/api/runner/runs/${runId}/results/${resultId}`;
458
+ }
459
+ function knowledgePath(runId, entryId) {
460
+ return `/api/runner/runs/${runId}/knowledge/${entryId}`;
461
+ }
462
+ function permissionPath(runId, permissionId) {
463
+ return `/api/runner/runs/${runId}/permissions/${permissionId}`;
464
+ }
465
+ function recordingUploadPath(runId, upload, part) {
466
+ return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
467
+ }
468
+
443
469
  // src/feed.ts
444
470
  var MAX_TEXT_LENGTH = 8e3;
445
471
  var MAX_RESULT_LENGTH = 2e3;
@@ -459,29 +485,29 @@ function textField(fields, name) {
459
485
  return typeof value === "string" && value.trim() ? value : null;
460
486
  }
461
487
  function toolDraft(block, parent) {
462
- const tool4 = (block.name ?? "tool").replace(MCP_PREFIX, "");
488
+ const tool5 = (block.name ?? "tool").replace(MCP_PREFIX, "");
463
489
  const fields = fieldsOf(block.input);
464
- const base = { tool: tool4, ref: block.id ?? null, parent, failed: false };
465
- if (tool4 === "Skill" && textField(fields, "skill")) {
490
+ const base = { tool: tool5, ref: block.id ?? null, parent, failed: false };
491
+ if (tool5 === "Skill" && textField(fields, "skill")) {
466
492
  return { ...base, kind: "skill", text: clip(textField(fields, "skill"), MAX_SUMMARY_LENGTH) };
467
493
  }
468
- if (HELPER_TOOLS2.has(tool4)) {
494
+ if (HELPER_TOOLS2.has(tool5)) {
469
495
  const who = textField(fields, "subagent_type") ?? "general-purpose";
470
496
  const what = textField(fields, "description");
471
497
  return { ...base, kind: "agent", text: clip(what ? `${who}: ${what}` : who, MAX_SUMMARY_LENGTH) };
472
498
  }
473
- if (tool4 === "submit_verdict") {
499
+ if (tool5 === "submit_verdict") {
474
500
  const verdict = fields.couldNotRun === true ? "Could not run" : fields.passed === true ? "Passed" : "Did not pass";
475
501
  return { ...base, kind: "tool", text: clip(`${verdict}. ${textField(fields, "report") ?? ""}`, MAX_SUMMARY_LENGTH) };
476
502
  }
477
- if (tool4 === "hand_off") {
503
+ if (tool5 === "hand_off") {
478
504
  return { ...base, kind: "tool", text: clip(`Hand off to ${textField(fields, "to") ?? "?"}: ${textField(fields, "note") ?? ""}`, MAX_SUMMARY_LENGTH) };
479
505
  }
480
- if (tool4 in ATTACH_LABELS) {
481
- return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool4]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
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) };
482
508
  }
483
509
  const command = textField(fields, "command");
484
- const summary = tool4 === "Bash" && command ? `$ ${command}` : activityLine(tool4, block.input);
510
+ const summary = tool5 === "Bash" && command ? `$ ${command}` : activityLine(tool5, block.input);
485
511
  return { ...base, kind: "tool", text: clip(summary, MAX_SUMMARY_LENGTH) };
486
512
  }
487
513
  function resultText(content) {
@@ -493,6 +519,10 @@ function resultText(content) {
493
519
  }
494
520
  return content.map((part) => part.type === "text" ? part.text ?? "" : part.type === "image" ? "[image]" : "").filter(Boolean).join("\n");
495
521
  }
522
+ function helperEndDraft(ref, parent, end, text2) {
523
+ const stopped = end === "stopped";
524
+ return { kind: "result", text: stopped ? "" : clip(text2, MAX_RESULT_LENGTH), tool: stopped ? HELPER_STOPPED : null, ref, parent, failed: end === "failed" };
525
+ }
496
526
  function feedOf(message) {
497
527
  if (message.type !== "assistant" && message.type !== "user") {
498
528
  return [];
@@ -561,6 +591,77 @@ function chooseHandoff(targets, request) {
561
591
  return { handoff: { toColumnId: edge.columnId, outcome: edge.outcome, note } };
562
592
  }
563
593
 
594
+ // src/helpers.ts
595
+ function helperTasks() {
596
+ const byRef = /* @__PURE__ */ new Map();
597
+ const refByTask = /* @__PURE__ */ new Map();
598
+ const parents = /* @__PURE__ */ new Map();
599
+ function observe(message) {
600
+ if (message.type !== "system") {
601
+ return [];
602
+ }
603
+ if (message.subtype === "task_started" && message.task_type === "local_agent" && message.tool_use_id) {
604
+ byRef.set(message.tool_use_id, { taskId: message.task_id, background: message.is_backgrounded === true, stopped: false, over: false });
605
+ refByTask.set(message.task_id, message.tool_use_id);
606
+ return [];
607
+ }
608
+ if (message.subtype === "task_updated" && message.patch.is_backgrounded !== void 0) {
609
+ const ref2 = refByTask.get(message.task_id);
610
+ const task2 = ref2 ? byRef.get(ref2) : void 0;
611
+ if (task2) {
612
+ task2.background = message.patch.is_backgrounded;
613
+ }
614
+ return [];
615
+ }
616
+ if (message.subtype !== "task_notification") {
617
+ return [];
618
+ }
619
+ const ref = message.tool_use_id ?? refByTask.get(message.task_id);
620
+ const task = ref ? byRef.get(ref) : void 0;
621
+ if (!ref || !task || task.over) {
622
+ return [];
623
+ }
624
+ task.stopped = message.status === "stopped";
625
+ if (!task.background) {
626
+ return [];
627
+ }
628
+ task.over = true;
629
+ return [helperEndDraft(ref, parents.get(ref) ?? null, message.status, message.summary)];
630
+ }
631
+ function adjust(draft) {
632
+ if (draft.kind === "agent" && draft.ref) {
633
+ parents.set(draft.ref, draft.parent);
634
+ }
635
+ const task = draft.kind === "result" && draft.ref ? byRef.get(draft.ref) : void 0;
636
+ if (!task || !draft.ref) {
637
+ return [draft];
638
+ }
639
+ if (task.background) {
640
+ return [];
641
+ }
642
+ task.over = true;
643
+ return task.stopped ? [helperEndDraft(draft.ref, draft.parent, "stopped", "")] : [draft];
644
+ }
645
+ async function stop(target, ref) {
646
+ const task = byRef.get(ref);
647
+ if (!task) {
648
+ return "unknown";
649
+ }
650
+ if (task.over || task.stopped) {
651
+ return "finished";
652
+ }
653
+ await target.stopTask(task.taskId);
654
+ return "stopped";
655
+ }
656
+ return { observe, adjust, stop };
657
+ }
658
+ function helperStoppedNote(stop) {
659
+ return `${stop.byName} stopped your helper ${stop.name} on TAMsense. Go on without it and say in your result what it did not finish. Start it again only if a person asks.`;
660
+ }
661
+ function helperStopRequest(stop) {
662
+ return `${stop.byName} asked on TAMsense to stop your helper ${stop.name}. Stop it now, go on without it and say in your result what it did not finish. Start it again only if a person asks.`;
663
+ }
664
+
564
665
  // src/input.ts
565
666
  var DEFAULT_MODEL = "default";
566
667
  var QUIET_MS = 1e3;
@@ -571,8 +672,11 @@ function userMessage(text2) {
571
672
  function humanText(message) {
572
673
  return `Message from ${message.authorName}: ${message.text}`;
573
674
  }
675
+ function priorityOf(mode) {
676
+ return mode === "steer" ? "now" : "next";
677
+ }
574
678
  function humanMessage(message) {
575
- return { ...userMessage(humanText(message)), priority: message.mode === "steer" ? "now" : "next", client_composed: true };
679
+ return { ...userMessage(humanText(message)), priority: priorityOf(message.mode), client_composed: true };
576
680
  }
577
681
  function switchLine(change) {
578
682
  const model = change.model === DEFAULT_MODEL ? "the default model" : change.model;
@@ -601,6 +705,7 @@ function inputChannel(options = {}) {
601
705
  let wake = null;
602
706
  let timers = [];
603
707
  let applySwitch2 = null;
708
+ let applyStop = null;
604
709
  let waitingSwitch = null;
605
710
  function wakeUp() {
606
711
  wake?.();
@@ -653,8 +758,8 @@ function inputChannel(options = {}) {
653
758
  }
654
759
  const next = queue.shift();
655
760
  if (!next) {
656
- await new Promise((resolve) => {
657
- wake = resolve;
761
+ await new Promise((resolve3) => {
762
+ wake = resolve3;
658
763
  });
659
764
  continue;
660
765
  }
@@ -714,11 +819,14 @@ function inputChannel(options = {}) {
714
819
  onSwitch(apply) {
715
820
  applySwitch2 = apply;
716
821
  if (waitingSwitch) {
717
- const { change, resolve } = waitingSwitch;
822
+ const { change, resolve: resolve3 } = waitingSwitch;
718
823
  waitingSwitch = null;
719
- void apply(change).then(() => resolve(true), () => resolve(false));
824
+ void apply(change).then(() => resolve3(true), () => resolve3(false));
720
825
  }
721
826
  },
827
+ onStopHelper(stop) {
828
+ applyStop = stop;
829
+ },
722
830
  close,
723
831
  push(message) {
724
832
  if (closed || seen.has(message.id)) {
@@ -729,6 +837,17 @@ function inputChannel(options = {}) {
729
837
  wakeUp();
730
838
  return true;
731
839
  },
840
+ note(text2, mode) {
841
+ if (closed) {
842
+ return false;
843
+ }
844
+ queue.push({ ids: [], message: { ...userMessage(text2), priority: priorityOf(mode) } });
845
+ wakeUp();
846
+ return true;
847
+ },
848
+ async stopHelper(ref) {
849
+ return closed || !applyStop ? "unknown" : await applyStop(ref);
850
+ },
732
851
  async checking(work2) {
733
852
  const epoch = idle ? idleEpoch : null;
734
853
  inFlight += 1;
@@ -763,8 +882,8 @@ function inputChannel(options = {}) {
763
882
  const earlier = waitingSwitch;
764
883
  earlier?.resolve(false);
765
884
  const merged = { model: change.model ?? earlier?.change.model ?? null, effort: change.effort ?? earlier?.change.effort ?? null };
766
- return new Promise((resolve) => {
767
- waitingSwitch = { change: merged, resolve };
885
+ return new Promise((resolve3) => {
886
+ waitingSwitch = { change: merged, resolve: resolve3 };
768
887
  });
769
888
  }
770
889
  };
@@ -927,8 +1046,8 @@ function messageOf2(error) {
927
1046
  async function decisionOf(tools, first, signal) {
928
1047
  const now = tools.now ?? Date.now;
929
1048
  const giveUpAt = Date.parse(first.waitUntil) + SITE_SILENCE_MS;
930
- let reply2 = first;
931
- while (reply2.decision === "wait") {
1049
+ let reply3 = first;
1050
+ while (reply3.decision === "wait") {
932
1051
  await tools.sleep(CHECK_INTERVAL_MS, signal);
933
1052
  if (signal.aborted) {
934
1053
  return STOPPED_TEXT2;
@@ -936,9 +1055,9 @@ async function decisionOf(tools, first, signal) {
936
1055
  if (now() > giveUpAt) {
937
1056
  return SITE_SILENT_TEXT;
938
1057
  }
939
- reply2 = await tools.check(reply2.id).catch(() => reply2);
1058
+ reply3 = await tools.check(reply3.id).catch(() => reply3);
940
1059
  }
941
- return reply2;
1060
+ return reply3;
942
1061
  }
943
1062
  function askPermission(tools, lastWords, folder) {
944
1063
  return async (toolName, input, options) => {
@@ -959,15 +1078,203 @@ function askPermission(tools, lastWords, folder) {
959
1078
  } catch (error) {
960
1079
  return deny(`Could not ask for permission on TAMsense: ${messageOf2(error)}. Do it another way, or explain in your report why it is needed.`);
961
1080
  }
962
- const reply2 = await decisionOf(tools, first, options.signal);
963
- if (typeof reply2 === "string") {
964
- return deny(reply2);
1081
+ const reply3 = await decisionOf(tools, first, options.signal);
1082
+ if (typeof reply3 === "string") {
1083
+ return deny(reply3);
965
1084
  }
966
- return reply2.decision === "allow" ? { behavior: "allow", updatedInput: input } : deny(reply2.message);
1085
+ return reply3.decision === "allow" ? { behavior: "allow", updatedInput: input } : deny(reply3.message);
967
1086
  };
968
1087
  }
969
1088
  var denyWhenAsked = async () => deny(NOBODY_TO_ASK);
970
1089
 
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
+ }
1126
+ 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)
1130
+ };
1131
+ }
1132
+ function handOverTools(job, api) {
1133
+ const boards = job.handOverBoards ?? [];
1134
+ if (boards.length === 0 || job.stage.kind !== "code") {
1135
+ return null;
1136
+ }
1137
+ return { boards, send: (body) => api.request("POST", runPath(job.runId, "hand-over"), body) };
1138
+ }
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}`;
1145
+ }
1146
+ taken.add(name.toLowerCase());
1147
+ return name;
1148
+ }
1149
+ function linksText(links) {
1150
+ return links.map((link) => `- ${link.title}: ${link.url ?? ""}`).join("\n");
1151
+ }
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 [];
1157
+ }
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 ?? "");
1174
+ }
1175
+ lines.push(`${RESULTS_FOLDER}/${name}: ${result.title}${result.version ? `, version ${result.version}` : ""}`);
1176
+ }
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)`);
1182
+ }
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
+ `);
1187
+ }
1188
+ return lines;
1189
+ }
1190
+ async function prepareFolder(job, root, api) {
1191
+ if (!job.keepWorkspace) {
1192
+ await rm2(root, { recursive: true, force: true });
1193
+ }
1194
+ await mkdir2(root, { recursive: true });
1195
+ return { root, repositories: [], results: await writeResults(join2(root, RESULTS_FOLDER), job, api) };
1196
+ }
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}.`;
1202
+ }
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.`;
1205
+ }
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") };
1208
+ }
1209
+ function reply(text2, isError = false) {
1210
+ return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
1211
+ }
1212
+ async function sent(send) {
1213
+ try {
1214
+ return reply((await send()).text);
1215
+ } 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);
1217
+ }
1218
+ }
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
+
971
1278
  // src/specText.ts
972
1279
  function isRecord(value) {
973
1280
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -1020,10 +1327,31 @@ var RISK_LEVELS = ["low", "medium", "high"];
1020
1327
  function present(lines) {
1021
1328
  return lines.filter((line) => line !== null);
1022
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
+ }
1023
1348
  function specPromptText(spec) {
1024
1349
  if (!isRecord(spec)) {
1025
1350
  return null;
1026
1351
  }
1352
+ if (spec.format === "brief") {
1353
+ return briefPromptText(spec);
1354
+ }
1027
1355
  const structured = Array.isArray(spec.outOfScope) || isRecord(spec.testPlan);
1028
1356
  const plan = isRecord(spec.testPlan) ? spec.testPlan : {};
1029
1357
  const risks = records(spec.risks).filter((item) => text(item.risk));
@@ -1033,7 +1361,7 @@ function specPromptText(spec) {
1033
1361
  text(spec.context) && `${structured ? "Summary" : "Context"}: ${text(spec.context)}`,
1034
1362
  section("In scope:", texts(spec.inScope).map((item) => `- ${item}`)),
1035
1363
  section("Out of scope (do not build):", texts(spec.outOfScope).map((item) => `- ${item}`)),
1036
- section("Decisions:", 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"})`)),
1364
+ section("Decisions:", decisionLines(spec)),
1037
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]}]` : ""}`)),
1038
1366
  section("Changes, in order:", records(spec.changes).filter((item) => text(item.file)).flatMap(changeLines)),
1039
1367
  section("Acceptance criteria:", present(Array.isArray(spec.acceptance) ? spec.acceptance.map(criterionLine) : [])),
@@ -1048,6 +1376,209 @@ function specPromptText(spec) {
1048
1376
  ].filter(Boolean).join("\n\n");
1049
1377
  }
1050
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
+
1421
+ // src/lock.ts
1422
+ function keyedLock() {
1423
+ const tails = /* @__PURE__ */ new Map();
1424
+ return {
1425
+ async run(key, work2) {
1426
+ const previous = tails.get(key) ?? Promise.resolve();
1427
+ let release = () => {
1428
+ };
1429
+ const done2 = new Promise((resolve3) => {
1430
+ release = resolve3;
1431
+ });
1432
+ const tail = previous.then(() => done2);
1433
+ tails.set(key, tail);
1434
+ await previous;
1435
+ try {
1436
+ return await work2();
1437
+ } finally {
1438
+ release();
1439
+ if (tails.get(key) === tail) {
1440
+ tails.delete(key);
1441
+ }
1442
+ }
1443
+ }
1444
+ };
1445
+ }
1446
+
1447
+ // src/workspace.ts
1448
+ var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
1449
+ var cacheLock = keyedLock();
1450
+ function githubRemoteUrl(fullName) {
1451
+ return `https://github.com/${fullName}.git`;
1452
+ }
1453
+ function tamsenseFolder(home, ...parts) {
1454
+ return join3(home, ".tamsense", ...parts);
1455
+ }
1456
+ function folderNames(repositories) {
1457
+ const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
1458
+ const counts = /* @__PURE__ */ new Map();
1459
+ for (const repository of repositories) {
1460
+ counts.set(shortName(repository.fullName), (counts.get(shortName(repository.fullName)) ?? 0) + 1);
1461
+ }
1462
+ return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
1463
+ }
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 });
1470
+ }
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 });
1475
+ }
1476
+ function stepFolder(job) {
1477
+ const task = `task-${job.taskId}`;
1478
+ return typeof job.stage.columnId === "number" ? [task, `step-${job.stage.columnId}`] : [task];
1479
+ }
1480
+ function isReadOnlyCheck(job) {
1481
+ return job.stage.kind === "review" || job.stage.kind === "test";
1482
+ }
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;
1494
+ }
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
+ }
1500
+ }
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}`;
1504
+ }
1505
+ async function keptBase(cache, path, job, repository) {
1506
+ if (!job.keepWorkspace || !existsSync2(join3(path, ".git"))) {
1507
+ return null;
1508
+ }
1509
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
1510
+ if (branch.code !== 0 || branch.stdout !== (isReadOnlyCheck(job) ? "HEAD" : job.branch)) {
1511
+ return null;
1512
+ }
1513
+ return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
1514
+ }
1515
+ function taskFolder(home, job) {
1516
+ return tamsenseFolder(home, "work", ...stepFolder(job));
1517
+ }
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 };
1545
+ }
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
+ };
1557
+ }
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
+ }
1578
+ }
1579
+ return pushed;
1580
+ }
1581
+
1051
1582
  // src/prompt.ts
1052
1583
  var VERDICT_TOOL_NAME = "submit_verdict";
1053
1584
  var SCREENSHOT_TOOL_NAME = "attach_screenshot";
@@ -1070,6 +1601,11 @@ var CODE_RULES = [
1070
1601
  ];
1071
1602
  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.";
1072
1603
  var CODE_REPORT_RULE = "End with a short report for the reviewer: what you changed, how you checked it, and anything left open.";
1604
+ var READ_ONLY_CODE_RULES = [
1605
+ "Your role on this step only reads. Do the work the task asks for by researching and running what helps, then report. Do not change, commit or push product code: nothing from this run is pushed. You may create throwaway files outside the repositories.",
1606
+ "Check what you report with the project's own tools where they exist, and say how you checked it."
1607
+ ];
1608
+ var READ_ONLY_REPORT_RULE = "End with a short report: what you found, how you checked it, and what is left open.";
1073
1609
  var CHECK_RULES = [
1074
1610
  "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.",
1075
1611
  "Do not change, commit or push product code. You may run anything and create throwaway files outside the repositories.",
@@ -1080,9 +1616,27 @@ var SCREENSHOT_RULE = `If you render screens (screenshots of the interface), att
1080
1616
  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".';
1081
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.`;
1082
1618
  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
+ 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
+ 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.";
1621
+ var FOLDER_DO_RULES = [
1622
+ `Save what you make on the task with the TAMsense tools: ${SAVE_DOCUMENT_TOOL_NAME} for text in Markdown, ${SAVE_TABLE_TOOL_NAME} for rows of data as CSV, ${ATTACH_FILE_TOOL_NAME} for files you made in this folder, ${ADD_LINK_TOOL_NAME} for pages the result relies on. Keep one title per document and save every change as a new version of it.`,
1623
+ "Use only facts from the task, the notes, the earlier results and what you read yourself; never invent numbers, names or dates.",
1624
+ RESULT_HOME_RULE
1625
+ ];
1626
+ var FOLDER_REWORK_RULE = "The task came back with notes. Read the earlier results in the results folder, fix what the notes ask and save new versions of the same documents.";
1627
+ var FOLDER_REPORT_RULE = "End with a short report for the people on the task: what you made, what you decided yourself and what is still open.";
1628
+ var FOLDER_CHECK_RULES = [
1629
+ `The result to check is in the ${RESULTS_FOLDER} folder: the latest version of each document (.md) and table (.csv), the files and links.md. Read all of it.`,
1630
+ `Do not change the result. You may create throwaway files outside the ${RESULTS_FOLDER} folder.`,
1631
+ "Judge it against the task, the notes of earlier steps and the instructions of this step. Be specific: every problem names the document, the place in it and what to change.",
1632
+ `Finish by calling the ${VERDICT_TOOL_NAME} tool exactly once: passed is true only if nothing must be fixed; report says what you checked and, if it failed, exactly what to fix.`
1633
+ ];
1083
1634
  var DEPENDENCIES_RULE = "The checkout may have no dependencies installed. To run the app or its tests, install the project's own dependencies from its lockfile with its package manager (pnpm install --frozen-lockfile, npm ci, yarn install --immutable and similar). Never add packages, change a lockfile or download browsers.";
1084
- function localAppRule(taskId, isCheck, canAsk) {
1085
- const project = `tamsense-task-${taskId}`;
1635
+ function composeProjectName(job) {
1636
+ return ["tamsense", ...stepFolder(job)].join("-");
1637
+ }
1638
+ function localAppRule(job, isCheck, canAsk) {
1639
+ const project = composeProjectName(job);
1086
1640
  const cannotRun = isCheck ? `call ${VERDICT_TOOL_NAME} with passed false and couldNotRun true, write the exact reason and what a person should do in the report, and do not hand off: the task waits on this step for a person.` : "say so in your report with the exact reason and what a person should do.";
1087
1641
  return [
1088
1642
  "When you need the running app, start a local copy from this task folder only.",
@@ -1099,6 +1653,10 @@ var STAGE_GOALS = {
1099
1653
  review: "Review the code for",
1100
1654
  test: "Test"
1101
1655
  };
1656
+ var READ_ONLY_GOAL = "Work on";
1657
+ function isReadOnlyCode(stage) {
1658
+ return stage.kind === "code" && stage.role?.access === "read";
1659
+ }
1102
1660
  var SPEC_UNATTENDED_RULE = `You are preparing a TAMsense task on the user's own computer. Nobody answers you during this run: the owner answers your questions and reviews the spec on the task page after it. So never stop to wait for a reply and never ask in your messages: hand in what this stage asks for with the TAMsense tool, then finish. People on the task may send you messages during this run, each starting with "Message from <name>:". Follow them.`;
1103
1661
  var READ_ONLY_RULE = "Read the code to research the task. Do not change, commit or push anything: nothing from this run is pushed, and the code step starts from a clean branch.";
1104
1662
  var RESEARCH_RULE = "Start by loading the tamsense:task-interview skill with the Skill tool and follow it: it holds the question test, the corner sweep and the rules for rounds. Research before asking: read the code in the repositories here and the evidence of the linked action, if there is one. Finding facts is your job; the decisions about intent are the owner's.";
@@ -1116,12 +1674,29 @@ var SPEC_RULES = [
1116
1674
  "Then reply with a short plain summary: the approach, the risk level and the open questions, if any. Do not paste the spec: the page shows it."
1117
1675
  ];
1118
1676
  var SPEC_WORK_MISSING = "TAMsense sent a spec step without its round and brief.";
1677
+ var BRIEF_UNATTENDED_RULE = `You are preparing a TAMsense task on the user's own computer. Nobody answers you during this run: the owner answers your questions and reviews the brief on the task page after it. So never stop to wait for a reply and never ask in your messages: hand in what this stage asks for with the TAMsense tool, then finish. People on the task may send you messages during this run, each starting with "Message from <name>:". Follow them.`;
1678
+ var BRIEF_RESEARCH_RULE = "Research before asking: read what the task, the project knowledge, the files in this folder and the connected services give you. Finding facts is your job; the decisions about intent are the owner's.";
1679
+ var RESEARCH_BRIEF_RULE = "Write the research brief as you go: findings (what the sources say that matters, each with where: the source), dataFacts (numbers and facts, each with its source; empty when none), approach (2 to 4 sentences), codeMap (always empty: this board has no code), assumptions (each corner you settled with a default, with the reason) and pendingDecisions (decisions that depend on an answer to this round; empty when none).";
1680
+ var RESULT_BRIEF_RULES = [
1681
+ 'Write the brief of the result: goal, context (the summary), contains (what the result must contain), acceptance (criteria with ids AC-1, AC-2 and so on in order, each one observable property a checker confirms by reading the result), constraints, outOfScope, sources (where the facts come from: knowledge, linked items, files of the task, connected services that are read only), approver (who approves the result), decisions, assumptions and openQuestions. Set format to "brief". Never write a code map, files to touch or repositories.',
1682
+ 'Start from the research brief and the conversation below. Apply the answers: an answer the owner gave is a decision with by: owner; an answer marked "Recommended option applied, not answered" is an assumption with the reason "not answered, the recommended option applies".',
1683
+ "Where the task, the answers and the project context are silent and a safe default exists, choose it and record it as an assumption with its reason. A point with no safe default that changes the result becomes an open question; keep them to 3 at most. Never invent numbers, names or dates.",
1684
+ `Hand in the FULL brief with ${SUBMIT_SPEC_TOOL_NAME}. TAMsense lints it; if it returns feedback, the brief was not saved: fix it and call ${SUBMIT_SPEC_TOOL_NAME} again. After two rounds of feedback the brief is saved as it is.`,
1685
+ "When the task already has a saved brief, the conversation holds the owner's feedback or answers to its open questions: apply them and hand in the full updated brief. An answered open question becomes a decision.",
1686
+ "Then reply with a short plain summary: what the result is and the open questions, if any. Do not paste the brief: the page shows it."
1687
+ ];
1688
+ var BRIEF_STYLE_RULE = "Write the research brief, the questions, the brief and your reply in the language the task is written in. Replies are plain text, no Markdown. Use real names, numbers and sources; if you could not verify something, say so. No em dashes and no en dashes; use commas, colons or a plain hyphen.";
1119
1689
  var SPEC_STYLE_RULE = "Write the brief, the questions, the spec and your reply in the language the task is written in. Replies are plain text, no Markdown. Use real file paths, function and table names; if you could not verify something, say so. No em dashes and no en dashes; use commas, colons or a plain hyphen.";
1120
1690
  function specText(spec) {
1121
1691
  const text2 = specPromptText(spec);
1122
1692
  return text2 ? `Approved spec:
1123
1693
  ${text2}` : null;
1124
1694
  }
1695
+ function briefText(spec) {
1696
+ const text2 = specPromptText(spec);
1697
+ return text2 ? `Approved brief:
1698
+ ${text2}` : null;
1699
+ }
1125
1700
  function contextText(context) {
1126
1701
  return context.trim() ? `Project context from the team (follow it like the project's own rules):
1127
1702
  ${context.trim()}` : null;
@@ -1160,6 +1735,18 @@ function peopleText(people) {
1160
1735
  function canProposeKnowledge(job) {
1161
1736
  return job.knowledge !== void 0 && (job.people ?? []).length > 0 && job.stage.kind !== "interview" && job.stage.kind !== "spec";
1162
1737
  }
1738
+ function folderText(root, results) {
1739
+ const lines = results ?? [];
1740
+ return lines.length > 0 ? `The task folder is ${root}. The results of the task so far, latest versions:
1741
+ ${bulletList(lines)}` : `The task folder is ${root}. The task has no result yet.`;
1742
+ }
1743
+ function handOverText(boards) {
1744
+ const names = (boards ?? []).map((board) => board.name);
1745
+ if (names.length === 0) {
1746
+ return null;
1747
+ }
1748
+ return `When part of the work belongs to another board, hand it over with the ${HAND_OVER_TOOL_NAME} tool: ${names.join(", ")}. Write a plain title and a note on what you found and what that board should do; never put names, emails or other personal data of clients in the note. With wait true this task waits on its step until the linked task is done, then it goes on and you get its result note.`;
1749
+ }
1163
1750
  function handoffText(targets, isCheck) {
1164
1751
  if (targets.length === 0) {
1165
1752
  return null;
@@ -1206,6 +1793,64 @@ function specGoal(job, work2) {
1206
1793
  }
1207
1794
  return work2.round > 1 ? `Follow-up questions for TAMsense task #${job.taskId}: ${job.title}` : `Prepare TAMsense task #${job.taskId} for its spec: ${job.title}`;
1208
1795
  }
1796
+ function briefQuestionRules(work2) {
1797
+ const handIn = `Call ${SUBMIT_QUESTIONS_TOOL_NAME} once with the research brief and the questions, or an empty list when none are needed. If it returns problems, fix them and call it again.`;
1798
+ if (work2.round === 1) {
1799
+ return [
1800
+ BRIEF_RESEARCH_RULE,
1801
+ RESEARCH_BRIEF_RULE,
1802
+ `Ask 0 to ${work2.maxQuestions} questions, ideally 2 or 3, the most important first, and only the ones research cannot settle, whose answers change the result, and where a wrong default is costly. Zero questions is a good result: the brief is then written right away.`,
1803
+ QUESTION_SHAPE_RULE,
1804
+ handIn,
1805
+ QUESTIONS_REPLY_RULE
1806
+ ];
1807
+ }
1808
+ const taken = work2.takenIds.length > 0 ? ` These ids are taken by round 1: ${work2.takenIds.join(", ")}.` : "";
1809
+ return [
1810
+ `This is the follow-up round ${work2.round} of 2, and there is no round after it. Read the answers in the conversation below; do not research again what your research brief covers.`,
1811
+ `Ask at most ${work2.maxQuestions} more questions, and only when an answer is vague, contradicts another answer or the facts, or unlocked a decision from pendingDecisions. Otherwise hand in no questions: whatever is still open becomes an assumption.${taken}`,
1812
+ QUESTION_SHAPE_RULE,
1813
+ "Update the research brief with what the answers changed and leave pendingDecisions empty.",
1814
+ handIn,
1815
+ QUESTIONS_REPLY_RULE
1816
+ ];
1817
+ }
1818
+ function briefGoal(job, work2) {
1819
+ if (work2.round === null) {
1820
+ return `Write the brief for TAMsense task #${job.taskId}: ${job.title}`;
1821
+ }
1822
+ return work2.round > 1 ? `Follow-up questions for TAMsense task #${job.taskId}: ${job.title}` : `Prepare TAMsense task #${job.taskId} for its brief: ${job.title}`;
1823
+ }
1824
+ function buildBriefPrompt(job, work2, workspace) {
1825
+ const prompt = [
1826
+ briefGoal(job, work2),
1827
+ job.description,
1828
+ work2.linkedAction,
1829
+ work2.brief ? `Research brief from the clarification phase (verify only what you rely on):
1830
+ ${JSON.stringify(work2.brief)}` : null,
1831
+ work2.round === null && job.spec ? `Current saved brief (hand in the full updated version):
1832
+ ${JSON.stringify(job.spec)}` : null,
1833
+ job.handoffNote && `Note from another step: ${job.handoffNote}`,
1834
+ threadText(work2.thread),
1835
+ teamMessagesText(job.messages)
1836
+ ].filter(Boolean).join("\n\n");
1837
+ const instructions = [
1838
+ BRIEF_UNATTENDED_RULE,
1839
+ MACHINE_RULE,
1840
+ workplaceText(job.computer, workspace.root),
1841
+ stepLine(job.stage),
1842
+ job.stage.instructions,
1843
+ contextText(job.projectContext),
1844
+ FOLDER_RULE,
1845
+ RESULT_HOME_RULE,
1846
+ `How to work:
1847
+ ${bulletList(work2.round === null ? RESULT_BRIEF_RULES : briefQuestionRules(work2))}`,
1848
+ safetyText(job),
1849
+ knowledgeText(job.knowledge, job.computer, false),
1850
+ BRIEF_STYLE_RULE
1851
+ ].filter(Boolean).join("\n\n");
1852
+ return { prompt, instructions };
1853
+ }
1209
1854
  function buildSpecPrompt(job, work2, workspace) {
1210
1855
  const prompt = [
1211
1856
  specGoal(job, work2),
@@ -1230,10 +1875,50 @@ ${JSON.stringify(job.spec)}` : null,
1230
1875
  ${bulletList(repositoryLines(job, workspace))}`,
1231
1876
  READ_ONLY_RULE,
1232
1877
  `How to work:
1233
- ${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
1878
+ ${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
1879
+ safetyText(job),
1880
+ knowledgeText(job.knowledge, job.computer, false),
1881
+ SPEC_STYLE_RULE
1882
+ ].filter(Boolean).join("\n\n");
1883
+ return { prompt, instructions };
1884
+ }
1885
+ var FOLDER_GOALS = {
1886
+ code: "Work on",
1887
+ review: "Check the result of",
1888
+ test: "Check the result of"
1889
+ };
1890
+ function buildFolderPrompt(job, workspace, options) {
1891
+ const kind = job.stage.kind === "review" || job.stage.kind === "test" ? job.stage.kind : "code";
1892
+ const isCheck = kind !== "code";
1893
+ const people = job.people ?? [];
1894
+ const canAsk = people.length > 0;
1895
+ const prompt = [
1896
+ `${FOLDER_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
1897
+ job.description,
1898
+ briefText(job.spec),
1899
+ job.handoffNote && `Note from the previous step: ${job.handoffNote}`,
1900
+ job.review && `Notes to address:
1901
+ ${job.review}`,
1902
+ teamMessagesText(job.messages)
1903
+ ].filter(Boolean).join("\n\n");
1904
+ const rules = isCheck ? FOLDER_CHECK_RULES : [...FOLDER_DO_RULES, ...job.review ? [FOLDER_REWORK_RULE] : [], FOLDER_REPORT_RULE];
1905
+ const instructions = [
1906
+ canAsk ? ASKING_RULE : UNATTENDED_RULE,
1907
+ MACHINE_RULE,
1908
+ workplaceText(job.computer, workspace.root),
1909
+ stepLine(job.stage),
1910
+ job.stage.instructions,
1911
+ contextText(job.projectContext),
1912
+ `${FOLDER_RULE} ${folderText(workspace.root, options.results)}`,
1913
+ `How to work:
1914
+ ${bulletList(rules)}`,
1234
1915
  safetyText(job),
1235
- knowledgeText(job.knowledge, job.computer, false),
1236
- SPEC_STYLE_RULE
1916
+ knowledgeText(job.knowledge, job.computer, canProposeKnowledge(job)),
1917
+ APPROVAL_RULE,
1918
+ peopleText(people),
1919
+ isCheck ? null : handOverText(job.handOverBoards),
1920
+ handoffText(job.allowedHandoffs, isCheck),
1921
+ options.browser && BROWSER_RULE
1237
1922
  ].filter(Boolean).join("\n\n");
1238
1923
  return { prompt, instructions };
1239
1924
  }
@@ -1243,13 +1928,17 @@ function buildTaskPrompt(job, workspace, options = {}) {
1243
1928
  if (!job.specWork) {
1244
1929
  throw new Error(SPEC_WORK_MISSING);
1245
1930
  }
1246
- return buildSpecPrompt(job, job.specWork, workspace);
1931
+ return isFolderJob(job) ? buildBriefPrompt(job, job.specWork, workspace) : buildSpecPrompt(job, job.specWork, workspace);
1932
+ }
1933
+ if (isFolderJob(job)) {
1934
+ return buildFolderPrompt(job, workspace, options);
1247
1935
  }
1248
1936
  const isCheck = kind !== "code";
1937
+ const readOnly = isReadOnlyCode(job.stage);
1249
1938
  const people = job.people ?? [];
1250
1939
  const canAsk = people.length > 0;
1251
1940
  const prompt = [
1252
- `${STAGE_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
1941
+ `${readOnly ? READ_ONLY_GOAL : STAGE_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
1253
1942
  job.description,
1254
1943
  specText(job.spec),
1255
1944
  job.handoffNote && `Note from the previous step: ${job.handoffNote}`,
@@ -1258,8 +1947,8 @@ ${job.review}`,
1258
1947
  teamMessagesText(job.messages)
1259
1948
  ].filter(Boolean).join("\n\n");
1260
1949
  const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
1261
- const localApp = localAppRule(job.taskId, isCheck, canAsk);
1262
- const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : [...CODE_RULES, ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
1950
+ 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];
1263
1952
  const instructions = [
1264
1953
  canAsk ? ASKING_RULE : UNATTENDED_RULE,
1265
1954
  MACHINE_RULE,
@@ -1267,7 +1956,7 @@ ${job.review}`,
1267
1956
  stepLine(job.stage),
1268
1957
  job.stage.instructions,
1269
1958
  contextText(job.projectContext),
1270
- `The repositories for this task are checked out in the current folder, one subfolder each, on branch ${job.branch}:
1959
+ `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}:
1271
1960
  ${bulletList(repositoryLines(job, workspace))}`,
1272
1961
  `How to work:
1273
1962
  ${bulletList(rules)}`,
@@ -1282,108 +1971,135 @@ ${bulletList(rules)}`,
1282
1971
  }
1283
1972
 
1284
1973
  // src/specTools.ts
1285
- import { tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
1286
- import { z as z2 } from "zod";
1974
+ import { tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
1975
+ import { z as z3 } from "zod";
1287
1976
  var MAX_FIRST_ROUND = 4;
1288
- var questionSchema = z2.object({
1289
- id: z2.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
1290
- question: z2.string().describe("One plain sentence the owner can answer without reading code"),
1291
- whyItMatters: z2.string().describe("One line: what changes in the result depending on the answer"),
1292
- options: z2.array(z2.object({
1293
- label: z2.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
1294
- description: z2.string().describe("One sentence: what happens if this option is chosen"),
1295
- recommended: z2.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
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")
1296
1985
  })).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'),
1297
- multiSelect: z2.boolean().describe("true only when the options are additive and several can apply together")
1986
+ multiSelect: z3.boolean().describe("true only when the options are additive and several can apply together")
1298
1987
  });
1299
- var briefSchema = z2.object({
1300
- findings: z2.array(z2.object({
1301
- fact: z2.string().describe("What the code does today that matters for this task"),
1302
- where: z2.string().describe("Where it is: owner/repo path:line")
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")
1303
1992
  })).describe("Findings from the code"),
1304
- dataFacts: z2.array(z2.string()).describe("Numbers and facts from the events or the business data, each with its source. Empty when the task needs no data"),
1305
- approach: z2.string().describe("The approach you would take, in 2-4 sentences"),
1306
- codeMap: z2.array(z2.object({
1307
- repo: z2.string().describe("Repository full name (owner/name)"),
1308
- file: z2.string().describe("Path relative to the repository root"),
1309
- role: z2.string().describe("Why it matters: what to change or reuse there")
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")
1310
1999
  })).describe("The files the change will touch or must reuse"),
1311
- assumptions: z2.array(z2.object({
1312
- text: z2.string().describe("The default you chose"),
1313
- reason: z2.string().describe("Why it is safe or why it was chosen")
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")
1314
2003
  })).describe("Corners settled with a default instead of a question"),
1315
- pendingDecisions: z2.array(z2.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
2004
+ pendingDecisions: z3.array(z3.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
1316
2005
  });
1317
- var questionsPayloadSchema = z2.object({
2006
+ var questionsPayloadSchema = z3.object({
1318
2007
  brief: briefSchema,
1319
- questions: z2.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`)
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`)
1320
2009
  });
1321
- var specSchema = z2.object({
1322
- goal: z2.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
1323
- context: z2.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
1324
- inScope: z2.array(z2.string()).describe("What this task delivers, one item each"),
1325
- outOfScope: z2.array(z2.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"),
1326
- decisions: z2.array(z2.object({
1327
- question: z2.string(),
1328
- answer: z2.string(),
1329
- by: z2.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
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")
1330
2019
  })).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
1331
- codeMap: z2.array(z2.object({
1332
- repo: z2.string().describe("Repository full name (owner/name)"),
1333
- file: z2.string().describe("Path relative to the repository root"),
1334
- role: z2.string().describe("What the coder needs to know about this file"),
1335
- use: z2.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
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")
1336
2025
  })).describe("The files a coder must read before starting"),
1337
- changes: z2.array(z2.object({
1338
- repo: z2.string().describe("Repository full name (owner/name) where the change happens"),
1339
- file: z2.string().describe("Path to the file, relative to the repository root"),
1340
- action: z2.enum(["create", "modify", "delete"]),
1341
- description: z2.string().describe("The behaviour the file has after the change and which existing functions or patterns to extend. No code snippets or diffs"),
1342
- produces: z2.array(z2.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
1343
- consumes: z2.array(z2.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
1344
- acceptance: z2.array(z2.string()).describe("Ids of the criteria this change makes true, like AC-1")
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")
1345
2034
  })).min(1).describe("File changes in dependency order: each uses only what earlier changes or the code already provide"),
1346
- acceptance: z2.array(z2.object({
1347
- id: z2.string().describe("AC-1, AC-2 and so on, in order"),
1348
- kind: z2.enum(["main", "edge", "keep"]).describe("main: the main path; edge: an error, empty or limit case; keep: existing behaviour that must keep working"),
1349
- text: z2.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
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')
1350
2039
  })).min(1).describe("Acceptance criteria: main path first, then edge cases, then what must keep working"),
1351
- testPlan: z2.object({
1352
- automated: z2.array(z2.object({
1353
- repo: z2.string(),
1354
- command: z2.string().describe("The exact command to run"),
1355
- expect: z2.string().describe("What passing looks like")
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")
1356
2045
  })).describe("At least one command"),
1357
- newTests: z2.array(z2.object({
1358
- seam: z2.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
1359
- covers: z2.array(z2.string()).describe("Ids of the criteria it covers")
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")
1360
2049
  })),
1361
- manual: z2.array(z2.object({
1362
- page: z2.string().describe("The page or screen"),
1363
- steps: z2.string().describe("What a person does and sees")
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")
1364
2053
  }))
1365
2054
  }),
1366
- risks: z2.array(z2.object({
1367
- risk: z2.string(),
1368
- mitigation: z2.string()
2055
+ risks: z3.array(z3.object({
2056
+ risk: z3.string(),
2057
+ mitigation: z3.string()
1369
2058
  })).describe("What could go wrong: migrations, existing data, access, performance, compatibility"),
1370
- riskLevel: z2.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
1371
- assumptions: z2.array(z2.object({
1372
- text: z2.string().describe("The default you chose"),
1373
- reason: z2.string().describe("Why, so the owner can confirm or correct it quickly")
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")
1374
2063
  })).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
1375
- openQuestions: z2.array(questionSchema).describe("Questions only the owner can settle; the spec cannot be approved until each is answered or dismissed. Empty when none"),
1376
- split: z2.array(z2.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")
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")
1377
2066
  });
1378
- function reply(text2, isError = false) {
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")
2075
+ })).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")
2081
+ })).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")
2087
+ })).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")
2091
+ })).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")
2093
+ });
2094
+ function reply2(text2, isError = false) {
1379
2095
  return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
1380
2096
  }
1381
2097
  function unreachable(error, toolName) {
1382
2098
  const message = error instanceof Error ? error.message : String(error);
1383
- return reply(`Could not reach TAMsense to check this: ${message}. Call ${toolName} again in a moment.`, true);
2099
+ return reply2(`Could not reach TAMsense to check this: ${message}. Call ${toolName} again in a moment.`, true);
1384
2100
  }
1385
2101
  function questionsTool(review, onSubmit) {
1386
- return tool2(
2102
+ return tool3(
1387
2103
  SUBMIT_QUESTIONS_TOOL_NAME,
1388
2104
  "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.",
1389
2105
  questionsPayloadSchema.shape,
@@ -1395,10 +2111,10 @@ function questionsTool(review, onSubmit) {
1395
2111
  return unreachable(error, SUBMIT_QUESTIONS_TOOL_NAME);
1396
2112
  }
1397
2113
  if (!verdict.accepted) {
1398
- return reply(verdict.feedback, true);
2114
+ return reply2(verdict.feedback, true);
1399
2115
  }
1400
2116
  onSubmit(args);
1401
- return reply(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.");
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.");
1402
2118
  }
1403
2119
  );
1404
2120
  }
@@ -1406,7 +2122,7 @@ var BUILDER_CHECK_FIRST = "The spec passed the lint but was not saved yet: the b
1406
2122
  function specTool(review, onSubmit, builderCheckRan) {
1407
2123
  let repairs = 0;
1408
2124
  let reminded = false;
1409
- return tool2(
2125
+ return tool3(
1410
2126
  SUBMIT_SPEC_TOOL_NAME,
1411
2127
  "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.",
1412
2128
  specSchema.shape,
@@ -1419,14 +2135,36 @@ function specTool(review, onSubmit, builderCheckRan) {
1419
2135
  }
1420
2136
  if (!verdict.accepted) {
1421
2137
  repairs += 1;
1422
- return reply(verdict.feedback, true);
2138
+ return reply2(verdict.feedback, true);
1423
2139
  }
1424
2140
  if (!builderCheckRan() && !reminded) {
1425
2141
  reminded = true;
1426
- return reply(BUILDER_CHECK_FIRST, true);
2142
+ return reply2(BUILDER_CHECK_FIRST, true);
2143
+ }
2144
+ 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.");
2146
+ }
2147
+ );
2148
+ }
2149
+ function briefTool(review, onSubmit) {
2150
+ let repairs = 0;
2151
+ return tool3(
2152
+ SUBMIT_SPEC_TOOL_NAME,
2153
+ "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
+ resultBriefSchema.shape,
2155
+ async (args) => {
2156
+ let verdict;
2157
+ try {
2158
+ verdict = await review({ spec: args, repairs });
2159
+ } catch (error) {
2160
+ return unreachable(error, SUBMIT_SPEC_TOOL_NAME);
2161
+ }
2162
+ if (!verdict.accepted) {
2163
+ repairs += 1;
2164
+ return reply2(verdict.feedback, true);
1427
2165
  }
1428
2166
  onSubmit({ spec: args, problems: verdict.problems });
1429
- return reply(verdict.problems.length > 0 ? `Spec saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Spec saved.");
2167
+ return reply2(verdict.problems.length > 0 ? `Brief saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Brief saved.");
1430
2168
  }
1431
2169
  );
1432
2170
  }
@@ -1528,10 +2266,9 @@ function usageLimitWatch() {
1528
2266
  // src/claude.ts
1529
2267
  var NO_RESULT_TEXT = "Claude finished without a result.";
1530
2268
  var SERVER_NAME = "tamsense";
1531
- var LIBRARY_PATH = fileURLToPath(new URL("../library", import.meta.url));
1532
2269
  var CAPTION_FIELDS = {
1533
- title: z3.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"'),
1534
- purpose: z3.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
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"')
1535
2272
  };
1536
2273
  var ATTACH_TOOLS = {
1537
2274
  screenshot: {
@@ -1552,21 +2289,21 @@ var ATTACH_TOOLS = {
1552
2289
  };
1553
2290
  function attachTool(kind, attach) {
1554
2291
  const spec = ATTACH_TOOLS[kind];
1555
- return tool3(
2292
+ return tool4(
1556
2293
  spec.name,
1557
2294
  spec.description,
1558
- { path: z3.string().describe(spec.path), ...CAPTION_FIELDS },
2295
+ { path: z4.string().describe(spec.path), ...CAPTION_FIELDS },
1559
2296
  async (args) => ({ content: [{ type: "text", text: await attach({ kind, ...args }) }] })
1560
2297
  );
1561
2298
  }
1562
2299
  function verdictTool(onVerdict) {
1563
- return tool3(
2300
+ return tool4(
1564
2301
  VERDICT_TOOL_NAME,
1565
2302
  "Hand in the result of this check. Call it exactly once, at the very end.",
1566
2303
  {
1567
- passed: z3.boolean().describe("true only if nothing must be fixed"),
1568
- report: z3.string().describe("what you checked and, if it failed, exactly what to fix with file and line"),
1569
- couldNotRun: z3.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")
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")
1570
2307
  },
1571
2308
  async (args) => {
1572
2309
  onVerdict(args.couldNotRun ? { passed: false, report: args.report, couldNotRun: true } : { passed: args.passed, report: args.report });
@@ -1575,13 +2312,13 @@ function verdictTool(onVerdict) {
1575
2312
  );
1576
2313
  }
1577
2314
  function handoffTool(targets, onHandoff) {
1578
- return tool3(
2315
+ return tool4(
1579
2316
  HANDOFF_TOOL_NAME,
1580
2317
  "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.",
1581
2318
  {
1582
- to: z3.string().describe("name or id of the step to hand the task to"),
1583
- outcome: z3.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
1584
- note: z3.string().describe("for the next agent and the owner: what was done, what to check, what is open")
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")
1585
2322
  },
1586
2323
  async (args) => {
1587
2324
  const choice = chooseHandoff(targets, args);
@@ -1594,25 +2331,25 @@ function handoffTool(targets, onHandoff) {
1594
2331
  );
1595
2332
  }
1596
2333
  function askPersonTool(asking, waiting) {
1597
- return tool3(
2334
+ return tool4(
1598
2335
  ASK_PERSON_TOOL_NAME,
1599
2336
  "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.",
1600
2337
  {
1601
- to: z3.union([z3.number(), z3.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"),
1602
- why: z3.string().describe('one line on why this person: "assignee, and the run is on her computer", "Designer, owns empty states"'),
1603
- question: z3.string().describe("one plain question the person can answer without reading the code"),
1604
- context: z3.string().optional().describe("one line on what you saw that led to the question"),
1605
- tried: z3.array(z3.object({
1606
- did: z3.string().describe("the command or step"),
1607
- error: z3.string().optional().describe("the exact error line, if there was one"),
1608
- result: z3.string().describe("what it showed, one line")
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")
1609
2346
  })).max(MAX_ASK_TRIES).optional().describe("what you already tried, the most telling first"),
1610
- options: z3.array(z3.object({
1611
- label: z3.string().describe("a short answer the person can pick"),
1612
- detail: z3.string().describe("one line on what happens if it is picked")
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")
1613
2350
  })).max(MAX_ASK_OPTIONS).optional().describe("2 to 4 answers you suggest, the one you would pick first"),
1614
- blocking: z3.boolean().describe("true when you cannot go on without the answer"),
1615
- assumption: z3.string().optional().describe("when blocking is false: what you continue with until the answer comes")
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")
1616
2353
  },
1617
2354
  async (args) => {
1618
2355
  if (waiting.waitingFor !== null) {
@@ -1622,16 +2359,16 @@ function askPersonTool(asking, waiting) {
1622
2359
  if ("error" in choice) {
1623
2360
  return { content: [{ type: "text", text: choice.error }], isError: true };
1624
2361
  }
1625
- let reply2;
2362
+ let reply3;
1626
2363
  try {
1627
- reply2 = await asking.send(choice.body);
2364
+ reply3 = await asking.send(choice.body);
1628
2365
  } catch (error) {
1629
2366
  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 };
1630
2367
  }
1631
2368
  if (choice.body.blocking) {
1632
- waiting.waitingFor = reply2.id;
2369
+ waiting.waitingFor = reply3.id;
1633
2370
  }
1634
- return { content: [{ type: "text", text: askedText(reply2.name, choice.body) }] };
2371
+ return { content: [{ type: "text", text: askedText(reply3.name, choice.body) }] };
1635
2372
  }
1636
2373
  );
1637
2374
  }
@@ -1639,8 +2376,13 @@ function specWorkTools(specWork, submitted) {
1639
2376
  if (!specWork) {
1640
2377
  return [];
1641
2378
  }
1642
- return specWork.stage === "interview" ? [questionsTool(specWork.review, (questions) => {
1643
- submitted.questions = questions;
2379
+ if (specWork.stage === "interview") {
2380
+ return [questionsTool(specWork.review, (questions) => {
2381
+ submitted.questions = questions;
2382
+ })];
2383
+ }
2384
+ return specWork.brief ? [briefTool(specWork.review, (brief) => {
2385
+ submitted.spec = brief;
1644
2386
  })] : [specTool(specWork.review, (spec) => {
1645
2387
  submitted.spec = spec;
1646
2388
  }, () => submitted.helperStarted)];
@@ -1668,7 +2410,9 @@ function tamsenseServer(request, submitted) {
1668
2410
  ...knowledgeTools(request.knowledge),
1669
2411
  ...request.handoffs.length > 0 ? [handoffTool(request.handoffs, (handoff) => {
1670
2412
  submitted.handoff = handoff;
1671
- })] : []
2413
+ })] : [],
2414
+ ...request.results ? resultToolset(request.results) : [],
2415
+ ...request.handOver ? [handOverTool(request.handOver)] : []
1672
2416
  ];
1673
2417
  return {
1674
2418
  server: createSdkMcpServer({ name: SERVER_NAME, version: "1.0.0", tools }),
@@ -1695,6 +2439,7 @@ async function attempt(request, context, resume) {
1695
2439
  let outcome = { completed: false, report: NO_RESULT_TEXT, model: null, usage: null, verdict: null };
1696
2440
  const tamsense = tamsenseServer(request, context.submitted);
1697
2441
  const shown = hiddenStepsFilter();
2442
+ const helpers = helperTasks();
1698
2443
  const messages = context.startQuery({
1699
2444
  prompt: request.input.start(request.prompt.prompt),
1700
2445
  options: {
@@ -1706,7 +2451,7 @@ async function attempt(request, context, resume) {
1706
2451
  thinking: { type: "adaptive", display: "summarized" },
1707
2452
  // режим auto сам решает, что можно; о чём он спросил бы человека, решают правила проекта или люди задачи
1708
2453
  canUseTool: request.permissions ? askPermission(request.permissions, () => context.lastWords.text, request.cwd) : denyWhenAsked,
1709
- plugins: [{ type: "local", path: LIBRARY_PATH }],
2454
+ plugins: [{ type: "local", path: request.library }],
1710
2455
  mcpServers: { [SERVER_NAME]: tamsense.server },
1711
2456
  allowedTools: tamsense.allowed,
1712
2457
  abortController: context.abortController,
@@ -1716,6 +2461,7 @@ async function attempt(request, context, resume) {
1716
2461
  }
1717
2462
  });
1718
2463
  request.input.onSwitch((change) => applySwitch(messages, change));
2464
+ request.input.onStopHelper((ref) => helpers.stop(messages, ref));
1719
2465
  const failedToResume = () => resume !== null && sessionId === null && !request.signal.aborted;
1720
2466
  try {
1721
2467
  for await (const message of messages) {
@@ -1727,7 +2473,7 @@ async function attempt(request, context, resume) {
1727
2473
  messages.close();
1728
2474
  return { outcome, sessionId, resumeFailed: true };
1729
2475
  }
1730
- for (const draft of feedOf(message).filter(shown)) {
2476
+ for (const draft of [...helpers.observe(message), ...feedOf(message).filter(shown).flatMap(helpers.adjust)]) {
1731
2477
  if (draft.kind === "agent" && draft.parent === null) {
1732
2478
  context.submitted.helperStarted = true;
1733
2479
  }
@@ -1778,13 +2524,13 @@ function claudeRunner(startQuery) {
1778
2524
  var runClaude = claudeRunner(query2);
1779
2525
 
1780
2526
  // src/config.ts
1781
- import { mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
2527
+ import { mkdir as mkdir3, readFile as readFile3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
1782
2528
  import { homedir as homedir2 } from "os";
1783
- import { dirname as dirname2, join as join2 } from "path";
2529
+ import { dirname as dirname2, join as join4 } from "path";
1784
2530
  var OWNER_ONLY_FILE = 384;
1785
2531
  var OWNER_ONLY_FOLDER = 448;
1786
2532
  function configPath(home = homedir2()) {
1787
- return join2(home, ".tamsense", "runner.json");
2533
+ return join4(home, ".tamsense", "runner.json");
1788
2534
  }
1789
2535
  function isRecord2(value) {
1790
2536
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -1802,7 +2548,7 @@ function isMissingFile(error) {
1802
2548
  }
1803
2549
  async function readConfig(path = configPath()) {
1804
2550
  try {
1805
- return parseConfig(JSON.parse(await readFile2(path, "utf8")));
2551
+ return parseConfig(JSON.parse(await readFile3(path, "utf8")));
1806
2552
  } catch (error) {
1807
2553
  if (isMissingFile(error) || error instanceof SyntaxError) {
1808
2554
  return null;
@@ -1811,34 +2557,12 @@ async function readConfig(path = configPath()) {
1811
2557
  }
1812
2558
  }
1813
2559
  async function writeConfig(config, path = configPath()) {
1814
- await mkdir2(dirname2(path), { recursive: true, mode: OWNER_ONLY_FOLDER });
1815
- await writeFile2(path, `${JSON.stringify(config, null, 4)}
2560
+ await mkdir3(dirname2(path), { recursive: true, mode: OWNER_ONLY_FOLDER });
2561
+ await writeFile3(path, `${JSON.stringify(config, null, 4)}
1816
2562
  `, { mode: OWNER_ONLY_FILE });
1817
2563
  }
1818
2564
  async function removeConfig(path = configPath()) {
1819
- await rm2(path, { force: true });
1820
- }
1821
-
1822
- // src/protocol.ts
1823
- var MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
1824
- var MAX_ATTACHMENTS_PER_RUN = 20;
1825
- var MAX_VIDEO_BYTES = 200 * 1024 * 1024;
1826
- var PAIRINGS_PATH = "/api/runner/pairings";
1827
- var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
1828
- var IDENTITY_PATH = "/api/runner/me";
1829
- var NEXT_RUN_PATH = "/api/runner/runs/next";
1830
- var CAPABILITIES_PATH = "/api/runner/me/capabilities";
1831
- function runPath(runId, action) {
1832
- return `/api/runner/runs/${runId}/${action}`;
1833
- }
1834
- function knowledgePath(runId, entryId) {
1835
- return `/api/runner/runs/${runId}/knowledge/${entryId}`;
1836
- }
1837
- function permissionPath(runId, permissionId) {
1838
- return `/api/runner/runs/${runId}/permissions/${permissionId}`;
1839
- }
1840
- function recordingUploadPath(runId, upload, part) {
1841
- return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
2565
+ await rm4(path, { force: true });
1842
2566
  }
1843
2567
 
1844
2568
  // src/login.ts
@@ -1855,14 +2579,14 @@ async function login(tools, request) {
1855
2579
  tools.openBrowser(pageUrl);
1856
2580
  while (tools.now() < expiresAt) {
1857
2581
  await tools.sleep(pairing.intervalSeconds * MS_PER_SECOND);
1858
- const reply2 = await tools.api.request("POST", PAIRING_TOKEN_PATH, { deviceCode: pairing.deviceCode });
1859
- if (reply2.status === "connected") {
1860
- return { token: reply2.token, device: reply2.device };
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 };
1861
2585
  }
1862
- if (reply2.status === "denied") {
2586
+ if (reply3.status === "denied") {
1863
2587
  throw new Error(DENIED_TEXT);
1864
2588
  }
1865
- if (reply2.status === "expired") {
2589
+ if (reply3.status === "expired") {
1866
2590
  break;
1867
2591
  }
1868
2592
  }
@@ -1870,14 +2594,14 @@ async function login(tools, request) {
1870
2594
  }
1871
2595
 
1872
2596
  // src/recorderEnv.ts
1873
- import { existsSync as existsSync2 } from "fs";
1874
- import { join as join4 } from "path";
1875
- import { fileURLToPath as fileURLToPath2 } from "url";
2597
+ import { existsSync as existsSync3 } from "fs";
2598
+ import { join as join6 } from "path";
2599
+ import { fileURLToPath } from "url";
1876
2600
 
1877
2601
  // src/recorder.ts
1878
2602
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1879
2603
  import Module, { createRequire } from "module";
1880
- import { basename, dirname as dirname3, join as join3 } from "path";
2604
+ import { basename as basename2, dirname as dirname3, join as join5 } from "path";
1881
2605
  import process2 from "process";
1882
2606
  var RECORDINGS_DIR_ENV = "TAMSENSE_RECORDINGS_DIR";
1883
2607
  var SERVER_ENV = "TAMSENSE_SERVER";
@@ -1950,7 +2674,7 @@ function clip2(text2, max) {
1950
2674
  return text2.length > max ? `${text2.slice(0, max - 1).trimEnd()}\u2026` : text2;
1951
2675
  }
1952
2676
  function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
1953
- const from = script2 ? `, opened by ${basename(script2)}` : "";
2677
+ const from = script2 ? `, opened by ${basename2(script2)}` : "";
1954
2678
  return {
1955
2679
  title: clip2(env[DEFAULT_TITLE_ENV]?.trim() || "Browser session", MAX_TITLE_LENGTH),
1956
2680
  purpose: clip2(`${env[DEFAULT_PURPOSE_ENV]?.trim() || "Recorded automatically while the agent used a browser"}${from}`, MAX_PURPOSE_LENGTH)
@@ -1958,7 +2682,7 @@ function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
1958
2682
  }
1959
2683
  function rrwebSource() {
1960
2684
  const entry = createRequire(import.meta.url).resolve("rrweb");
1961
- return readFileSync(join3(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
2685
+ return readFileSync(join5(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
1962
2686
  }
1963
2687
  function pageScript(rrweb) {
1964
2688
  return `(() => {
@@ -2009,8 +2733,8 @@ var Session = class {
2009
2733
  bytes = 0;
2010
2734
  constructor(dir, recording, url) {
2011
2735
  const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
2012
- this.file = join3(dir, `${id}${REPLAY_SUFFIX}`);
2013
- this.metaFile = join3(dir, `${id}${META_SUFFIX}`);
2736
+ this.file = join5(dir, `${id}${REPLAY_SUFFIX}`);
2737
+ this.metaFile = join5(dir, `${id}${META_SUFFIX}`);
2014
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 };
2015
2739
  writeFileSync(this.file, "");
2016
2740
  this.save();
@@ -2182,11 +2906,11 @@ if (process2.env[RECORDINGS_DIR_ENV]) {
2182
2906
  // src/recorderEnv.ts
2183
2907
  var RECORDER_ENV = "TAMSENSE_RECORDER";
2184
2908
  function bundledRecorder() {
2185
- const path = fileURLToPath2(new URL("./recorder.cjs", import.meta.url));
2186
- return existsSync2(path) ? path : null;
2909
+ const path = fileURLToPath(new URL("./recorder.cjs", import.meta.url));
2910
+ return existsSync3(path) ? path : null;
2187
2911
  }
2188
2912
  function recordingsFolder(home, runId) {
2189
- return join4(home, ".tamsense", "recordings", `run-${runId}`);
2913
+ return join6(home, ".tamsense", "recordings", `run-${runId}`);
2190
2914
  }
2191
2915
  function nodeOptionsWithRecorder(recorder, nodeOptions) {
2192
2916
  return [nodeOptions?.trim(), `--require "${recorder.replaceAll("\\", "/")}"`].filter(Boolean).join(" ");
@@ -2213,7 +2937,7 @@ function describeDevice(identity) {
2213
2937
  }
2214
2938
 
2215
2939
  // src/system.ts
2216
- import { spawn as spawn2 } from "child_process";
2940
+ import { spawn as spawn3 } from "child_process";
2217
2941
  import { hostname } from "os";
2218
2942
  import { delimiter as delimiter2 } from "path";
2219
2943
  import { setTimeout as wait } from "timers/promises";
@@ -2222,12 +2946,12 @@ var PLATFORM_LABELS = {
2222
2946
  darwin: "macOS",
2223
2947
  linux: "Linux"
2224
2948
  };
2225
- var MAX_NAME_LENGTH = 80;
2949
+ var MAX_NAME_LENGTH2 = 80;
2226
2950
  function platformLabel(platform = process.platform) {
2227
2951
  return PLATFORM_LABELS[platform] ?? platform;
2228
2952
  }
2229
2953
  function defaultDeviceName() {
2230
- return hostname().slice(0, MAX_NAME_LENGTH) || "My computer";
2954
+ return hostname().slice(0, MAX_NAME_LENGTH2) || "My computer";
2231
2955
  }
2232
2956
  function browserCommand(url, platform) {
2233
2957
  if (platform === "win32") {
@@ -2237,7 +2961,7 @@ function browserCommand(url, platform) {
2237
2961
  }
2238
2962
  function openBrowser(url, platform = process.platform) {
2239
2963
  const [command, args] = browserCommand(url, platform);
2240
- const child = spawn2(command, args, { detached: true, stdio: "ignore" });
2964
+ const child = spawn3(command, args, { detached: true, stdio: "ignore" });
2241
2965
  child.on("error", () => {
2242
2966
  });
2243
2967
  child.unref();
@@ -2257,7 +2981,7 @@ async function sleep(ms, signal) {
2257
2981
  }
2258
2982
 
2259
2983
  // src/update.ts
2260
- import { spawn as spawn3 } from "child_process";
2984
+ import { spawn as spawn4 } from "child_process";
2261
2985
  import { readFileSync as readFileSync2 } from "fs";
2262
2986
  import process3 from "process";
2263
2987
  var PACKAGE_NAME = "@tamsensedev/runner";
@@ -2312,8 +3036,8 @@ function relaunchArgs(argv) {
2312
3036
  }
2313
3037
  function relaunch(argv, env) {
2314
3038
  const isWindows = process3.platform === "win32";
2315
- return new Promise((resolve, reject) => {
2316
- const child = spawn3(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
3039
+ return new Promise((resolve3, reject) => {
3040
+ const child = spawn4(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
2317
3041
  const ignoreInterrupt = () => {
2318
3042
  };
2319
3043
  process3.on("SIGINT", ignoreInterrupt);
@@ -2323,13 +3047,13 @@ function relaunch(argv, env) {
2323
3047
  });
2324
3048
  child.on("close", (code) => {
2325
3049
  process3.off("SIGINT", ignoreInterrupt);
2326
- resolve(code ?? 1);
3050
+ resolve3(code ?? 1);
2327
3051
  });
2328
3052
  });
2329
3053
  }
2330
3054
 
2331
3055
  // src/worker.ts
2332
- import { rm as rm4 } from "fs/promises";
3056
+ import { rm as rm6 } from "fs/promises";
2333
3057
  import { dirname as dirname5 } from "path";
2334
3058
  import process4 from "process";
2335
3059
 
@@ -2369,37 +3093,43 @@ function feedQueue(send) {
2369
3093
  };
2370
3094
  }
2371
3095
 
2372
- // src/lock.ts
2373
- function keyedLock() {
2374
- const tails = /* @__PURE__ */ new Map();
2375
- return {
2376
- async run(key, work2) {
2377
- const previous = tails.get(key) ?? Promise.resolve();
2378
- let release = () => {
2379
- };
2380
- const done2 = new Promise((resolve) => {
2381
- release = resolve;
2382
- });
2383
- const tail = previous.then(() => done2);
2384
- tails.set(key, tail);
2385
- await previous;
2386
- try {
2387
- return await work2();
2388
- } finally {
2389
- release();
2390
- if (tails.get(key) === tail) {
2391
- tails.delete(key);
2392
- }
2393
- }
2394
- }
2395
- };
3096
+ // 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";
3100
+ import { fileURLToPath as fileURLToPath2 } from "url";
3101
+ var LIBRARY_PATH = fileURLToPath2(new URL("../library", import.meta.url));
3102
+ var SKILL_NAME = /^[a-z0-9-]+$/;
3103
+ var STAGE_SKILLS = {
3104
+ interview: ["task-interview"],
3105
+ spec: ["spec-writing", "task-interview"]
3106
+ };
3107
+ function allowedSkills(stage) {
3108
+ const skills = stage.role?.skills ?? null;
3109
+ return skills && [.../* @__PURE__ */ new Set([...skills, ...STAGE_SKILLS[stage.kind] ?? []])].filter((name) => SKILL_NAME.test(name));
3110
+ }
3111
+ function roleLibraryFolder(home, runId) {
3112
+ return join7(home, ".tamsense", "libraries", `run-${runId}`);
3113
+ }
3114
+ async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
3115
+ if (skills === null) {
3116
+ return source;
3117
+ }
3118
+ 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 });
3124
+ }
3125
+ return folder;
2396
3126
  }
2397
3127
 
2398
3128
  // src/recordings.ts
2399
3129
  import { randomUUID } from "crypto";
2400
3130
  import { createReadStream } from "fs";
2401
- import { readdir, readFile as readFile3, stat, writeFile as writeFile3 } from "fs/promises";
2402
- import { basename as basename2, dirname as dirname4, extname, isAbsolute, join as join5 } from "path";
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";
2403
3133
  import { createInterface as createInterface2 } from "readline";
2404
3134
  import { Readable } from "stream";
2405
3135
  import { promisify } from "util";
@@ -2460,23 +3190,23 @@ function marksFrom(marks, startTs) {
2460
3190
  return marks.map((mark) => ({ at: Math.max(0, mark.ts - startTs), label: mark.label })).sort((a, b) => a.at - b.at);
2461
3191
  }
2462
3192
  function sessionIdOf(file) {
2463
- return basename2(file).replace(REPLAY_SUFFIX, "");
3193
+ return basename3(file).replace(REPLAY_SUFFIX, "");
2464
3194
  }
2465
3195
  async function readMeta(file) {
2466
3196
  try {
2467
- return JSON.parse(await readFile3(file, "utf8"));
3197
+ return JSON.parse(await readFile4(file, "utf8"));
2468
3198
  } catch {
2469
3199
  return null;
2470
3200
  }
2471
3201
  }
2472
3202
  async function metaFor(kind, file) {
2473
3203
  if (kind === "replay") {
2474
- return readMeta(join5(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
3204
+ return readMeta(join8(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
2475
3205
  }
2476
- const folder = basename2(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
3206
+ const folder = basename3(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
2477
3207
  for (const name of await readdir(folder).catch(() => [])) {
2478
3208
  if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
2479
- const meta = await readMeta(join5(folder, name));
3209
+ const meta = await readMeta(join8(folder, name));
2480
3210
  if (meta?.video === file) {
2481
3211
  return meta;
2482
3212
  }
@@ -2486,7 +3216,7 @@ async function metaFor(kind, file) {
2486
3216
  }
2487
3217
  async function ledger(dir) {
2488
3218
  try {
2489
- return new Set(JSON.parse(await readFile3(join5(dir, LEDGER_FILE), "utf8")));
3219
+ return new Set(JSON.parse(await readFile4(join8(dir, LEDGER_FILE), "utf8")));
2490
3220
  } catch {
2491
3221
  return /* @__PURE__ */ new Set();
2492
3222
  }
@@ -2494,10 +3224,10 @@ async function ledger(dir) {
2494
3224
  async function remember(dir, file) {
2495
3225
  const done2 = await ledger(dir);
2496
3226
  done2.add(file);
2497
- await writeFile3(join5(dir, LEDGER_FILE), JSON.stringify([...done2]));
3227
+ await writeFile4(join8(dir, LEDGER_FILE), JSON.stringify([...done2]));
2498
3228
  }
2499
3229
  async function nonEmpty(file) {
2500
- return file !== null && ((await stat(file).catch(() => null))?.size ?? 0) > 0;
3230
+ return file !== null && ((await stat2(file).catch(() => null))?.size ?? 0) > 0;
2501
3231
  }
2502
3232
  async function pendingRecordings(dir) {
2503
3233
  const done2 = await ledger(dir);
@@ -2507,8 +3237,8 @@ async function pendingRecordings(dir) {
2507
3237
  if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
2508
3238
  continue;
2509
3239
  }
2510
- const meta = await readMeta(join5(dir, name));
2511
- const replay = join5(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
3240
+ const meta = await readMeta(join8(dir, name));
3241
+ const replay = join8(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
2512
3242
  if (!done2.has(replay) && await nonEmpty(replay)) {
2513
3243
  pending.push({ kind: "replay", file: replay, meta });
2514
3244
  }
@@ -2528,11 +3258,11 @@ async function uploadReplay(upload, file, caption) {
2528
3258
  if (!scan.snapshot) {
2529
3259
  throw new Error("This replay has no page snapshot, so it cannot be played. Was the page ever opened?");
2530
3260
  }
2531
- return finish(upload, { kind: "replay", upload: id, name: basename2(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
3261
+ return finish(upload, { kind: "replay", upload: id, name: basename3(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
2532
3262
  }
2533
3263
  async function uploadVideo(upload, file, caption, meta) {
2534
- const contentType = VIDEO_TYPES_BY_EXTENSION[extname(file).toLowerCase()];
2535
- const info = await stat(file);
3264
+ const contentType = VIDEO_TYPES_BY_EXTENSION[extname2(file).toLowerCase()];
3265
+ const info = await stat2(file);
2536
3266
  if (info.size > MAX_VIDEO_BYTES) {
2537
3267
  throw new Error(`The video is larger than ${MAX_VIDEO_BYTES / 1024 / 1024} MB. Record a shorter one.`);
2538
3268
  }
@@ -2543,16 +3273,16 @@ async function uploadVideo(upload, file, caption, meta) {
2543
3273
  const endedAt = meta?.endedAt ?? info.mtimeMs;
2544
3274
  const durationMs = Math.max(0, Math.round(endedAt - startedAt));
2545
3275
  const marks = marksFrom(meta?.marks ?? [], startedAt).filter((mark) => mark.at <= durationMs);
2546
- return finish(upload, { kind: "video", upload: id, name: basename2(file), ...caption, contentType, durationMs, marks });
3276
+ return finish(upload, { kind: "video", upload: id, name: basename3(file), ...caption, contentType, durationMs, marks });
2547
3277
  }
2548
3278
  async function finish(upload, body) {
2549
3279
  return (await upload.api.request("POST", runPath(upload.runId, "recordings"), body)).id;
2550
3280
  }
2551
3281
  async function attachRecording(api, runId, recordingsDir2, request) {
2552
- if (!isAbsolute(request.path)) {
3282
+ if (!isAbsolute2(request.path)) {
2553
3283
  return `Give the absolute path of the ${request.kind === "replay" ? "replay (.rrweb.ndjson)" : "video (.webm or .mp4)"} file.`;
2554
3284
  }
2555
- const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) : extname(request.path).toLowerCase() in VIDEO_TYPES_BY_EXTENSION;
3285
+ const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) : extname2(request.path).toLowerCase() in VIDEO_TYPES_BY_EXTENSION;
2556
3286
  if (!fitsKind) {
2557
3287
  return request.kind === "replay" ? `A replay is the ${REPLAY_SUFFIX} file the TAMsense recorder wrote.` : "Only WebM and MP4 videos can be attached.";
2558
3288
  }
@@ -2617,19 +3347,19 @@ async function uploadPendingRecordings(api, runId, dir) {
2617
3347
  }
2618
3348
 
2619
3349
  // src/screenshot.ts
2620
- import { readFile as readFile4, stat as stat2 } from "fs/promises";
2621
- import { basename as basename3, extname as extname2, isAbsolute as isAbsolute2 } from "path";
2622
- var TYPES_BY_EXTENSION = {
3350
+ import { readFile as readFile5, stat as stat3 } from "fs/promises";
3351
+ import { basename as basename4, extname as extname3, isAbsolute as isAbsolute3 } from "path";
3352
+ var TYPES_BY_EXTENSION2 = {
2623
3353
  ".png": "image/png",
2624
3354
  ".jpg": "image/jpeg",
2625
3355
  ".jpeg": "image/jpeg",
2626
3356
  ".webp": "image/webp"
2627
3357
  };
2628
3358
  async function readScreenshot(request) {
2629
- if (!isAbsolute2(request.path)) {
3359
+ if (!isAbsolute3(request.path)) {
2630
3360
  return "Give the absolute path of the image file.";
2631
3361
  }
2632
- const contentType = TYPES_BY_EXTENSION[extname2(request.path).toLowerCase()];
3362
+ const contentType = TYPES_BY_EXTENSION2[extname3(request.path).toLowerCase()];
2633
3363
  if (!contentType) {
2634
3364
  return "Only PNG, JPEG and WebP images can be attached.";
2635
3365
  }
@@ -2639,158 +3369,15 @@ async function readScreenshot(request) {
2639
3369
  } catch (error) {
2640
3370
  return error instanceof Error ? error.message : String(error);
2641
3371
  }
2642
- const info = await stat2(request.path).catch(() => null);
3372
+ const info = await stat3(request.path).catch(() => null);
2643
3373
  if (!info?.isFile()) {
2644
3374
  return `There is no file at ${request.path}.`;
2645
3375
  }
2646
3376
  if (info.size > MAX_ATTACHMENT_BYTES) {
2647
3377
  return "The image is larger than 5 MB. Take a smaller screenshot (viewport instead of full page, or JPEG).";
2648
3378
  }
2649
- const data = (await readFile4(request.path)).toString("base64");
2650
- return { name: basename3(request.path), ...caption, contentType, data };
2651
- }
2652
-
2653
- // src/workspace.ts
2654
- import { existsSync as existsSync3 } from "fs";
2655
- import { rm as rm3 } from "fs/promises";
2656
- import { join as join6 } from "path";
2657
-
2658
- // src/git.ts
2659
- import { Buffer } from "buffer";
2660
- import { spawn as spawn4 } from "child_process";
2661
- var GITHUB_ORIGIN = "https://github.com/";
2662
- function githubAuthEnv(token) {
2663
- const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
2664
- return {
2665
- GIT_CONFIG_COUNT: "1",
2666
- GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
2667
- GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
2668
- GIT_TERMINAL_PROMPT: "0"
2669
- };
2670
- }
2671
- function runGit(args, options) {
2672
- const env = { ...process.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
2673
- return new Promise((resolve, reject) => {
2674
- const child = spawn4("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
2675
- let stdout = "";
2676
- let stderr = "";
2677
- child.stdout.on("data", (chunk) => {
2678
- stdout += chunk.toString();
2679
- });
2680
- child.stderr.on("data", (chunk) => {
2681
- stderr += chunk.toString();
2682
- });
2683
- child.on("error", reject);
2684
- child.on("close", (code) => resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
2685
- });
2686
- }
2687
- async function git(args, options) {
2688
- const result = await runGit(args, options);
2689
- if (result.code !== 0) {
2690
- throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
2691
- }
2692
- return result.stdout;
2693
- }
2694
-
2695
- // src/workspace.ts
2696
- var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
2697
- var cacheLock = keyedLock();
2698
- function githubRemoteUrl(fullName) {
2699
- return `https://github.com/${fullName}.git`;
2700
- }
2701
- function tamsenseFolder(home, ...parts) {
2702
- return join6(home, ".tamsense", ...parts);
2703
- }
2704
- function folderNames(repositories) {
2705
- const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
2706
- const counts = /* @__PURE__ */ new Map();
2707
- for (const repository of repositories) {
2708
- counts.set(shortName(repository.fullName), (counts.get(shortName(repository.fullName)) ?? 0) + 1);
2709
- }
2710
- return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
2711
- }
2712
- async function refreshCache(cache, repository, token, tools) {
2713
- if (existsSync3(join6(cache, ".git"))) {
2714
- await git(["fetch", "--prune", "origin"], { cwd: cache, token });
2715
- return;
2716
- }
2717
- await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
2718
- }
2719
- async function removeWorktree(cache, path) {
2720
- await runGit(["worktree", "remove", "--force", path], { cwd: cache });
2721
- await rm3(path, { recursive: true, force: true });
2722
- await git(["worktree", "prune"], { cwd: cache });
2723
- }
2724
- async function baseRef(cache, job, repository) {
2725
- const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
2726
- return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
2727
- }
2728
- async function keptBase(cache, path, job, repository) {
2729
- if (!job.keepWorkspace || !existsSync3(join6(path, ".git"))) {
2730
- return null;
2731
- }
2732
- const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
2733
- if (branch.code !== 0 || branch.stdout !== job.branch) {
2734
- return null;
2735
- }
2736
- return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
2737
- }
2738
- async function prepareWorkspace(job, token, tools) {
2739
- const root = tamsenseFolder(tools.home, "work", `task-${job.taskId}`);
2740
- const folders = folderNames(job.repositories);
2741
- const repositories = [];
2742
- for (const [index, repository] of job.repositories.entries()) {
2743
- const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
2744
- const folder = folders[index] ?? repository.fullName;
2745
- const path = join6(root, folder);
2746
- const baseSha = await cacheLock.run(repository.fullName, async () => {
2747
- await refreshCache(cache, repository, token, tools);
2748
- const kept = await keptBase(cache, path, job, repository);
2749
- if (kept) {
2750
- return kept;
2751
- }
2752
- await removeWorktree(cache, path);
2753
- await git(["worktree", "add", "--force", "-B", job.branch, path, await baseRef(cache, job, repository)], { cwd: cache });
2754
- return git(["rev-parse", "HEAD"], { cwd: path });
2755
- });
2756
- repositories.push({ fullName: repository.fullName, folder, path, baseSha });
2757
- }
2758
- return { root, repositories };
2759
- }
2760
- async function commitIdentityEnv(home) {
2761
- const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
2762
- if (configured.code === 0 && configured.stdout) {
2763
- return {};
2764
- }
2765
- return {
2766
- GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
2767
- GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
2768
- GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
2769
- GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
2770
- };
2771
- }
2772
- async function publishWorkspace(workspace, job, token, identity) {
2773
- const pushed = [];
2774
- for (const repository of workspace.repositories) {
2775
- const options = { cwd: repository.path, env: identity };
2776
- const sha = await cacheLock.run(repository.fullName, async () => {
2777
- await git(["add", "--all"], options);
2778
- const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
2779
- if (hasStaged) {
2780
- await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
2781
- }
2782
- const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
2783
- if (newCommits === 0) {
2784
- return null;
2785
- }
2786
- await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
2787
- return git(["rev-parse", "HEAD"], options);
2788
- });
2789
- if (sha) {
2790
- pushed.push({ repository: repository.fullName, branch: job.branch, sha });
2791
- }
2792
- }
2793
- return pushed;
3379
+ const data = (await readFile5(request.path)).toString("base64");
3380
+ return { name: basename4(request.path), ...caption, contentType, data };
2794
3381
  }
2795
3382
 
2796
3383
  // src/worker.ts
@@ -2853,17 +3440,31 @@ function messageLine(message) {
2853
3440
  const line = `Message from ${message.authorName}: ${message.text}`.replace(/\s+/g, " ").trim();
2854
3441
  return line.length > MAX_MESSAGE_LINE ? `${line.slice(0, MAX_MESSAGE_LINE - 1)}\u2026` : line;
2855
3442
  }
3443
+ async function stopHelper(input, stop, print2) {
3444
+ try {
3445
+ const result = await input.stopHelper(stop.ref);
3446
+ if (result === "stopped") {
3447
+ input.note(helperStoppedNote(stop), "queue");
3448
+ print2(` ${stop.byName} stopped the helper ${stop.name}`);
3449
+ } else if (result === "unknown" && input.note(helperStopRequest(stop), "steer")) {
3450
+ print2(` ${stop.byName} asked Claude to stop the helper ${stop.name}`);
3451
+ }
3452
+ } catch (error) {
3453
+ print2(`Could not stop the helper ${stop.name}: ${messageOf3(error)}`);
3454
+ }
3455
+ }
2856
3456
  function inbox(job, tools, record, closedOnSite) {
2857
3457
  const input = inputChannel({ onIdle: () => void poll() });
3458
+ const stopsSeen = /* @__PURE__ */ new Set();
2858
3459
  let running = null;
2859
3460
  let again = false;
2860
3461
  let failing = false;
2861
3462
  async function check() {
2862
3463
  const ack = input.takeDelivered();
2863
3464
  const body = { ack };
2864
- let reply2;
3465
+ let reply3;
2865
3466
  try {
2866
- reply2 = await tools.api.request("POST", runPath(job.runId, "inbox"), body);
3467
+ reply3 = await tools.api.request("POST", runPath(job.runId, "inbox"), body);
2867
3468
  } catch (error) {
2868
3469
  input.restoreDelivered(ack);
2869
3470
  if (!failing) {
@@ -2873,17 +3474,23 @@ function inbox(job, tools, record, closedOnSite) {
2873
3474
  return;
2874
3475
  }
2875
3476
  failing = false;
2876
- if (!reply2.keepGoing) {
3477
+ if (!reply3.keepGoing) {
2877
3478
  closedOnSite();
2878
3479
  return;
2879
3480
  }
2880
- for (const message of reply2.messages) {
3481
+ for (const message of reply3.messages) {
2881
3482
  if (input.push(message)) {
2882
3483
  tools.print(` ${messageLine(message)}`);
2883
3484
  }
2884
3485
  }
2885
- if (reply2.model !== null || reply2.effort !== null) {
2886
- const change = { model: reply2.model, effort: reply2.effort };
3486
+ for (const stop of reply3.helperStops ?? []) {
3487
+ if (!stopsSeen.has(stop.ref)) {
3488
+ stopsSeen.add(stop.ref);
3489
+ void stopHelper(input, stop, tools.print);
3490
+ }
3491
+ }
3492
+ if (reply3.model !== null || reply3.effort !== null) {
3493
+ const change = { model: reply3.model, effort: reply3.effort };
2887
3494
  void input.switchModel(change).then(
2888
3495
  (applied) => {
2889
3496
  if (applied) {
@@ -2923,7 +3530,7 @@ function specWorkTools2(job, api) {
2923
3530
  if (kind !== "interview" && kind !== "spec") {
2924
3531
  return null;
2925
3532
  }
2926
- return { stage: kind, review: (body) => api.request("POST", runPath(job.runId, "review"), body) };
3533
+ return { stage: kind, review: (body) => api.request("POST", runPath(job.runId, "review"), body), brief: isFolderJob(job) };
2927
3534
  }
2928
3535
  function askingTools(job, api) {
2929
3536
  const people = job.people ?? [];
@@ -2938,11 +3545,11 @@ function permissionTools(job, tools) {
2938
3545
  }
2939
3546
  return {
2940
3547
  send: async (body) => {
2941
- const reply2 = await tools.api.request("POST", runPath(job.runId, "permissions"), body);
2942
- if (reply2.decision === "wait") {
3548
+ const reply3 = await tools.api.request("POST", runPath(job.runId, "permissions"), body);
3549
+ if (reply3.decision === "wait") {
2943
3550
  tools.print(` Waiting for a person on TAMsense to allow: ${body.title || body.action}`);
2944
3551
  }
2945
- return reply2;
3552
+ return reply3;
2946
3553
  },
2947
3554
  check: (id) => tools.api.request("GET", permissionPath(job.runId, id)),
2948
3555
  sleep: tools.sleep
@@ -2961,11 +3568,27 @@ function knowledgeTools2(job, api) {
2961
3568
  read: (entryId) => api.request("GET", knowledgePath(job.runId, entryId))
2962
3569
  };
2963
3570
  }
2964
- async function executeJob(job, tools, signal, input, record) {
2965
- const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
3571
+ function skillsLine(skills) {
3572
+ return `This role uses ${skills.length} skill(s) from the library: ${skills.join(", ")}.`;
3573
+ }
3574
+ async function prepare(job, tools) {
3575
+ if (isFolderJob(job)) {
3576
+ tools.print("Preparing the task folder\u2026");
3577
+ const { results, ...workspace2 } = await prepareFolder(job, taskFolder(tools.home, job), tools.api);
3578
+ return { workspace: workspace2, results, identity: {} };
3579
+ }
2966
3580
  tools.print("Preparing the code\u2026");
2967
3581
  const workspace = await prepareWorkspace(job, await gitToken(tools.api, job), tools);
2968
- const identity = await commitIdentityEnv(tools.home);
3582
+ return { workspace, results: null, identity: await commitIdentityEnv(tools.home) };
3583
+ }
3584
+ async function executeJob(job, tools, signal, input, record) {
3585
+ const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
3586
+ const { workspace, results, identity } = await prepare(job, tools);
3587
+ const skills = allowedSkills(job.stage);
3588
+ if (skills) {
3589
+ tools.print(skillsLine(skills));
3590
+ }
3591
+ const library = await prepareLibrary(roleLibraryFolder(tools.home, job.runId), skills);
2969
3592
  signal.throwIfAborted();
2970
3593
  tools.print("Claude is working\u2026");
2971
3594
  const attached = { count: 0 };
@@ -2975,7 +3598,8 @@ async function executeJob(job, tools, signal, input, record) {
2975
3598
  const recording = tools.recorder ? recorderEnv({ recorder: tools.recorder, folder: recordings, server: tools.api.server, job, nodeOptions: baseEnv.NODE_OPTIONS }) : {};
2976
3599
  const outcome = await tools.runAgent({
2977
3600
  cwd: workspace.root,
2978
- prompt: buildTaskPrompt(job, workspace, { browser: Boolean(tools.browser), recorder: Boolean(tools.recorder) }),
3601
+ library,
3602
+ prompt: buildTaskPrompt(job, workspace, { browser: Boolean(tools.browser), recorder: Boolean(tools.recorder), results }),
2979
3603
  input,
2980
3604
  env: { ...baseEnv, ...tools.browser ? browserEnv(tools.browser) : {}, ...recording, ...identity },
2981
3605
  signal,
@@ -2990,12 +3614,14 @@ async function executeJob(job, tools, signal, input, record) {
2990
3614
  effort: job.effort,
2991
3615
  onFeed: record,
2992
3616
  onSession: session.report,
2993
- attach: (request) => request.kind === "screenshot" ? attachScreenshot(tools.api, job, request, attached) : attachRecording(tools.api, job.runId, recordings, request)
3617
+ attach: (request) => request.kind === "screenshot" ? attachScreenshot(tools.api, job, request, attached) : attachRecording(tools.api, job.runId, recordings, request),
3618
+ results: resultTools(job, tools.api, workspace.root),
3619
+ handOver: handOverTools(job, tools.api)
2994
3620
  });
2995
3621
  await session.sent();
2996
3622
  signal.throwIfAborted();
2997
3623
  const waitingFor = outcome.waitingFor ?? null;
2998
- const shouldPublish = job.stage.kind === "code" && outcome.completed && waitingFor === null;
3624
+ const shouldPublish = job.stage.kind === "code" && !isFolderJob(job) && !isReadOnlyCode(job.stage) && outcome.completed && waitingFor === null;
2999
3625
  const pushed = shouldPublish ? await publishWorkspace(workspace, job, await gitToken(tools.api, job), identity) : [];
3000
3626
  return {
3001
3627
  outcome: outcome.completed ? "succeeded" : "failed",
@@ -3016,7 +3642,7 @@ function clockOf(iso) {
3016
3642
  const time = new Date(iso);
3017
3643
  return `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
3018
3644
  }
3019
- function resultLine(finish2) {
3645
+ function resultLine(finish2, folder = false) {
3020
3646
  if (finish2.pausedUntil) {
3021
3647
  return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
3022
3648
  }
@@ -3035,7 +3661,10 @@ function resultLine(finish2) {
3035
3661
  if (finish2.spec) {
3036
3662
  return "Done. The spec waits for the owner on TAMsense.";
3037
3663
  }
3038
- return finish2.outcome === "succeeded" ? `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.` : `Failed: ${finish2.report}`;
3664
+ if (finish2.outcome !== "succeeded") {
3665
+ return `Failed: ${finish2.report}`;
3666
+ }
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.`;
3039
3668
  }
3040
3669
  async function sendRecordings(job, tools) {
3041
3670
  const folder = recordingsFolder(tools.home, job.runId);
@@ -3051,7 +3680,7 @@ async function sendRecordings(job, tools) {
3051
3680
  tools.print(`Could not attach a recording: ${line}`);
3052
3681
  }
3053
3682
  } finally {
3054
- await rm4(folder, { recursive: true, force: true });
3683
+ await rm6(folder, { recursive: true, force: true });
3055
3684
  }
3056
3685
  }
3057
3686
  function failureReport(error, signal) {
@@ -3079,6 +3708,7 @@ async function runJob(job, tools, stop) {
3079
3708
  } catch (error) {
3080
3709
  finish2 = { outcome: "failed", report: failureReport(error, run2.signal), pushed: [], model: null, usage: null, verdict: null, handoff: null, sessionId: null, pausedUntil: null, questions: null, spec: null, waitingFor: null };
3081
3710
  } finally {
3711
+ await rm6(roleLibraryFolder(tools.home, job.runId), { recursive: true, force: true });
3082
3712
  clearInterval(polling);
3083
3713
  clearInterval(feeding);
3084
3714
  stop.removeEventListener("abort", stopRun);
@@ -3090,14 +3720,14 @@ async function runJob(job, tools, stop) {
3090
3720
  }
3091
3721
  await feed.flush();
3092
3722
  if (run2.signal.reason === CLOSED_ON_SITE) {
3093
- await rm4(recordingsFolder(tools.home, job.runId), { recursive: true, force: true });
3723
+ await rm6(recordingsFolder(tools.home, job.runId), { recursive: true, force: true });
3094
3724
  tools.print("TAMsense closed this run while it was in progress. The result was not sent.");
3095
3725
  return;
3096
3726
  }
3097
3727
  await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf3(error)}`));
3098
3728
  try {
3099
3729
  await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
3100
- tools.print(resultLine(finish2));
3730
+ tools.print(resultLine(finish2, isFolderJob(job)));
3101
3731
  } catch (error) {
3102
3732
  tools.print(`Could not send the result to TAMsense: ${messageOf3(error)}`);
3103
3733
  }
@@ -3126,18 +3756,18 @@ async function reportCapabilities(tools, stop) {
3126
3756
  }
3127
3757
  }
3128
3758
  function jobTools(job, tools, parallel) {
3129
- return parallel > 1 ? { ...tools, print: (line) => tools.print(`[#${job.taskId}] ${line}`) } : tools;
3759
+ return parallel > 1 ? { ...tools, print: (line) => tools.print(`[#${job.taskId} ${job.stage.name}] ${line}`) } : tools;
3130
3760
  }
3131
3761
  async function work(tools, stop, parallel = 1) {
3132
3762
  tools.print(parallel > 1 ? `Waiting for tasks, up to ${parallel} at once. Press Ctrl+C to stop.` : "Waiting for tasks. Press Ctrl+C to stop.");
3133
3763
  const halt = new AbortController();
3134
3764
  const stopAll = AbortSignal.any([stop, halt.signal]);
3135
3765
  const running = /* @__PURE__ */ new Set();
3136
- const taskLock = keyedLock();
3766
+ const folderLock = keyedLock();
3137
3767
  const reporting = reportCapabilities(tools, stopAll);
3138
3768
  const start = (job) => {
3139
3769
  const runTools = jobTools(job, tools, parallel);
3140
- const task = taskLock.run(String(job.taskId), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf3(error)}`)).finally(() => running.delete(task));
3770
+ const task = folderLock.run(stepFolder(job).join("/"), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf3(error)}`)).finally(() => running.delete(task));
3141
3771
  running.add(task);
3142
3772
  };
3143
3773
  try {
@@ -3146,9 +3776,9 @@ async function work(tools, stop, parallel = 1) {
3146
3776
  await Promise.race(running);
3147
3777
  continue;
3148
3778
  }
3149
- let reply2;
3779
+ let reply3;
3150
3780
  try {
3151
- reply2 = await tools.api.request("POST", NEXT_RUN_PATH);
3781
+ reply3 = await tools.api.request("POST", NEXT_RUN_PATH);
3152
3782
  } catch (error) {
3153
3783
  if (isFatal(error)) {
3154
3784
  halt.abort();
@@ -3158,8 +3788,8 @@ async function work(tools, stop, parallel = 1) {
3158
3788
  await tools.sleep(RETRY_AFTER_ERROR_MS, stopAll);
3159
3789
  continue;
3160
3790
  }
3161
- if (reply2.job) {
3162
- start(reply2.job);
3791
+ if (reply3.job) {
3792
+ start(reply3.job);
3163
3793
  } else {
3164
3794
  await tools.sleep(POLL_INTERVAL_MS, stopAll);
3165
3795
  }
@@ -3214,7 +3844,7 @@ function stopOnInterrupt() {
3214
3844
  return stop.signal;
3215
3845
  }
3216
3846
  function installTools(home) {
3217
- return { home, nodePath: process5.execPath, env: process5.env, print, run: runProcess, exists: existsSync4 };
3847
+ return { home, nodePath: process5.execPath, env: process5.env, print, run: runProcess, exists: existsSync5 };
3218
3848
  }
3219
3849
  async function browserForStart(home, install) {
3220
3850
  const installed = await installedBrowser(home);