@tamsensedev/runner 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -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((resolve2, 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) => resolve2({ 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((resolve2) => {
318
+ release = resolve2;
319
319
  });
320
320
  const nothing = {
321
321
  [Symbol.asyncIterator]: () => ({
@@ -341,15 +341,15 @@ async function probeCapabilities(stop) {
341
341
 
342
342
  // src/claude.ts
343
343
  import { fileURLToPath } from "url";
344
- import { createSdkMcpServer, query as query2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
345
- import { z as z2 } from "zod";
344
+ import { createSdkMcpServer, query as query2, tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
345
+ import { z as z3 } from "zod";
346
346
 
347
347
  // src/askPerson.ts
348
348
  var MAX_ASK_TRIES = 5;
349
349
  var MAX_ASK_OPTIONS = 4;
350
350
  function personLine(person) {
351
- const roles = person.roles.length > 0 ? `, ${person.roles.join(", ")}` : "";
352
- return `${person.id}: ${person.name} (${person.relation}, ${person.access} access${roles})`;
351
+ const roles = person.roles.length > 0 ? `. Team roles: ${person.roles.join("; ")}` : "";
352
+ return `${person.id}: ${person.name} (${person.relation}, ${person.access} access)${roles}`;
353
353
  }
354
354
  function findPerson(people, to) {
355
355
  const key = String(to).trim().toLowerCase();
@@ -402,21 +402,21 @@ function shorten(text2) {
402
402
  const line = text2.replace(/\s+/g, " ").trim();
403
403
  return line.length > MAX_ACTIVITY_LENGTH ? `${line.slice(0, MAX_ACTIVITY_LENGTH - 1)}\u2026` : line;
404
404
  }
405
- function activityLine(tool3, input) {
405
+ function activityLine(tool4, input) {
406
406
  const fields = typeof input === "object" && input !== null ? input : {};
407
- if (tool3 === "Bash" && typeof fields.command === "string") {
407
+ if (tool4 === "Bash" && typeof fields.command === "string") {
408
408
  return `$ ${shorten(fields.command)}`;
409
409
  }
410
- if (FILE_TOOLS.has(tool3) && typeof fields.file_path === "string") {
411
- return `${tool3} ${shorten(fields.file_path)}`;
410
+ if (FILE_TOOLS.has(tool4) && typeof fields.file_path === "string") {
411
+ return `${tool4} ${shorten(fields.file_path)}`;
412
412
  }
413
- if (tool3 === "Skill" && typeof fields.skill === "string") {
413
+ if (tool4 === "Skill" && typeof fields.skill === "string") {
414
414
  return `Skill ${shorten(fields.skill)}`;
415
415
  }
416
- if (HELPER_TOOLS.has(tool3) && typeof fields.subagent_type === "string") {
416
+ if (HELPER_TOOLS.has(tool4) && typeof fields.subagent_type === "string") {
417
417
  return `Agent ${shorten(fields.subagent_type)}`;
418
418
  }
419
- return tool3;
419
+ return tool4;
420
420
  }
421
421
  function usageOf(modelUsage) {
422
422
  const entries = Object.values(modelUsage);
@@ -459,29 +459,29 @@ function textField(fields, name) {
459
459
  return typeof value === "string" && value.trim() ? value : null;
460
460
  }
461
461
  function toolDraft(block, parent) {
462
- const tool3 = (block.name ?? "tool").replace(MCP_PREFIX, "");
462
+ const tool4 = (block.name ?? "tool").replace(MCP_PREFIX, "");
463
463
  const fields = fieldsOf(block.input);
464
- const base = { tool: tool3, ref: block.id ?? null, parent, failed: false };
465
- if (tool3 === "Skill" && textField(fields, "skill")) {
464
+ const base = { tool: tool4, ref: block.id ?? null, parent, failed: false };
465
+ if (tool4 === "Skill" && textField(fields, "skill")) {
466
466
  return { ...base, kind: "skill", text: clip(textField(fields, "skill"), MAX_SUMMARY_LENGTH) };
467
467
  }
468
- if (HELPER_TOOLS2.has(tool3)) {
468
+ if (HELPER_TOOLS2.has(tool4)) {
469
469
  const who = textField(fields, "subagent_type") ?? "general-purpose";
470
470
  const what = textField(fields, "description");
471
471
  return { ...base, kind: "agent", text: clip(what ? `${who}: ${what}` : who, MAX_SUMMARY_LENGTH) };
472
472
  }
473
- if (tool3 === "submit_verdict") {
473
+ if (tool4 === "submit_verdict") {
474
474
  const verdict = fields.couldNotRun === true ? "Could not run" : fields.passed === true ? "Passed" : "Did not pass";
475
475
  return { ...base, kind: "tool", text: clip(`${verdict}. ${textField(fields, "report") ?? ""}`, MAX_SUMMARY_LENGTH) };
476
476
  }
477
- if (tool3 === "hand_off") {
477
+ if (tool4 === "hand_off") {
478
478
  return { ...base, kind: "tool", text: clip(`Hand off to ${textField(fields, "to") ?? "?"}: ${textField(fields, "note") ?? ""}`, MAX_SUMMARY_LENGTH) };
479
479
  }
480
- if (tool3 in ATTACH_LABELS) {
481
- return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool3]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
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) };
482
482
  }
483
483
  const command = textField(fields, "command");
484
- const summary = tool3 === "Bash" && command ? `$ ${command}` : activityLine(tool3, block.input);
484
+ const summary = tool4 === "Bash" && command ? `$ ${command}` : activityLine(tool4, block.input);
485
485
  return { ...base, kind: "tool", text: clip(summary, MAX_SUMMARY_LENGTH) };
486
486
  }
487
487
  function resultText(content) {
@@ -653,8 +653,8 @@ function inputChannel(options = {}) {
653
653
  }
654
654
  const next = queue.shift();
655
655
  if (!next) {
656
- await new Promise((resolve) => {
657
- wake = resolve;
656
+ await new Promise((resolve2) => {
657
+ wake = resolve2;
658
658
  });
659
659
  continue;
660
660
  }
@@ -714,9 +714,9 @@ function inputChannel(options = {}) {
714
714
  onSwitch(apply) {
715
715
  applySwitch2 = apply;
716
716
  if (waitingSwitch) {
717
- const { change, resolve } = waitingSwitch;
717
+ const { change, resolve: resolve2 } = waitingSwitch;
718
718
  waitingSwitch = null;
719
- void apply(change).then(() => resolve(true), () => resolve(false));
719
+ void apply(change).then(() => resolve2(true), () => resolve2(false));
720
720
  }
721
721
  },
722
722
  close,
@@ -763,13 +763,211 @@ function inputChannel(options = {}) {
763
763
  const earlier = waitingSwitch;
764
764
  earlier?.resolve(false);
765
765
  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 };
766
+ return new Promise((resolve2) => {
767
+ waitingSwitch = { change: merged, resolve: resolve2 };
768
768
  });
769
769
  }
770
770
  };
771
771
  }
772
772
 
773
+ // src/knowledge.ts
774
+ import { tool } from "@anthropic-ai/claude-agent-sdk";
775
+ import { z } from "zod";
776
+ var PROPOSE_KNOWLEDGE_TOOL_NAME = "propose_knowledge";
777
+ var UPDATE_KNOWLEDGE_TOOL_NAME = "update_knowledge";
778
+ var READ_KNOWLEDGE_TOOL_NAME = "read_knowledge";
779
+ var MAX_KNOWLEDGE_TITLE = 80;
780
+ var MAX_KNOWLEDGE_TEXT = 600;
781
+ var UPDATE_RULE = `Rely on this knowledge instead of asking people again. When you relied on an entry and found it true, call ${UPDATE_KNOWLEDGE_TOOL_NAME} with stillTrue true. When the code shows an entry is out of date (a file moved, a command or a port changed), fix it yourself at once: ${UPDATE_KNOWLEDGE_TOOL_NAME} with stillTrue false, the full new text and the evidence (the commit, the file or the command output that shows it). The change applies right away and people can revert it. Never ask a person whether an entry is still right, and never move facts of this computer into project knowledge.`;
782
+ var PROPOSE_RULE = `When a person's answer to your question teaches something later runs need (how the team runs the local stand, where something lives, a convention), propose one short note with ${PROPOSE_KNOWLEDGE_TOOL_NAME} and the question number from the answer. Scope computer is for what is true only on this computer (folders, ports, installed tools, local quirks); scope project is for what comes from the repository and works on every computer. Never propose secrets, personal data or decisions about this one task. A person approves every note; keep working while they decide.`;
783
+ function dateOf(iso) {
784
+ return iso ? `checked ${iso.slice(0, 10)}` : "not checked yet";
785
+ }
786
+ function entryText(entry) {
787
+ return `[${entry.id}] ${entry.title}: ${entry.text.replace(/\s+/g, " ").trim()} (${dateOf(entry.checkedAt)})`;
788
+ }
789
+ function entryLine(entry) {
790
+ return `- ${entryText(entry)}`;
791
+ }
792
+ function workplaceText(computer, folder) {
793
+ if (!computer) {
794
+ return null;
795
+ }
796
+ return `You run on ${computer.name}, the ${computer.platform} computer of ${computer.ownerName}. Your task folder is ${folder}; everything outside it belongs to ${computer.ownerName}.`;
797
+ }
798
+ function hasKnowledge(knowledge) {
799
+ return Boolean(knowledge && (knowledge.project.length > 0 || knowledge.computer.length > 0 || knowledge.more.length > 0));
800
+ }
801
+ function knowledgeText(knowledge, computer, canPropose) {
802
+ const rules = canPropose ? [PROPOSE_RULE] : [];
803
+ if (!hasKnowledge(knowledge)) {
804
+ return rules.length > 0 ? `Knowledge of this project is empty so far. ${rules.join(" ")}` : null;
805
+ }
806
+ const here = computer ? computer.name : "this computer";
807
+ const sections = [
808
+ knowledge.project.length > 0 ? `Project knowledge (approved by the team, true on every computer):
809
+ ${knowledge.project.map(entryLine).join("\n")}` : null,
810
+ knowledge.computer.length > 0 ? `Knowledge for ${here} only (true here, not on other computers):
811
+ ${knowledge.computer.map(entryLine).join("\n")}` : null,
812
+ knowledge.more.length > 0 ? `More entries, titles only to keep this short: ${knowledge.more.map((entry) => `[${entry.id}] ${entry.title} (${entry.scope})`).join("; ")}. When a title touches your work, read the entry with ${READ_KNOWLEDGE_TOOL_NAME} and its number.` : null
813
+ ];
814
+ return [...sections, UPDATE_RULE, ...rules].filter(Boolean).join("\n\n");
815
+ }
816
+ function knowledgeIds(knowledge) {
817
+ return knowledge ? [...knowledge.project, ...knowledge.computer, ...knowledge.more].map((entry) => entry.id) : [];
818
+ }
819
+ function failed(text2) {
820
+ return { content: [{ type: "text", text: text2 }], isError: true };
821
+ }
822
+ function done(text2) {
823
+ return { content: [{ type: "text", text: text2 }] };
824
+ }
825
+ function unknownEntry(tools, entry) {
826
+ return failed(`There is no entry ${entry} in this run. Use a number in square brackets from your instructions: ${tools.entryIds.join(", ") || "none"}.`);
827
+ }
828
+ function messageOf(error) {
829
+ return error instanceof Error ? error.message : String(error);
830
+ }
831
+ function proposeKnowledgeTool(tools) {
832
+ return tool(
833
+ PROPOSE_KNOWLEDGE_TOOL_NAME,
834
+ "Propose a short note for the TAMsense project knowledge after a person answered your question, so later runs do not ask again. A person on the task approves it: for the whole project, for this computer only, or not at all.",
835
+ {
836
+ title: z.string().describe(`a few words, at most ${MAX_KNOWLEDGE_TITLE} characters: "Local stand and database"`),
837
+ text: z.string().describe(`the fact in one to three plain sentences, at most ${MAX_KNOWLEDGE_TEXT} characters, with exact commands and paths in backticks`),
838
+ scope: z.enum(["project", "computer"]).describe("computer: true only on this computer (folders, ports, installed tools); project: comes from the repository and works everywhere"),
839
+ question: z.number().optional().describe('the number of the question whose answer this comes from, as in "(question 31)"')
840
+ },
841
+ async (args) => {
842
+ const title = args.title.trim();
843
+ const text2 = args.text.trim();
844
+ if (!title || !text2 || title.length > MAX_KNOWLEDGE_TITLE || text2.length > MAX_KNOWLEDGE_TEXT) {
845
+ return failed(`Write a title of at most ${MAX_KNOWLEDGE_TITLE} characters and a text of at most ${MAX_KNOWLEDGE_TEXT}.`);
846
+ }
847
+ try {
848
+ await tools.propose({ title, text: text2, scope: args.scope, question: args.question ?? null });
849
+ } catch (error) {
850
+ return failed(`The note was not suggested: ${messageOf(error)}`);
851
+ }
852
+ return done("Suggested. A person on the task decides whether to save it; keep working.");
853
+ }
854
+ );
855
+ }
856
+ function updateKnowledgeTool(tools) {
857
+ return tool(
858
+ UPDATE_KNOWLEDGE_TOOL_NAME,
859
+ "Keep the TAMsense knowledge current. stillTrue true: you relied on the entry and it holds. stillTrue false: the code shows it is out of date; the new text applies at once and people see your evidence.",
860
+ {
861
+ entry: z.number().describe("the entry number in square brackets from your instructions"),
862
+ stillTrue: z.boolean(),
863
+ text: z.string().optional().describe(`when stillTrue is false: the full new text, at most ${MAX_KNOWLEDGE_TEXT} characters`),
864
+ evidence: z.string().optional().describe("when stillTrue is false: one line on what shows the old text is wrong, such as a commit, a file or a command output")
865
+ },
866
+ async (args) => {
867
+ if (!tools.entryIds.includes(args.entry)) {
868
+ return unknownEntry(tools, args.entry);
869
+ }
870
+ const text2 = args.text?.trim() ?? "";
871
+ const evidence = args.evidence?.trim() ?? "";
872
+ if (!args.stillTrue && (!text2 || !evidence || text2.length > MAX_KNOWLEDGE_TEXT)) {
873
+ return failed(`An update needs the full new text (at most ${MAX_KNOWLEDGE_TEXT} characters) and the evidence.`);
874
+ }
875
+ try {
876
+ await tools.update(args.entry, { stillTrue: args.stillTrue, text: text2, evidence });
877
+ } catch (error) {
878
+ return failed(`The entry was not updated: ${messageOf(error)}`);
879
+ }
880
+ return done(args.stillTrue ? "Marked as checked today." : "Updated. People see the old text, your evidence and can revert it.");
881
+ }
882
+ );
883
+ }
884
+ function readKnowledgeTool(tools) {
885
+ return tool(
886
+ READ_KNOWLEDGE_TOOL_NAME,
887
+ "Read a TAMsense knowledge entry that your instructions list by title only.",
888
+ {
889
+ entry: z.number().describe("the entry number in square brackets from your instructions")
890
+ },
891
+ async (args) => {
892
+ if (!tools.entryIds.includes(args.entry)) {
893
+ return unknownEntry(tools, args.entry);
894
+ }
895
+ try {
896
+ return done(entryText(await tools.read(args.entry)));
897
+ } catch (error) {
898
+ return failed(`The entry was not read: ${messageOf(error)}`);
899
+ }
900
+ }
901
+ );
902
+ }
903
+
904
+ // src/permissions.ts
905
+ var NOBODY_TO_ASK = "Nobody is at the computer to approve this during a TAMsense run. Do it another way, or explain in your report why it is needed.";
906
+ var CHECK_INTERVAL_MS = 5e3;
907
+ var SITE_SILENCE_MS = 6e4;
908
+ var MAX_ACTION_LENGTH = 4e3;
909
+ var STOPPED_TEXT2 = "The run is stopping, so this was not done.";
910
+ var SITE_SILENT_TEXT = "TAMsense did not answer about this action, so it was not done. Do it another way, or explain in your report why it is needed.";
911
+ var NEVER_SENT = /* @__PURE__ */ new Set(["AskUserQuestion", "ExitPlanMode"]);
912
+ var ACTION_FIELDS = ["command", "file_path", "notebook_path", "url", "query", "path"];
913
+ function actionOf(input) {
914
+ const field = ACTION_FIELDS.map((key) => input[key]).find((value) => typeof value === "string" && value.trim());
915
+ return (typeof field === "string" ? field.trim() : JSON.stringify(input)).slice(0, MAX_ACTION_LENGTH);
916
+ }
917
+ function titleOf(input, options) {
918
+ const described = typeof input.description === "string" ? input.description.trim() : "";
919
+ return described || options.title?.trim() || options.displayName?.trim() || "";
920
+ }
921
+ function deny(message) {
922
+ return { behavior: "deny", message };
923
+ }
924
+ function messageOf2(error) {
925
+ return error instanceof Error ? error.message : String(error);
926
+ }
927
+ async function decisionOf(tools, first, signal) {
928
+ const now = tools.now ?? Date.now;
929
+ const giveUpAt = Date.parse(first.waitUntil) + SITE_SILENCE_MS;
930
+ let reply2 = first;
931
+ while (reply2.decision === "wait") {
932
+ await tools.sleep(CHECK_INTERVAL_MS, signal);
933
+ if (signal.aborted) {
934
+ return STOPPED_TEXT2;
935
+ }
936
+ if (now() > giveUpAt) {
937
+ return SITE_SILENT_TEXT;
938
+ }
939
+ reply2 = await tools.check(reply2.id).catch(() => reply2);
940
+ }
941
+ return reply2;
942
+ }
943
+ function askPermission(tools, lastWords, folder) {
944
+ return async (toolName, input, options) => {
945
+ if (NEVER_SENT.has(toolName)) {
946
+ return deny(NOBODY_TO_ASK);
947
+ }
948
+ const body = {
949
+ tool: toolName,
950
+ action: actionOf(input),
951
+ title: titleOf(input, options),
952
+ reason: lastWords() || options.decisionReason || "",
953
+ folder,
954
+ outsideFolder: Boolean(options.blockedPath)
955
+ };
956
+ let first;
957
+ try {
958
+ first = await tools.send(body);
959
+ } catch (error) {
960
+ 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
+ }
962
+ const reply2 = await decisionOf(tools, first, options.signal);
963
+ if (typeof reply2 === "string") {
964
+ return deny(reply2);
965
+ }
966
+ return reply2.decision === "allow" ? { behavior: "allow", updatedInput: input } : deny(reply2.message);
967
+ };
968
+ }
969
+ var denyWhenAsked = async () => deny(NOBODY_TO_ASK);
970
+
773
971
  // src/specText.ts
774
972
  function isRecord(value) {
775
973
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -850,6 +1048,206 @@ function specPromptText(spec) {
850
1048
  ].filter(Boolean).join("\n\n");
851
1049
  }
852
1050
 
1051
+ // src/workspace.ts
1052
+ import { existsSync as existsSync2 } from "fs";
1053
+ import { rm as rm2 } from "fs/promises";
1054
+ import { join as join2, resolve } from "path";
1055
+
1056
+ // src/git.ts
1057
+ import { Buffer } from "buffer";
1058
+ import { spawn as spawn2 } from "child_process";
1059
+ var GITHUB_ORIGIN = "https://github.com/";
1060
+ function githubAuthEnv(token) {
1061
+ const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
1062
+ return {
1063
+ GIT_CONFIG_COUNT: "1",
1064
+ GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
1065
+ GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
1066
+ GIT_TERMINAL_PROMPT: "0"
1067
+ };
1068
+ }
1069
+ function runGit(args, options) {
1070
+ const env = { ...process.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
1071
+ return new Promise((resolve2, reject) => {
1072
+ const child = spawn2("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
1073
+ let stdout = "";
1074
+ let stderr = "";
1075
+ child.stdout.on("data", (chunk) => {
1076
+ stdout += chunk.toString();
1077
+ });
1078
+ child.stderr.on("data", (chunk) => {
1079
+ stderr += chunk.toString();
1080
+ });
1081
+ child.on("error", reject);
1082
+ child.on("close", (code) => resolve2({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
1083
+ });
1084
+ }
1085
+ async function git(args, options) {
1086
+ const result = await runGit(args, options);
1087
+ if (result.code !== 0) {
1088
+ throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
1089
+ }
1090
+ return result.stdout;
1091
+ }
1092
+
1093
+ // src/lock.ts
1094
+ function keyedLock() {
1095
+ const tails = /* @__PURE__ */ new Map();
1096
+ return {
1097
+ async run(key, work2) {
1098
+ const previous = tails.get(key) ?? Promise.resolve();
1099
+ let release = () => {
1100
+ };
1101
+ const done2 = new Promise((resolve2) => {
1102
+ release = resolve2;
1103
+ });
1104
+ const tail = previous.then(() => done2);
1105
+ tails.set(key, tail);
1106
+ await previous;
1107
+ try {
1108
+ return await work2();
1109
+ } finally {
1110
+ release();
1111
+ if (tails.get(key) === tail) {
1112
+ tails.delete(key);
1113
+ }
1114
+ }
1115
+ }
1116
+ };
1117
+ }
1118
+
1119
+ // src/workspace.ts
1120
+ var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
1121
+ var cacheLock = keyedLock();
1122
+ function githubRemoteUrl(fullName) {
1123
+ return `https://github.com/${fullName}.git`;
1124
+ }
1125
+ function tamsenseFolder(home, ...parts) {
1126
+ return join2(home, ".tamsense", ...parts);
1127
+ }
1128
+ function folderNames(repositories) {
1129
+ const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
1130
+ const counts = /* @__PURE__ */ new Map();
1131
+ for (const repository of repositories) {
1132
+ counts.set(shortName(repository.fullName), (counts.get(shortName(repository.fullName)) ?? 0) + 1);
1133
+ }
1134
+ return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
1135
+ }
1136
+ async function refreshCache(cache, repository, token, tools) {
1137
+ if (existsSync2(join2(cache, ".git"))) {
1138
+ await git(["fetch", "--prune", "origin"], { cwd: cache, token });
1139
+ return;
1140
+ }
1141
+ await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
1142
+ }
1143
+ async function removeWorktree(cache, path) {
1144
+ await runGit(["worktree", "remove", "--force", path], { cwd: cache });
1145
+ await rm2(path, { recursive: true, force: true });
1146
+ await git(["worktree", "prune"], { cwd: cache });
1147
+ }
1148
+ function stepFolder(job) {
1149
+ const task = `task-${job.taskId}`;
1150
+ return typeof job.stage.columnId === "number" ? [task, `step-${job.stage.columnId}`] : [task];
1151
+ }
1152
+ function isReadOnlyCheck(job) {
1153
+ return job.stage.kind === "review" || job.stage.kind === "test";
1154
+ }
1155
+ function branchHolders(porcelain, branch) {
1156
+ const holders = [];
1157
+ let path = null;
1158
+ for (const line of porcelain.split("\n").map((text2) => text2.trim())) {
1159
+ if (line.startsWith("worktree ")) {
1160
+ path = line.slice("worktree ".length);
1161
+ } else if (path && line === `branch refs/heads/${branch}`) {
1162
+ holders.push(path);
1163
+ }
1164
+ }
1165
+ return holders;
1166
+ }
1167
+ async function releaseBranch(cache, branch, keep) {
1168
+ const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: cache });
1169
+ for (const path of branchHolders(listed.stdout, branch).filter((holder) => resolve(holder) !== resolve(keep))) {
1170
+ await removeWorktree(cache, path);
1171
+ }
1172
+ }
1173
+ async function baseRef(cache, job, repository) {
1174
+ const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
1175
+ return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
1176
+ }
1177
+ async function keptBase(cache, path, job, repository) {
1178
+ if (!job.keepWorkspace || !existsSync2(join2(path, ".git"))) {
1179
+ return null;
1180
+ }
1181
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
1182
+ if (branch.code !== 0 || branch.stdout !== (isReadOnlyCheck(job) ? "HEAD" : job.branch)) {
1183
+ return null;
1184
+ }
1185
+ return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
1186
+ }
1187
+ async function prepareWorkspace(job, token, tools) {
1188
+ const root = tamsenseFolder(tools.home, "work", ...stepFolder(job));
1189
+ const folders = folderNames(job.repositories);
1190
+ const repositories = [];
1191
+ for (const [index, repository] of job.repositories.entries()) {
1192
+ const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
1193
+ const folder = folders[index] ?? repository.fullName;
1194
+ const path = join2(root, folder);
1195
+ const baseSha = await cacheLock.run(repository.fullName, async () => {
1196
+ await refreshCache(cache, repository, token, tools);
1197
+ const kept = await keptBase(cache, path, job, repository);
1198
+ if (kept) {
1199
+ return kept;
1200
+ }
1201
+ await removeWorktree(cache, path);
1202
+ const base = await baseRef(cache, job, repository);
1203
+ if (isReadOnlyCheck(job)) {
1204
+ await git(["worktree", "add", "--force", "--detach", path, base], { cwd: cache });
1205
+ } else {
1206
+ await releaseBranch(cache, job.branch, path);
1207
+ await git(["worktree", "add", "--force", "-B", job.branch, path, base], { cwd: cache });
1208
+ }
1209
+ return git(["rev-parse", "HEAD"], { cwd: path });
1210
+ });
1211
+ repositories.push({ fullName: repository.fullName, folder, path, baseSha });
1212
+ }
1213
+ return { root, repositories };
1214
+ }
1215
+ async function commitIdentityEnv(home) {
1216
+ const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
1217
+ if (configured.code === 0 && configured.stdout) {
1218
+ return {};
1219
+ }
1220
+ return {
1221
+ GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
1222
+ GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
1223
+ GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
1224
+ GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
1225
+ };
1226
+ }
1227
+ async function publishWorkspace(workspace, job, token, identity) {
1228
+ const pushed = [];
1229
+ for (const repository of workspace.repositories) {
1230
+ const options = { cwd: repository.path, env: identity };
1231
+ const sha = await cacheLock.run(repository.fullName, async () => {
1232
+ await git(["add", "--all"], options);
1233
+ const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
1234
+ if (hasStaged) {
1235
+ await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
1236
+ }
1237
+ const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
1238
+ if (newCommits === 0) {
1239
+ return null;
1240
+ }
1241
+ await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
1242
+ return git(["rev-parse", "HEAD"], options);
1243
+ });
1244
+ if (sha) {
1245
+ pushed.push({ repository: repository.fullName, branch: job.branch, sha });
1246
+ }
1247
+ }
1248
+ return pushed;
1249
+ }
1250
+
853
1251
  // src/prompt.ts
854
1252
  var VERDICT_TOOL_NAME = "submit_verdict";
855
1253
  var SCREENSHOT_TOOL_NAME = "attach_screenshot";
@@ -860,8 +1258,9 @@ var SUBMIT_QUESTIONS_TOOL_NAME = "submit_questions";
860
1258
  var SUBMIT_SPEC_TOOL_NAME = "submit_spec";
861
1259
  var ASK_PERSON_TOOL_NAME = "ask_person";
862
1260
  var MESSAGES_RULE = `You are working on a TAMsense task on the user's own computer. People on the task may send you messages during this run, each starting with "Message from <name>:". Follow them: they take priority over the original task where the two disagree.`;
863
- var UNATTENDED_RULE = `${MESSAGES_RULE} Nobody answers questions or approves actions during the run, so never stop to wait for a reply: decide yourself, keep working, and explain your decisions in your report.`;
1261
+ var UNATTENDED_RULE = `${MESSAGES_RULE} Nobody answers questions during the run, so never stop to wait for a reply: decide yourself, keep working, and explain your decisions in your report.`;
864
1262
  var ASKING_RULE = `${MESSAGES_RULE} Decide yourself when you reasonably can, keep working, and explain your decisions in your report. When you are stuck, or the decision belongs to a person (intent, design, access, how the team runs something locally), ask one of the people on the task with the ${ASK_PERSON_TOOL_NAME} tool instead of guessing, and say what you tried. Never ask what the code or the project knowledge already answers. Nobody watches your messages, so ask only with the tool.`;
1263
+ var APPROVAL_RULE = "Some actions need a person's approval. When Claude Code would ask for one, TAMsense checks the project safety rules: a Never action is refused at once, an Allowed one goes ahead, and anything else goes to a person on the task while your tool call waits up to 15 minutes. Before such an action, write one short line: why you need it and what you will do instead if it is denied. Never get around a refused action with another command that has the same effect.";
865
1264
  var MACHINE_RULE = "This is the person's own computer, and their other programs may be running on it. Stop only the processes you started yourself, by their process id, and stop them before you finish. Never kill processes by name or pattern (taskkill /IM, pkill, killall, Stop-Process -Name) and never touch programs you did not start.";
866
1265
  var CODE_RULES = [
867
1266
  "Make the changes the task asks for, and keep them focused on it.",
@@ -882,8 +1281,11 @@ var NAMING_RULE = 'Name everything you attach (screenshots, replays, videos) by
882
1281
  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.`;
883
1282
  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.";
884
1283
  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.";
885
- function localAppRule(taskId, isCheck, canAsk) {
886
- const project = `tamsense-task-${taskId}`;
1284
+ function composeProjectName(job) {
1285
+ return ["tamsense", ...stepFolder(job)].join("-");
1286
+ }
1287
+ function localAppRule(job, isCheck, canAsk) {
1288
+ const project = composeProjectName(job);
887
1289
  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.";
888
1290
  return [
889
1291
  "When you need the running app, start a local copy from this task folder only.",
@@ -927,6 +1329,9 @@ function contextText(context) {
927
1329
  return context.trim() ? `Project context from the team (follow it like the project's own rules):
928
1330
  ${context.trim()}` : null;
929
1331
  }
1332
+ function safetyText(job) {
1333
+ return job.safetyRules?.trim() || null;
1334
+ }
930
1335
  function bulletList(lines) {
931
1336
  return lines.map((line) => `- ${line}`).join("\n");
932
1337
  }
@@ -935,7 +1340,7 @@ function repositoryLines(job, workspace) {
935
1340
  return workspace.repositories.map((repository) => {
936
1341
  const source = sourceOf.get(repository.fullName);
937
1342
  const pull = source?.pullRequestUrl ? `, pull request ${source.pullRequestUrl}` : "";
938
- return `${repository.folder} (${repository.fullName}), base branch origin/${source?.defaultBranch ?? "main"}${pull}`;
1343
+ return `${repository.folder} (${repository.fullName}) at commit ${repository.baseSha.slice(0, 7)}, base branch origin/${source?.defaultBranch ?? "main"}${pull}`;
939
1344
  });
940
1345
  }
941
1346
  function stepLine(stage) {
@@ -950,11 +1355,14 @@ function peopleText(people) {
950
1355
  return null;
951
1356
  }
952
1357
  return [
953
- `People on this task you can ask with ${ASK_PERSON_TOOL_NAME} (id: name, relation, access):`,
1358
+ `People on this task you can ask with ${ASK_PERSON_TOOL_NAME} (id: name, relation, access, then their team roles with what each owns):`,
954
1359
  bulletList(people.map(personLine)),
955
- "Ask the assignee first; a question on a topic goes to the person whose role fits it. Set blocking only when you cannot go on without the answer: the run then pauses and the computer is freed for other tasks. Otherwise give the assumption you continue with."
1360
+ 'Ask the assignee first. A question on a topic goes to the person whose team role fits it, and then why names that role and what it owns, for example "Designer, owns empty states". Set blocking only when you cannot go on without the answer: the run then pauses and the computer is freed for other tasks. Otherwise give the assumption you continue with.'
956
1361
  ].join("\n");
957
1362
  }
1363
+ function canProposeKnowledge(job) {
1364
+ return job.knowledge !== void 0 && (job.people ?? []).length > 0 && job.stage.kind !== "interview" && job.stage.kind !== "spec";
1365
+ }
958
1366
  function handoffText(targets, isCheck) {
959
1367
  if (targets.length === 0) {
960
1368
  return null;
@@ -1017,6 +1425,7 @@ ${JSON.stringify(job.spec)}` : null,
1017
1425
  const instructions = [
1018
1426
  SPEC_UNATTENDED_RULE,
1019
1427
  MACHINE_RULE,
1428
+ workplaceText(job.computer, workspace.root),
1020
1429
  stepLine(job.stage),
1021
1430
  job.stage.instructions,
1022
1431
  contextText(job.projectContext),
@@ -1025,6 +1434,8 @@ ${bulletList(repositoryLines(job, workspace))}`,
1025
1434
  READ_ONLY_RULE,
1026
1435
  `How to work:
1027
1436
  ${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
1437
+ safetyText(job),
1438
+ knowledgeText(job.knowledge, job.computer, false),
1028
1439
  SPEC_STYLE_RULE
1029
1440
  ].filter(Boolean).join("\n\n");
1030
1441
  return { prompt, instructions };
@@ -1050,18 +1461,22 @@ ${job.review}`,
1050
1461
  teamMessagesText(job.messages)
1051
1462
  ].filter(Boolean).join("\n\n");
1052
1463
  const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
1053
- const localApp = localAppRule(job.taskId, isCheck, canAsk);
1464
+ const localApp = localAppRule(job, isCheck, canAsk);
1054
1465
  const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : [...CODE_RULES, ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
1055
1466
  const instructions = [
1056
1467
  canAsk ? ASKING_RULE : UNATTENDED_RULE,
1057
1468
  MACHINE_RULE,
1469
+ workplaceText(job.computer, workspace.root),
1058
1470
  stepLine(job.stage),
1059
1471
  job.stage.instructions,
1060
1472
  contextText(job.projectContext),
1061
- `The repositories for this task are checked out in the current folder, one subfolder each, on branch ${job.branch}:
1473
+ `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}:
1062
1474
  ${bulletList(repositoryLines(job, workspace))}`,
1063
1475
  `How to work:
1064
1476
  ${bulletList(rules)}`,
1477
+ safetyText(job),
1478
+ knowledgeText(job.knowledge, job.computer, canProposeKnowledge(job)),
1479
+ APPROVAL_RULE,
1065
1480
  peopleText(people),
1066
1481
  handoffText(job.allowedHandoffs, isCheck),
1067
1482
  options.browser && BROWSER_RULE
@@ -1070,98 +1485,98 @@ ${bulletList(rules)}`,
1070
1485
  }
1071
1486
 
1072
1487
  // src/specTools.ts
1073
- import { tool } from "@anthropic-ai/claude-agent-sdk";
1074
- import { z } from "zod";
1488
+ import { tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
1489
+ import { z as z2 } from "zod";
1075
1490
  var MAX_FIRST_ROUND = 4;
1076
- var questionSchema = z.object({
1077
- id: z.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
1078
- question: z.string().describe("One plain sentence the owner can answer without reading code"),
1079
- whyItMatters: z.string().describe("One line: what changes in the result depending on the answer"),
1080
- options: z.array(z.object({
1081
- label: z.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
1082
- description: z.string().describe("One sentence: what happens if this option is chosen"),
1083
- recommended: z.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
1491
+ var questionSchema = z2.object({
1492
+ id: z2.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
1493
+ question: z2.string().describe("One plain sentence the owner can answer without reading code"),
1494
+ whyItMatters: z2.string().describe("One line: what changes in the result depending on the answer"),
1495
+ options: z2.array(z2.object({
1496
+ label: z2.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
1497
+ description: z2.string().describe("One sentence: what happens if this option is chosen"),
1498
+ recommended: z2.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
1084
1499
  })).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'),
1085
- multiSelect: z.boolean().describe("true only when the options are additive and several can apply together")
1500
+ multiSelect: z2.boolean().describe("true only when the options are additive and several can apply together")
1086
1501
  });
1087
- var briefSchema = z.object({
1088
- findings: z.array(z.object({
1089
- fact: z.string().describe("What the code does today that matters for this task"),
1090
- where: z.string().describe("Where it is: owner/repo path:line")
1502
+ var briefSchema = z2.object({
1503
+ findings: z2.array(z2.object({
1504
+ fact: z2.string().describe("What the code does today that matters for this task"),
1505
+ where: z2.string().describe("Where it is: owner/repo path:line")
1091
1506
  })).describe("Findings from the code"),
1092
- dataFacts: z.array(z.string()).describe("Numbers and facts from the events or the business data, each with its source. Empty when the task needs no data"),
1093
- approach: z.string().describe("The approach you would take, in 2-4 sentences"),
1094
- codeMap: z.array(z.object({
1095
- repo: z.string().describe("Repository full name (owner/name)"),
1096
- file: z.string().describe("Path relative to the repository root"),
1097
- role: z.string().describe("Why it matters: what to change or reuse there")
1507
+ 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"),
1508
+ approach: z2.string().describe("The approach you would take, in 2-4 sentences"),
1509
+ codeMap: z2.array(z2.object({
1510
+ repo: z2.string().describe("Repository full name (owner/name)"),
1511
+ file: z2.string().describe("Path relative to the repository root"),
1512
+ role: z2.string().describe("Why it matters: what to change or reuse there")
1098
1513
  })).describe("The files the change will touch or must reuse"),
1099
- assumptions: z.array(z.object({
1100
- text: z.string().describe("The default you chose"),
1101
- reason: z.string().describe("Why it is safe or why it was chosen")
1514
+ assumptions: z2.array(z2.object({
1515
+ text: z2.string().describe("The default you chose"),
1516
+ reason: z2.string().describe("Why it is safe or why it was chosen")
1102
1517
  })).describe("Corners settled with a default instead of a question"),
1103
- pendingDecisions: z.array(z.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
1518
+ pendingDecisions: z2.array(z2.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
1104
1519
  });
1105
- var questionsPayloadSchema = z.object({
1520
+ var questionsPayloadSchema = z2.object({
1106
1521
  brief: briefSchema,
1107
- questions: z.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`)
1522
+ 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`)
1108
1523
  });
1109
- var specSchema = z.object({
1110
- goal: z.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
1111
- context: z.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
1112
- inScope: z.array(z.string()).describe("What this task delivers, one item each"),
1113
- outOfScope: z.array(z.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"),
1114
- decisions: z.array(z.object({
1115
- question: z.string(),
1116
- answer: z.string(),
1117
- by: z.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
1524
+ var specSchema = z2.object({
1525
+ goal: z2.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
1526
+ context: z2.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
1527
+ inScope: z2.array(z2.string()).describe("What this task delivers, one item each"),
1528
+ 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"),
1529
+ decisions: z2.array(z2.object({
1530
+ question: z2.string(),
1531
+ answer: z2.string(),
1532
+ by: z2.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
1118
1533
  })).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
1119
- codeMap: z.array(z.object({
1120
- repo: z.string().describe("Repository full name (owner/name)"),
1121
- file: z.string().describe("Path relative to the repository root"),
1122
- role: z.string().describe("What the coder needs to know about this file"),
1123
- use: z.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
1534
+ codeMap: z2.array(z2.object({
1535
+ repo: z2.string().describe("Repository full name (owner/name)"),
1536
+ file: z2.string().describe("Path relative to the repository root"),
1537
+ role: z2.string().describe("What the coder needs to know about this file"),
1538
+ use: z2.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
1124
1539
  })).describe("The files a coder must read before starting"),
1125
- changes: z.array(z.object({
1126
- repo: z.string().describe("Repository full name (owner/name) where the change happens"),
1127
- file: z.string().describe("Path to the file, relative to the repository root"),
1128
- action: z.enum(["create", "modify", "delete"]),
1129
- description: z.string().describe("The behaviour the file has after the change and which existing functions or patterns to extend. No code snippets or diffs"),
1130
- produces: z.array(z.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
1131
- consumes: z.array(z.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
1132
- acceptance: z.array(z.string()).describe("Ids of the criteria this change makes true, like AC-1")
1540
+ changes: z2.array(z2.object({
1541
+ repo: z2.string().describe("Repository full name (owner/name) where the change happens"),
1542
+ file: z2.string().describe("Path to the file, relative to the repository root"),
1543
+ action: z2.enum(["create", "modify", "delete"]),
1544
+ 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"),
1545
+ produces: z2.array(z2.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
1546
+ consumes: z2.array(z2.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
1547
+ acceptance: z2.array(z2.string()).describe("Ids of the criteria this change makes true, like AC-1")
1133
1548
  })).min(1).describe("File changes in dependency order: each uses only what earlier changes or the code already provide"),
1134
- acceptance: z.array(z.object({
1135
- id: z.string().describe("AC-1, AC-2 and so on, in order"),
1136
- kind: z.enum(["main", "edge", "keep"]).describe("main: the main path; edge: an error, empty or limit case; keep: existing behaviour that must keep working"),
1137
- text: z.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
1549
+ acceptance: z2.array(z2.object({
1550
+ id: z2.string().describe("AC-1, AC-2 and so on, in order"),
1551
+ 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"),
1552
+ text: z2.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
1138
1553
  })).min(1).describe("Acceptance criteria: main path first, then edge cases, then what must keep working"),
1139
- testPlan: z.object({
1140
- automated: z.array(z.object({
1141
- repo: z.string(),
1142
- command: z.string().describe("The exact command to run"),
1143
- expect: z.string().describe("What passing looks like")
1554
+ testPlan: z2.object({
1555
+ automated: z2.array(z2.object({
1556
+ repo: z2.string(),
1557
+ command: z2.string().describe("The exact command to run"),
1558
+ expect: z2.string().describe("What passing looks like")
1144
1559
  })).describe("At least one command"),
1145
- newTests: z.array(z.object({
1146
- seam: z.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
1147
- covers: z.array(z.string()).describe("Ids of the criteria it covers")
1560
+ newTests: z2.array(z2.object({
1561
+ seam: z2.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
1562
+ covers: z2.array(z2.string()).describe("Ids of the criteria it covers")
1148
1563
  })),
1149
- manual: z.array(z.object({
1150
- page: z.string().describe("The page or screen"),
1151
- steps: z.string().describe("What a person does and sees")
1564
+ manual: z2.array(z2.object({
1565
+ page: z2.string().describe("The page or screen"),
1566
+ steps: z2.string().describe("What a person does and sees")
1152
1567
  }))
1153
1568
  }),
1154
- risks: z.array(z.object({
1155
- risk: z.string(),
1156
- mitigation: z.string()
1569
+ risks: z2.array(z2.object({
1570
+ risk: z2.string(),
1571
+ mitigation: z2.string()
1157
1572
  })).describe("What could go wrong: migrations, existing data, access, performance, compatibility"),
1158
- riskLevel: z.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
1159
- assumptions: z.array(z.object({
1160
- text: z.string().describe("The default you chose"),
1161
- reason: z.string().describe("Why, so the owner can confirm or correct it quickly")
1573
+ riskLevel: z2.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
1574
+ assumptions: z2.array(z2.object({
1575
+ text: z2.string().describe("The default you chose"),
1576
+ reason: z2.string().describe("Why, so the owner can confirm or correct it quickly")
1162
1577
  })).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
1163
- openQuestions: z.array(questionSchema).describe("Questions only the owner can settle; the spec cannot be approved until each is answered or dismissed. Empty when none"),
1164
- split: z.array(z.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")
1578
+ 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"),
1579
+ 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")
1165
1580
  });
1166
1581
  function reply(text2, isError = false) {
1167
1582
  return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
@@ -1171,7 +1586,7 @@ function unreachable(error, toolName) {
1171
1586
  return reply(`Could not reach TAMsense to check this: ${message}. Call ${toolName} again in a moment.`, true);
1172
1587
  }
1173
1588
  function questionsTool(review, onSubmit) {
1174
- return tool(
1589
+ return tool2(
1175
1590
  SUBMIT_QUESTIONS_TOOL_NAME,
1176
1591
  "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.",
1177
1592
  questionsPayloadSchema.shape,
@@ -1194,7 +1609,7 @@ var BUILDER_CHECK_FIRST = "The spec passed the lint but was not saved yet: the b
1194
1609
  function specTool(review, onSubmit, builderCheckRan) {
1195
1610
  let repairs = 0;
1196
1611
  let reminded = false;
1197
- return tool(
1612
+ return tool2(
1198
1613
  SUBMIT_SPEC_TOOL_NAME,
1199
1614
  "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.",
1200
1615
  specSchema.shape,
@@ -1314,14 +1729,12 @@ function usageLimitWatch() {
1314
1729
  }
1315
1730
 
1316
1731
  // src/claude.ts
1317
- var NOBODY_TO_ASK = "Nobody is at the computer to approve this during a TAMsense run. Do it another way, or explain in your report why it is needed.";
1318
1732
  var NO_RESULT_TEXT = "Claude finished without a result.";
1319
1733
  var SERVER_NAME = "tamsense";
1320
1734
  var LIBRARY_PATH = fileURLToPath(new URL("../library", import.meta.url));
1321
- var denyWhenAsked = async () => ({ behavior: "deny", message: NOBODY_TO_ASK });
1322
1735
  var CAPTION_FIELDS = {
1323
- title: z2.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"'),
1324
- purpose: z2.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
1736
+ 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"'),
1737
+ purpose: z3.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
1325
1738
  };
1326
1739
  var ATTACH_TOOLS = {
1327
1740
  screenshot: {
@@ -1342,21 +1755,21 @@ var ATTACH_TOOLS = {
1342
1755
  };
1343
1756
  function attachTool(kind, attach) {
1344
1757
  const spec = ATTACH_TOOLS[kind];
1345
- return tool2(
1758
+ return tool3(
1346
1759
  spec.name,
1347
1760
  spec.description,
1348
- { path: z2.string().describe(spec.path), ...CAPTION_FIELDS },
1761
+ { path: z3.string().describe(spec.path), ...CAPTION_FIELDS },
1349
1762
  async (args) => ({ content: [{ type: "text", text: await attach({ kind, ...args }) }] })
1350
1763
  );
1351
1764
  }
1352
1765
  function verdictTool(onVerdict) {
1353
- return tool2(
1766
+ return tool3(
1354
1767
  VERDICT_TOOL_NAME,
1355
1768
  "Hand in the result of this check. Call it exactly once, at the very end.",
1356
1769
  {
1357
- passed: z2.boolean().describe("true only if nothing must be fixed"),
1358
- report: z2.string().describe("what you checked and, if it failed, exactly what to fix with file and line"),
1359
- couldNotRun: z2.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")
1770
+ passed: z3.boolean().describe("true only if nothing must be fixed"),
1771
+ report: z3.string().describe("what you checked and, if it failed, exactly what to fix with file and line"),
1772
+ 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")
1360
1773
  },
1361
1774
  async (args) => {
1362
1775
  onVerdict(args.couldNotRun ? { passed: false, report: args.report, couldNotRun: true } : { passed: args.passed, report: args.report });
@@ -1365,13 +1778,13 @@ function verdictTool(onVerdict) {
1365
1778
  );
1366
1779
  }
1367
1780
  function handoffTool(targets, onHandoff) {
1368
- return tool2(
1781
+ return tool3(
1369
1782
  HANDOFF_TOOL_NAME,
1370
1783
  "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.",
1371
1784
  {
1372
- to: z2.string().describe("name or id of the step to hand the task to"),
1373
- outcome: z2.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
1374
- note: z2.string().describe("for the next agent and the owner: what was done, what to check, what is open")
1785
+ to: z3.string().describe("name or id of the step to hand the task to"),
1786
+ outcome: z3.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
1787
+ note: z3.string().describe("for the next agent and the owner: what was done, what to check, what is open")
1375
1788
  },
1376
1789
  async (args) => {
1377
1790
  const choice = chooseHandoff(targets, args);
@@ -1384,25 +1797,25 @@ function handoffTool(targets, onHandoff) {
1384
1797
  );
1385
1798
  }
1386
1799
  function askPersonTool(asking, waiting) {
1387
- return tool2(
1800
+ return tool3(
1388
1801
  ASK_PERSON_TOOL_NAME,
1389
1802
  "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.",
1390
1803
  {
1391
- to: z2.union([z2.number(), z2.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"),
1392
- why: z2.string().describe('one line on why this person: "assignee, and the run is on her computer", "Designer, owns empty states"'),
1393
- question: z2.string().describe("one plain question the person can answer without reading the code"),
1394
- context: z2.string().optional().describe("one line on what you saw that led to the question"),
1395
- tried: z2.array(z2.object({
1396
- did: z2.string().describe("the command or step"),
1397
- error: z2.string().optional().describe("the exact error line, if there was one"),
1398
- result: z2.string().describe("what it showed, one line")
1804
+ 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"),
1805
+ why: z3.string().describe('one line on why this person: "assignee, and the run is on her computer", "Designer, owns empty states"'),
1806
+ question: z3.string().describe("one plain question the person can answer without reading the code"),
1807
+ context: z3.string().optional().describe("one line on what you saw that led to the question"),
1808
+ tried: z3.array(z3.object({
1809
+ did: z3.string().describe("the command or step"),
1810
+ error: z3.string().optional().describe("the exact error line, if there was one"),
1811
+ result: z3.string().describe("what it showed, one line")
1399
1812
  })).max(MAX_ASK_TRIES).optional().describe("what you already tried, the most telling first"),
1400
- options: z2.array(z2.object({
1401
- label: z2.string().describe("a short answer the person can pick"),
1402
- detail: z2.string().describe("one line on what happens if it is picked")
1813
+ options: z3.array(z3.object({
1814
+ label: z3.string().describe("a short answer the person can pick"),
1815
+ detail: z3.string().describe("one line on what happens if it is picked")
1403
1816
  })).max(MAX_ASK_OPTIONS).optional().describe("2 to 4 answers you suggest, the one you would pick first"),
1404
- blocking: z2.boolean().describe("true when you cannot go on without the answer"),
1405
- assumption: z2.string().optional().describe("when blocking is false: what you continue with until the answer comes")
1817
+ blocking: z3.boolean().describe("true when you cannot go on without the answer"),
1818
+ assumption: z3.string().optional().describe("when blocking is false: what you continue with until the answer comes")
1406
1819
  },
1407
1820
  async (args) => {
1408
1821
  if (waiting.waitingFor !== null) {
@@ -1435,6 +1848,16 @@ function specWorkTools(specWork, submitted) {
1435
1848
  submitted.spec = spec;
1436
1849
  }, () => submitted.helperStarted)];
1437
1850
  }
1851
+ function knowledgeTools(knowledge) {
1852
+ if (!knowledge) {
1853
+ return [];
1854
+ }
1855
+ return [
1856
+ ...knowledge.entryIds.length > 0 ? [updateKnowledgeTool(knowledge)] : [],
1857
+ ...knowledge.moreIds.length > 0 ? [readKnowledgeTool(knowledge)] : [],
1858
+ ...knowledge.canPropose ? [proposeKnowledgeTool(knowledge)] : []
1859
+ ];
1860
+ }
1438
1861
  function tamsenseServer(request, submitted) {
1439
1862
  const tools = [
1440
1863
  attachTool("screenshot", request.attach),
@@ -1445,6 +1868,7 @@ function tamsenseServer(request, submitted) {
1445
1868
  })] : [],
1446
1869
  ...specWorkTools(request.specWork, submitted),
1447
1870
  ...request.asking ? [askPersonTool(request.asking, submitted)] : [],
1871
+ ...knowledgeTools(request.knowledge),
1448
1872
  ...request.handoffs.length > 0 ? [handoffTool(request.handoffs, (handoff) => {
1449
1873
  submitted.handoff = handoff;
1450
1874
  })] : []
@@ -1483,7 +1907,8 @@ async function attempt(request, context, resume) {
1483
1907
  permissionMode: "auto",
1484
1908
  // краткий пересказ размышлений Claude идёт в журнал задачи, как в Paseo
1485
1909
  thinking: { type: "adaptive", display: "summarized" },
1486
- canUseTool: denyWhenAsked,
1910
+ // режим auto сам решает, что можно; о чём он спросил бы человека, решают правила проекта или люди задачи
1911
+ canUseTool: request.permissions ? askPermission(request.permissions, () => context.lastWords.text, request.cwd) : denyWhenAsked,
1487
1912
  plugins: [{ type: "local", path: LIBRARY_PATH }],
1488
1913
  mcpServers: { [SERVER_NAME]: tamsense.server },
1489
1914
  allowedTools: tamsense.allowed,
@@ -1512,6 +1937,7 @@ async function attempt(request, context, resume) {
1512
1937
  request.onFeed(draft);
1513
1938
  if (draft.kind === "text" && draft.parent === null) {
1514
1939
  lastText = draft.text;
1940
+ context.lastWords.text = draft.text;
1515
1941
  }
1516
1942
  }
1517
1943
  if (message.type === "result") {
@@ -1538,7 +1964,7 @@ function claudeRunner(startQuery) {
1538
1964
  }, { once: true });
1539
1965
  const submitted = { verdict: null, handoff: null, questions: null, spec: null, helperStarted: false, waitingFor: null };
1540
1966
  const limit = usageLimitWatch();
1541
- const context = { startQuery, abortController, submitted, limit };
1967
+ const context = { startQuery, abortController, submitted, limit, lastWords: { text: "" } };
1542
1968
  try {
1543
1969
  let result = await attempt(request, context, request.resumeSessionId);
1544
1970
  if (result.resumeFailed) {
@@ -1555,13 +1981,13 @@ function claudeRunner(startQuery) {
1555
1981
  var runClaude = claudeRunner(query2);
1556
1982
 
1557
1983
  // src/config.ts
1558
- import { mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1984
+ import { mkdir as mkdir2, readFile as readFile2, rm as rm3, writeFile as writeFile2 } from "fs/promises";
1559
1985
  import { homedir as homedir2 } from "os";
1560
- import { dirname as dirname2, join as join2 } from "path";
1986
+ import { dirname as dirname2, join as join3 } from "path";
1561
1987
  var OWNER_ONLY_FILE = 384;
1562
1988
  var OWNER_ONLY_FOLDER = 448;
1563
1989
  function configPath(home = homedir2()) {
1564
- return join2(home, ".tamsense", "runner.json");
1990
+ return join3(home, ".tamsense", "runner.json");
1565
1991
  }
1566
1992
  function isRecord2(value) {
1567
1993
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -1593,7 +2019,7 @@ async function writeConfig(config, path = configPath()) {
1593
2019
  `, { mode: OWNER_ONLY_FILE });
1594
2020
  }
1595
2021
  async function removeConfig(path = configPath()) {
1596
- await rm2(path, { force: true });
2022
+ await rm3(path, { force: true });
1597
2023
  }
1598
2024
 
1599
2025
  // src/protocol.ts
@@ -1608,6 +2034,12 @@ var CAPABILITIES_PATH = "/api/runner/me/capabilities";
1608
2034
  function runPath(runId, action) {
1609
2035
  return `/api/runner/runs/${runId}/${action}`;
1610
2036
  }
2037
+ function knowledgePath(runId, entryId) {
2038
+ return `/api/runner/runs/${runId}/knowledge/${entryId}`;
2039
+ }
2040
+ function permissionPath(runId, permissionId) {
2041
+ return `/api/runner/runs/${runId}/permissions/${permissionId}`;
2042
+ }
1611
2043
  function recordingUploadPath(runId, upload, part) {
1612
2044
  return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
1613
2045
  }
@@ -1641,14 +2073,14 @@ async function login(tools, request) {
1641
2073
  }
1642
2074
 
1643
2075
  // src/recorderEnv.ts
1644
- import { existsSync as existsSync2 } from "fs";
1645
- import { join as join4 } from "path";
2076
+ import { existsSync as existsSync3 } from "fs";
2077
+ import { join as join5 } from "path";
1646
2078
  import { fileURLToPath as fileURLToPath2 } from "url";
1647
2079
 
1648
2080
  // src/recorder.ts
1649
2081
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1650
2082
  import Module, { createRequire } from "module";
1651
- import { basename, dirname as dirname3, join as join3 } from "path";
2083
+ import { basename, dirname as dirname3, join as join4 } from "path";
1652
2084
  import process2 from "process";
1653
2085
  var RECORDINGS_DIR_ENV = "TAMSENSE_RECORDINGS_DIR";
1654
2086
  var SERVER_ENV = "TAMSENSE_SERVER";
@@ -1729,7 +2161,7 @@ function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
1729
2161
  }
1730
2162
  function rrwebSource() {
1731
2163
  const entry = createRequire(import.meta.url).resolve("rrweb");
1732
- return readFileSync(join3(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
2164
+ return readFileSync(join4(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
1733
2165
  }
1734
2166
  function pageScript(rrweb) {
1735
2167
  return `(() => {
@@ -1780,8 +2212,8 @@ var Session = class {
1780
2212
  bytes = 0;
1781
2213
  constructor(dir, recording, url) {
1782
2214
  const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
1783
- this.file = join3(dir, `${id}${REPLAY_SUFFIX}`);
1784
- this.metaFile = join3(dir, `${id}${META_SUFFIX}`);
2215
+ this.file = join4(dir, `${id}${REPLAY_SUFFIX}`);
2216
+ this.metaFile = join4(dir, `${id}${META_SUFFIX}`);
1785
2217
  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 };
1786
2218
  writeFileSync(this.file, "");
1787
2219
  this.save();
@@ -1954,10 +2386,10 @@ if (process2.env[RECORDINGS_DIR_ENV]) {
1954
2386
  var RECORDER_ENV = "TAMSENSE_RECORDER";
1955
2387
  function bundledRecorder() {
1956
2388
  const path = fileURLToPath2(new URL("./recorder.cjs", import.meta.url));
1957
- return existsSync2(path) ? path : null;
2389
+ return existsSync3(path) ? path : null;
1958
2390
  }
1959
2391
  function recordingsFolder(home, runId) {
1960
- return join4(home, ".tamsense", "recordings", `run-${runId}`);
2392
+ return join5(home, ".tamsense", "recordings", `run-${runId}`);
1961
2393
  }
1962
2394
  function nodeOptionsWithRecorder(recorder, nodeOptions) {
1963
2395
  return [nodeOptions?.trim(), `--require "${recorder.replaceAll("\\", "/")}"`].filter(Boolean).join(" ");
@@ -1984,7 +2416,7 @@ function describeDevice(identity) {
1984
2416
  }
1985
2417
 
1986
2418
  // src/system.ts
1987
- import { spawn as spawn2 } from "child_process";
2419
+ import { spawn as spawn3 } from "child_process";
1988
2420
  import { hostname } from "os";
1989
2421
  import { delimiter as delimiter2 } from "path";
1990
2422
  import { setTimeout as wait } from "timers/promises";
@@ -2008,7 +2440,7 @@ function browserCommand(url, platform) {
2008
2440
  }
2009
2441
  function openBrowser(url, platform = process.platform) {
2010
2442
  const [command, args] = browserCommand(url, platform);
2011
- const child = spawn2(command, args, { detached: true, stdio: "ignore" });
2443
+ const child = spawn3(command, args, { detached: true, stdio: "ignore" });
2012
2444
  child.on("error", () => {
2013
2445
  });
2014
2446
  child.unref();
@@ -2028,7 +2460,7 @@ async function sleep(ms, signal) {
2028
2460
  }
2029
2461
 
2030
2462
  // src/update.ts
2031
- import { spawn as spawn3 } from "child_process";
2463
+ import { spawn as spawn4 } from "child_process";
2032
2464
  import { readFileSync as readFileSync2 } from "fs";
2033
2465
  import process3 from "process";
2034
2466
  var PACKAGE_NAME = "@tamsensedev/runner";
@@ -2083,8 +2515,8 @@ function relaunchArgs(argv) {
2083
2515
  }
2084
2516
  function relaunch(argv, env) {
2085
2517
  const isWindows = process3.platform === "win32";
2086
- return new Promise((resolve, reject) => {
2087
- const child = spawn3(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
2518
+ return new Promise((resolve2, reject) => {
2519
+ const child = spawn4(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
2088
2520
  const ignoreInterrupt = () => {
2089
2521
  };
2090
2522
  process3.on("SIGINT", ignoreInterrupt);
@@ -2094,7 +2526,7 @@ function relaunch(argv, env) {
2094
2526
  });
2095
2527
  child.on("close", (code) => {
2096
2528
  process3.off("SIGINT", ignoreInterrupt);
2097
- resolve(code ?? 1);
2529
+ resolve2(code ?? 1);
2098
2530
  });
2099
2531
  });
2100
2532
  }
@@ -2140,37 +2572,11 @@ function feedQueue(send) {
2140
2572
  };
2141
2573
  }
2142
2574
 
2143
- // src/lock.ts
2144
- function keyedLock() {
2145
- const tails = /* @__PURE__ */ new Map();
2146
- return {
2147
- async run(key, work2) {
2148
- const previous = tails.get(key) ?? Promise.resolve();
2149
- let release = () => {
2150
- };
2151
- const done = new Promise((resolve) => {
2152
- release = resolve;
2153
- });
2154
- const tail = previous.then(() => done);
2155
- tails.set(key, tail);
2156
- await previous;
2157
- try {
2158
- return await work2();
2159
- } finally {
2160
- release();
2161
- if (tails.get(key) === tail) {
2162
- tails.delete(key);
2163
- }
2164
- }
2165
- }
2166
- };
2167
- }
2168
-
2169
2575
  // src/recordings.ts
2170
2576
  import { randomUUID } from "crypto";
2171
2577
  import { createReadStream } from "fs";
2172
2578
  import { readdir, readFile as readFile3, stat, writeFile as writeFile3 } from "fs/promises";
2173
- import { basename as basename2, dirname as dirname4, extname, isAbsolute, join as join5 } from "path";
2579
+ import { basename as basename2, dirname as dirname4, extname, isAbsolute, join as join6 } from "path";
2174
2580
  import { createInterface as createInterface2 } from "readline";
2175
2581
  import { Readable } from "stream";
2176
2582
  import { promisify } from "util";
@@ -2242,12 +2648,12 @@ async function readMeta(file) {
2242
2648
  }
2243
2649
  async function metaFor(kind, file) {
2244
2650
  if (kind === "replay") {
2245
- return readMeta(join5(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
2651
+ return readMeta(join6(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
2246
2652
  }
2247
2653
  const folder = basename2(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
2248
2654
  for (const name of await readdir(folder).catch(() => [])) {
2249
2655
  if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
2250
- const meta = await readMeta(join5(folder, name));
2656
+ const meta = await readMeta(join6(folder, name));
2251
2657
  if (meta?.video === file) {
2252
2658
  return meta;
2253
2659
  }
@@ -2257,33 +2663,33 @@ async function metaFor(kind, file) {
2257
2663
  }
2258
2664
  async function ledger(dir) {
2259
2665
  try {
2260
- return new Set(JSON.parse(await readFile3(join5(dir, LEDGER_FILE), "utf8")));
2666
+ return new Set(JSON.parse(await readFile3(join6(dir, LEDGER_FILE), "utf8")));
2261
2667
  } catch {
2262
2668
  return /* @__PURE__ */ new Set();
2263
2669
  }
2264
2670
  }
2265
2671
  async function remember(dir, file) {
2266
- const done = await ledger(dir);
2267
- done.add(file);
2268
- await writeFile3(join5(dir, LEDGER_FILE), JSON.stringify([...done]));
2672
+ const done2 = await ledger(dir);
2673
+ done2.add(file);
2674
+ await writeFile3(join6(dir, LEDGER_FILE), JSON.stringify([...done2]));
2269
2675
  }
2270
2676
  async function nonEmpty(file) {
2271
2677
  return file !== null && ((await stat(file).catch(() => null))?.size ?? 0) > 0;
2272
2678
  }
2273
2679
  async function pendingRecordings(dir) {
2274
- const done = await ledger(dir);
2680
+ const done2 = await ledger(dir);
2275
2681
  const names = (await readdir(dir).catch(() => [])).sort();
2276
2682
  const pending = [];
2277
2683
  for (const name of names) {
2278
2684
  if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
2279
2685
  continue;
2280
2686
  }
2281
- const meta = await readMeta(join5(dir, name));
2282
- const replay = join5(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
2283
- if (!done.has(replay) && await nonEmpty(replay)) {
2687
+ const meta = await readMeta(join6(dir, name));
2688
+ const replay = join6(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
2689
+ if (!done2.has(replay) && await nonEmpty(replay)) {
2284
2690
  pending.push({ kind: "replay", file: replay, meta });
2285
2691
  }
2286
- if (meta?.video && !done.has(meta.video) && await nonEmpty(meta.video)) {
2692
+ if (meta?.video && !done2.has(meta.video) && await nonEmpty(meta.video)) {
2287
2693
  pending.push({ kind: "video", file: meta.video, meta });
2288
2694
  }
2289
2695
  }
@@ -2421,149 +2827,6 @@ async function readScreenshot(request) {
2421
2827
  return { name: basename3(request.path), ...caption, contentType, data };
2422
2828
  }
2423
2829
 
2424
- // src/workspace.ts
2425
- import { existsSync as existsSync3 } from "fs";
2426
- import { rm as rm3 } from "fs/promises";
2427
- import { join as join6 } from "path";
2428
-
2429
- // src/git.ts
2430
- import { Buffer } from "buffer";
2431
- import { spawn as spawn4 } from "child_process";
2432
- var GITHUB_ORIGIN = "https://github.com/";
2433
- function githubAuthEnv(token) {
2434
- const credentials = Buffer.from(`x-access-token:${token}`).toString("base64");
2435
- return {
2436
- GIT_CONFIG_COUNT: "1",
2437
- GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
2438
- GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`,
2439
- GIT_TERMINAL_PROMPT: "0"
2440
- };
2441
- }
2442
- function runGit(args, options) {
2443
- const env = { ...process.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
2444
- return new Promise((resolve, reject) => {
2445
- const child = spawn4("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
2446
- let stdout = "";
2447
- let stderr = "";
2448
- child.stdout.on("data", (chunk) => {
2449
- stdout += chunk.toString();
2450
- });
2451
- child.stderr.on("data", (chunk) => {
2452
- stderr += chunk.toString();
2453
- });
2454
- child.on("error", reject);
2455
- child.on("close", (code) => resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
2456
- });
2457
- }
2458
- async function git(args, options) {
2459
- const result = await runGit(args, options);
2460
- if (result.code !== 0) {
2461
- throw new Error(`git ${args[0]} failed: ${result.stderr || result.stdout || `exit code ${result.code}`}`);
2462
- }
2463
- return result.stdout;
2464
- }
2465
-
2466
- // src/workspace.ts
2467
- var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
2468
- var cacheLock = keyedLock();
2469
- function githubRemoteUrl(fullName) {
2470
- return `https://github.com/${fullName}.git`;
2471
- }
2472
- function tamsenseFolder(home, ...parts) {
2473
- return join6(home, ".tamsense", ...parts);
2474
- }
2475
- function folderNames(repositories) {
2476
- const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
2477
- const counts = /* @__PURE__ */ new Map();
2478
- for (const repository of repositories) {
2479
- counts.set(shortName(repository.fullName), (counts.get(shortName(repository.fullName)) ?? 0) + 1);
2480
- }
2481
- return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
2482
- }
2483
- async function refreshCache(cache, repository, token, tools) {
2484
- if (existsSync3(join6(cache, ".git"))) {
2485
- await git(["fetch", "--prune", "origin"], { cwd: cache, token });
2486
- return;
2487
- }
2488
- await git(["clone", "--no-checkout", tools.remoteUrl(repository.fullName), cache], { cwd: tools.home, token });
2489
- }
2490
- async function removeWorktree(cache, path) {
2491
- await runGit(["worktree", "remove", "--force", path], { cwd: cache });
2492
- await rm3(path, { recursive: true, force: true });
2493
- await git(["worktree", "prune"], { cwd: cache });
2494
- }
2495
- async function baseRef(cache, job, repository) {
2496
- const pushed = await runGit(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${job.branch}`], { cwd: cache });
2497
- return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
2498
- }
2499
- async function keptBase(cache, path, job, repository) {
2500
- if (!job.keepWorkspace || !existsSync3(join6(path, ".git"))) {
2501
- return null;
2502
- }
2503
- const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
2504
- if (branch.code !== 0 || branch.stdout !== job.branch) {
2505
- return null;
2506
- }
2507
- return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
2508
- }
2509
- async function prepareWorkspace(job, token, tools) {
2510
- const root = tamsenseFolder(tools.home, "work", `task-${job.taskId}`);
2511
- const folders = folderNames(job.repositories);
2512
- const repositories = [];
2513
- for (const [index, repository] of job.repositories.entries()) {
2514
- const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
2515
- const folder = folders[index] ?? repository.fullName;
2516
- const path = join6(root, folder);
2517
- const baseSha = await cacheLock.run(repository.fullName, async () => {
2518
- await refreshCache(cache, repository, token, tools);
2519
- const kept = await keptBase(cache, path, job, repository);
2520
- if (kept) {
2521
- return kept;
2522
- }
2523
- await removeWorktree(cache, path);
2524
- await git(["worktree", "add", "--force", "-B", job.branch, path, await baseRef(cache, job, repository)], { cwd: cache });
2525
- return git(["rev-parse", "HEAD"], { cwd: path });
2526
- });
2527
- repositories.push({ fullName: repository.fullName, folder, path, baseSha });
2528
- }
2529
- return { root, repositories };
2530
- }
2531
- async function commitIdentityEnv(home) {
2532
- const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
2533
- if (configured.code === 0 && configured.stdout) {
2534
- return {};
2535
- }
2536
- return {
2537
- GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
2538
- GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
2539
- GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
2540
- GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
2541
- };
2542
- }
2543
- async function publishWorkspace(workspace, job, token, identity) {
2544
- const pushed = [];
2545
- for (const repository of workspace.repositories) {
2546
- const options = { cwd: repository.path, env: identity };
2547
- const sha = await cacheLock.run(repository.fullName, async () => {
2548
- await git(["add", "--all"], options);
2549
- const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
2550
- if (hasStaged) {
2551
- await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
2552
- }
2553
- const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
2554
- if (newCommits === 0) {
2555
- return null;
2556
- }
2557
- await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
2558
- return git(["rev-parse", "HEAD"], options);
2559
- });
2560
- if (sha) {
2561
- pushed.push({ repository: repository.fullName, branch: job.branch, sha });
2562
- }
2563
- }
2564
- return pushed;
2565
- }
2566
-
2567
2830
  // src/worker.ts
2568
2831
  var POLL_INTERVAL_MS = 5e3;
2569
2832
  var RETRY_AFTER_ERROR_MS = 15e3;
@@ -2574,7 +2837,7 @@ var FEED_INTERVAL_MS = 1500;
2574
2837
  var FATAL_STATUSES = /* @__PURE__ */ new Set([401, 403]);
2575
2838
  var STOPPED_HERE = "stopped_here";
2576
2839
  var CLOSED_ON_SITE = "closed_on_site";
2577
- function messageOf(error) {
2840
+ function messageOf3(error) {
2578
2841
  return error instanceof Error ? error.message : String(error);
2579
2842
  }
2580
2843
  function isFatal(error) {
@@ -2594,7 +2857,7 @@ async function attachScreenshot(api, job, screenshot, attached) {
2594
2857
  try {
2595
2858
  await api.request("POST", runPath(job.runId, "attachments"), body);
2596
2859
  } catch (error) {
2597
- return `Could not attach the screenshot: ${messageOf(error)}`;
2860
+ return `Could not attach the screenshot: ${messageOf3(error)}`;
2598
2861
  }
2599
2862
  attached.count += 1;
2600
2863
  return "Attached to the task.";
@@ -2604,7 +2867,7 @@ function sessionReporter(api, job, print2) {
2604
2867
  return {
2605
2868
  report: (session) => {
2606
2869
  sending ??= api.request("POST", runPath(job.runId, "session"), session).then(() => {
2607
- }, (error) => print2(`Could not tell TAMsense which Claude took the task: ${messageOf(error)}`));
2870
+ }, (error) => print2(`Could not tell TAMsense which Claude took the task: ${messageOf3(error)}`));
2608
2871
  },
2609
2872
  sent: async () => {
2610
2873
  await sending;
@@ -2638,7 +2901,7 @@ function inbox(job, tools, record, closedOnSite) {
2638
2901
  } catch (error) {
2639
2902
  input.restoreDelivered(ack);
2640
2903
  if (!failing) {
2641
- tools.print(`Could not check for new messages on TAMsense, trying again: ${messageOf(error)}`);
2904
+ tools.print(`Could not check for new messages on TAMsense, trying again: ${messageOf3(error)}`);
2642
2905
  }
2643
2906
  failing = true;
2644
2907
  return;
@@ -2661,7 +2924,7 @@ function inbox(job, tools, record, closedOnSite) {
2661
2924
  record({ kind: "tool", text: switchLine(change), tool: "model", ref: null, parent: null, failed: false });
2662
2925
  }
2663
2926
  },
2664
- (error) => tools.print(`Could not switch the model: ${messageOf(error)}`)
2927
+ (error) => tools.print(`Could not switch the model: ${messageOf3(error)}`)
2665
2928
  );
2666
2929
  }
2667
2930
  }
@@ -2703,6 +2966,35 @@ function askingTools(job, api) {
2703
2966
  }
2704
2967
  return { people, send: (body) => api.request("POST", runPath(job.runId, "questions"), body) };
2705
2968
  }
2969
+ function permissionTools(job, tools) {
2970
+ if (job.stage.kind === "interview" || job.stage.kind === "spec") {
2971
+ return null;
2972
+ }
2973
+ return {
2974
+ send: async (body) => {
2975
+ const reply2 = await tools.api.request("POST", runPath(job.runId, "permissions"), body);
2976
+ if (reply2.decision === "wait") {
2977
+ tools.print(` Waiting for a person on TAMsense to allow: ${body.title || body.action}`);
2978
+ }
2979
+ return reply2;
2980
+ },
2981
+ check: (id) => tools.api.request("GET", permissionPath(job.runId, id)),
2982
+ sleep: tools.sleep
2983
+ };
2984
+ }
2985
+ function knowledgeTools2(job, api) {
2986
+ if (!job.knowledge) {
2987
+ return null;
2988
+ }
2989
+ return {
2990
+ entryIds: knowledgeIds(job.knowledge),
2991
+ moreIds: job.knowledge.more.map((entry) => entry.id),
2992
+ canPropose: canProposeKnowledge(job),
2993
+ propose: (body) => api.request("POST", runPath(job.runId, "knowledge"), body),
2994
+ update: (entryId, body) => api.request("POST", knowledgePath(job.runId, entryId), body),
2995
+ read: (entryId) => api.request("GET", knowledgePath(job.runId, entryId))
2996
+ };
2997
+ }
2706
2998
  async function executeJob(job, tools, signal, input, record) {
2707
2999
  const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
2708
3000
  tools.print("Preparing the code\u2026");
@@ -2724,6 +3016,8 @@ async function executeJob(job, tools, signal, input, record) {
2724
3016
  wantsVerdict: isCheck,
2725
3017
  specWork: specWorkTools2(job, tools.api),
2726
3018
  asking: askingTools(job, tools.api),
3019
+ permissions: permissionTools(job, tools),
3020
+ knowledge: knowledgeTools2(job, tools.api),
2727
3021
  handoffs: job.allowedHandoffs,
2728
3022
  resumeSessionId: job.resumeSessionId,
2729
3023
  model: job.model,
@@ -2795,7 +3089,7 @@ async function sendRecordings(job, tools) {
2795
3089
  }
2796
3090
  }
2797
3091
  function failureReport(error, signal) {
2798
- return signal.reason === STOPPED_HERE ? "The run was stopped on the computer." : `The run stopped on the computer: ${messageOf(error)}`;
3092
+ return signal.reason === STOPPED_HERE ? "The run was stopped on the computer." : `The run stopped on the computer: ${messageOf3(error)}`;
2799
3093
  }
2800
3094
  async function runJob(job, tools, stop) {
2801
3095
  tools.print(`Task #${job.taskId}: ${job.title} (${job.stage.name})`);
@@ -2834,12 +3128,12 @@ async function runJob(job, tools, stop) {
2834
3128
  tools.print("TAMsense closed this run while it was in progress. The result was not sent.");
2835
3129
  return;
2836
3130
  }
2837
- await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf(error)}`));
3131
+ await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf3(error)}`));
2838
3132
  try {
2839
3133
  await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
2840
3134
  tools.print(resultLine(finish2));
2841
3135
  } catch (error) {
2842
- tools.print(`Could not send the result to TAMsense: ${messageOf(error)}`);
3136
+ tools.print(`Could not send the result to TAMsense: ${messageOf3(error)}`);
2843
3137
  }
2844
3138
  }
2845
3139
  async function reportCapabilities(tools, stop) {
@@ -2858,7 +3152,7 @@ async function reportCapabilities(tools, stop) {
2858
3152
  }
2859
3153
  } catch (error) {
2860
3154
  if (!complained && !stop.aborted) {
2861
- tools.print(`Could not tell TAMsense which Claude models this computer has: ${messageOf(error)}`);
3155
+ tools.print(`Could not tell TAMsense which Claude models this computer has: ${messageOf3(error)}`);
2862
3156
  complained = true;
2863
3157
  }
2864
3158
  }
@@ -2866,18 +3160,18 @@ async function reportCapabilities(tools, stop) {
2866
3160
  }
2867
3161
  }
2868
3162
  function jobTools(job, tools, parallel) {
2869
- return parallel > 1 ? { ...tools, print: (line) => tools.print(`[#${job.taskId}] ${line}`) } : tools;
3163
+ return parallel > 1 ? { ...tools, print: (line) => tools.print(`[#${job.taskId} ${job.stage.name}] ${line}`) } : tools;
2870
3164
  }
2871
3165
  async function work(tools, stop, parallel = 1) {
2872
3166
  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.");
2873
3167
  const halt = new AbortController();
2874
3168
  const stopAll = AbortSignal.any([stop, halt.signal]);
2875
3169
  const running = /* @__PURE__ */ new Set();
2876
- const taskLock = keyedLock();
3170
+ const folderLock = keyedLock();
2877
3171
  const reporting = reportCapabilities(tools, stopAll);
2878
3172
  const start = (job) => {
2879
3173
  const runTools = jobTools(job, tools, parallel);
2880
- const task = taskLock.run(String(job.taskId), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf(error)}`)).finally(() => running.delete(task));
3174
+ const task = folderLock.run(stepFolder(job).join("/"), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf3(error)}`)).finally(() => running.delete(task));
2881
3175
  running.add(task);
2882
3176
  };
2883
3177
  try {
@@ -2894,7 +3188,7 @@ async function work(tools, stop, parallel = 1) {
2894
3188
  halt.abort();
2895
3189
  throw error;
2896
3190
  }
2897
- tools.print(`Could not reach TAMsense: ${messageOf(error)}. Trying again soon.`);
3191
+ tools.print(`Could not reach TAMsense: ${messageOf3(error)}. Trying again soon.`);
2898
3192
  await tools.sleep(RETRY_AFTER_ERROR_MS, stopAll);
2899
3193
  continue;
2900
3194
  }