@tamsensedev/runner 0.6.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/cli.js +1351 -425
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { existsSync as
|
|
4
|
+
import { existsSync as existsSync7 } from "fs";
|
|
5
5
|
import { homedir as homedir3 } from "os";
|
|
6
|
-
import
|
|
6
|
+
import process7 from "process";
|
|
7
7
|
|
|
8
8
|
// src/api.ts
|
|
9
9
|
var DEFAULT_SERVER = "https://my.tamsense.com";
|
|
@@ -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
|
|
37
|
-
const code = textOf(
|
|
38
|
-
const message = textOf(
|
|
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((
|
|
190
|
+
var runProcess = (command, args, options) => new Promise((resolve5, 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((resolve2, reject) => {
|
|
|
201
201
|
});
|
|
202
202
|
}
|
|
203
203
|
child.on("error", reject);
|
|
204
|
-
child.on("close", (code) =>
|
|
204
|
+
child.on("close", (code) => resolve5({ 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((
|
|
318
|
-
release =
|
|
317
|
+
const released = new Promise((resolve5) => {
|
|
318
|
+
release = resolve5;
|
|
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 {
|
|
344
|
-
import {
|
|
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(
|
|
404
|
+
function activityLine(tool5, input) {
|
|
406
405
|
const fields = typeof input === "object" && input !== null ? input : {};
|
|
407
|
-
if (
|
|
406
|
+
if (tool5 === "Bash" && typeof fields.command === "string") {
|
|
408
407
|
return `$ ${shorten(fields.command)}`;
|
|
409
408
|
}
|
|
410
|
-
if (FILE_TOOLS.has(
|
|
411
|
-
return `${
|
|
409
|
+
if (FILE_TOOLS.has(tool5) && typeof fields.file_path === "string") {
|
|
410
|
+
return `${tool5} ${shorten(fields.file_path)}`;
|
|
412
411
|
}
|
|
413
|
-
if (
|
|
412
|
+
if (tool5 === "Skill" && typeof fields.skill === "string") {
|
|
414
413
|
return `Skill ${shorten(fields.skill)}`;
|
|
415
414
|
}
|
|
416
|
-
if (HELPER_TOOLS.has(
|
|
415
|
+
if (HELPER_TOOLS.has(tool5) && typeof fields.subagent_type === "string") {
|
|
417
416
|
return `Agent ${shorten(fields.subagent_type)}`;
|
|
418
417
|
}
|
|
419
|
-
return
|
|
418
|
+
return tool5;
|
|
420
419
|
}
|
|
421
420
|
function usageOf(modelUsage) {
|
|
422
421
|
const entries = Object.values(modelUsage);
|
|
@@ -440,6 +439,37 @@ 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
|
+
var FOLDER_CHECKS_PATH = "/api/runner/folders/checks";
|
|
454
|
+
function folderCheckPath(folderId) {
|
|
455
|
+
return `/api/runner/folders/${folderId}`;
|
|
456
|
+
}
|
|
457
|
+
function runPath(runId, action) {
|
|
458
|
+
return `/api/runner/runs/${runId}/${action}`;
|
|
459
|
+
}
|
|
460
|
+
function resultFilePath(runId, resultId) {
|
|
461
|
+
return `/api/runner/runs/${runId}/results/${resultId}`;
|
|
462
|
+
}
|
|
463
|
+
function knowledgePath(runId, entryId) {
|
|
464
|
+
return `/api/runner/runs/${runId}/knowledge/${entryId}`;
|
|
465
|
+
}
|
|
466
|
+
function permissionPath(runId, permissionId) {
|
|
467
|
+
return `/api/runner/runs/${runId}/permissions/${permissionId}`;
|
|
468
|
+
}
|
|
469
|
+
function recordingUploadPath(runId, upload, part) {
|
|
470
|
+
return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
443
473
|
// src/feed.ts
|
|
444
474
|
var MAX_TEXT_LENGTH = 8e3;
|
|
445
475
|
var MAX_RESULT_LENGTH = 2e3;
|
|
@@ -459,29 +489,29 @@ function textField(fields, name) {
|
|
|
459
489
|
return typeof value === "string" && value.trim() ? value : null;
|
|
460
490
|
}
|
|
461
491
|
function toolDraft(block, parent) {
|
|
462
|
-
const
|
|
492
|
+
const tool5 = (block.name ?? "tool").replace(MCP_PREFIX, "");
|
|
463
493
|
const fields = fieldsOf(block.input);
|
|
464
|
-
const base = { tool:
|
|
465
|
-
if (
|
|
494
|
+
const base = { tool: tool5, ref: block.id ?? null, parent, failed: false };
|
|
495
|
+
if (tool5 === "Skill" && textField(fields, "skill")) {
|
|
466
496
|
return { ...base, kind: "skill", text: clip(textField(fields, "skill"), MAX_SUMMARY_LENGTH) };
|
|
467
497
|
}
|
|
468
|
-
if (HELPER_TOOLS2.has(
|
|
498
|
+
if (HELPER_TOOLS2.has(tool5)) {
|
|
469
499
|
const who = textField(fields, "subagent_type") ?? "general-purpose";
|
|
470
500
|
const what = textField(fields, "description");
|
|
471
501
|
return { ...base, kind: "agent", text: clip(what ? `${who}: ${what}` : who, MAX_SUMMARY_LENGTH) };
|
|
472
502
|
}
|
|
473
|
-
if (
|
|
503
|
+
if (tool5 === "submit_verdict") {
|
|
474
504
|
const verdict = fields.couldNotRun === true ? "Could not run" : fields.passed === true ? "Passed" : "Did not pass";
|
|
475
505
|
return { ...base, kind: "tool", text: clip(`${verdict}. ${textField(fields, "report") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
476
506
|
}
|
|
477
|
-
if (
|
|
507
|
+
if (tool5 === "hand_off") {
|
|
478
508
|
return { ...base, kind: "tool", text: clip(`Hand off to ${textField(fields, "to") ?? "?"}: ${textField(fields, "note") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
479
509
|
}
|
|
480
|
-
if (
|
|
481
|
-
return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[
|
|
510
|
+
if (tool5 in ATTACH_LABELS) {
|
|
511
|
+
return { ...base, kind: "tool", text: clip(`${ATTACH_LABELS[tool5]}: ${textField(fields, "title") ?? textField(fields, "path") ?? ""}`, MAX_SUMMARY_LENGTH) };
|
|
482
512
|
}
|
|
483
513
|
const command = textField(fields, "command");
|
|
484
|
-
const summary =
|
|
514
|
+
const summary = tool5 === "Bash" && command ? `$ ${command}` : activityLine(tool5, block.input);
|
|
485
515
|
return { ...base, kind: "tool", text: clip(summary, MAX_SUMMARY_LENGTH) };
|
|
486
516
|
}
|
|
487
517
|
function resultText(content) {
|
|
@@ -493,6 +523,10 @@ function resultText(content) {
|
|
|
493
523
|
}
|
|
494
524
|
return content.map((part) => part.type === "text" ? part.text ?? "" : part.type === "image" ? "[image]" : "").filter(Boolean).join("\n");
|
|
495
525
|
}
|
|
526
|
+
function helperEndDraft(ref, parent, end, text2) {
|
|
527
|
+
const stopped = end === "stopped";
|
|
528
|
+
return { kind: "result", text: stopped ? "" : clip(text2, MAX_RESULT_LENGTH), tool: stopped ? HELPER_STOPPED : null, ref, parent, failed: end === "failed" };
|
|
529
|
+
}
|
|
496
530
|
function feedOf(message) {
|
|
497
531
|
if (message.type !== "assistant" && message.type !== "user") {
|
|
498
532
|
return [];
|
|
@@ -561,6 +595,77 @@ function chooseHandoff(targets, request) {
|
|
|
561
595
|
return { handoff: { toColumnId: edge.columnId, outcome: edge.outcome, note } };
|
|
562
596
|
}
|
|
563
597
|
|
|
598
|
+
// src/helpers.ts
|
|
599
|
+
function helperTasks() {
|
|
600
|
+
const byRef = /* @__PURE__ */ new Map();
|
|
601
|
+
const refByTask = /* @__PURE__ */ new Map();
|
|
602
|
+
const parents = /* @__PURE__ */ new Map();
|
|
603
|
+
function observe(message) {
|
|
604
|
+
if (message.type !== "system") {
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
if (message.subtype === "task_started" && message.task_type === "local_agent" && message.tool_use_id) {
|
|
608
|
+
byRef.set(message.tool_use_id, { taskId: message.task_id, background: message.is_backgrounded === true, stopped: false, over: false });
|
|
609
|
+
refByTask.set(message.task_id, message.tool_use_id);
|
|
610
|
+
return [];
|
|
611
|
+
}
|
|
612
|
+
if (message.subtype === "task_updated" && message.patch.is_backgrounded !== void 0) {
|
|
613
|
+
const ref2 = refByTask.get(message.task_id);
|
|
614
|
+
const task2 = ref2 ? byRef.get(ref2) : void 0;
|
|
615
|
+
if (task2) {
|
|
616
|
+
task2.background = message.patch.is_backgrounded;
|
|
617
|
+
}
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
if (message.subtype !== "task_notification") {
|
|
621
|
+
return [];
|
|
622
|
+
}
|
|
623
|
+
const ref = message.tool_use_id ?? refByTask.get(message.task_id);
|
|
624
|
+
const task = ref ? byRef.get(ref) : void 0;
|
|
625
|
+
if (!ref || !task || task.over) {
|
|
626
|
+
return [];
|
|
627
|
+
}
|
|
628
|
+
task.stopped = message.status === "stopped";
|
|
629
|
+
if (!task.background) {
|
|
630
|
+
return [];
|
|
631
|
+
}
|
|
632
|
+
task.over = true;
|
|
633
|
+
return [helperEndDraft(ref, parents.get(ref) ?? null, message.status, message.summary)];
|
|
634
|
+
}
|
|
635
|
+
function adjust(draft) {
|
|
636
|
+
if (draft.kind === "agent" && draft.ref) {
|
|
637
|
+
parents.set(draft.ref, draft.parent);
|
|
638
|
+
}
|
|
639
|
+
const task = draft.kind === "result" && draft.ref ? byRef.get(draft.ref) : void 0;
|
|
640
|
+
if (!task || !draft.ref) {
|
|
641
|
+
return [draft];
|
|
642
|
+
}
|
|
643
|
+
if (task.background) {
|
|
644
|
+
return [];
|
|
645
|
+
}
|
|
646
|
+
task.over = true;
|
|
647
|
+
return task.stopped ? [helperEndDraft(draft.ref, draft.parent, "stopped", "")] : [draft];
|
|
648
|
+
}
|
|
649
|
+
async function stop(target, ref) {
|
|
650
|
+
const task = byRef.get(ref);
|
|
651
|
+
if (!task) {
|
|
652
|
+
return "unknown";
|
|
653
|
+
}
|
|
654
|
+
if (task.over || task.stopped) {
|
|
655
|
+
return "finished";
|
|
656
|
+
}
|
|
657
|
+
await target.stopTask(task.taskId);
|
|
658
|
+
return "stopped";
|
|
659
|
+
}
|
|
660
|
+
return { observe, adjust, stop };
|
|
661
|
+
}
|
|
662
|
+
function helperStoppedNote(stop) {
|
|
663
|
+
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.`;
|
|
664
|
+
}
|
|
665
|
+
function helperStopRequest(stop) {
|
|
666
|
+
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.`;
|
|
667
|
+
}
|
|
668
|
+
|
|
564
669
|
// src/input.ts
|
|
565
670
|
var DEFAULT_MODEL = "default";
|
|
566
671
|
var QUIET_MS = 1e3;
|
|
@@ -571,8 +676,11 @@ function userMessage(text2) {
|
|
|
571
676
|
function humanText(message) {
|
|
572
677
|
return `Message from ${message.authorName}: ${message.text}`;
|
|
573
678
|
}
|
|
679
|
+
function priorityOf(mode) {
|
|
680
|
+
return mode === "steer" ? "now" : "next";
|
|
681
|
+
}
|
|
574
682
|
function humanMessage(message) {
|
|
575
|
-
return { ...userMessage(humanText(message)), priority: message.mode
|
|
683
|
+
return { ...userMessage(humanText(message)), priority: priorityOf(message.mode), client_composed: true };
|
|
576
684
|
}
|
|
577
685
|
function switchLine(change) {
|
|
578
686
|
const model = change.model === DEFAULT_MODEL ? "the default model" : change.model;
|
|
@@ -601,6 +709,7 @@ function inputChannel(options = {}) {
|
|
|
601
709
|
let wake = null;
|
|
602
710
|
let timers = [];
|
|
603
711
|
let applySwitch2 = null;
|
|
712
|
+
let applyStop = null;
|
|
604
713
|
let waitingSwitch = null;
|
|
605
714
|
function wakeUp() {
|
|
606
715
|
wake?.();
|
|
@@ -653,8 +762,8 @@ function inputChannel(options = {}) {
|
|
|
653
762
|
}
|
|
654
763
|
const next = queue.shift();
|
|
655
764
|
if (!next) {
|
|
656
|
-
await new Promise((
|
|
657
|
-
wake =
|
|
765
|
+
await new Promise((resolve5) => {
|
|
766
|
+
wake = resolve5;
|
|
658
767
|
});
|
|
659
768
|
continue;
|
|
660
769
|
}
|
|
@@ -714,11 +823,14 @@ function inputChannel(options = {}) {
|
|
|
714
823
|
onSwitch(apply) {
|
|
715
824
|
applySwitch2 = apply;
|
|
716
825
|
if (waitingSwitch) {
|
|
717
|
-
const { change, resolve:
|
|
826
|
+
const { change, resolve: resolve5 } = waitingSwitch;
|
|
718
827
|
waitingSwitch = null;
|
|
719
|
-
void apply(change).then(() =>
|
|
828
|
+
void apply(change).then(() => resolve5(true), () => resolve5(false));
|
|
720
829
|
}
|
|
721
830
|
},
|
|
831
|
+
onStopHelper(stop) {
|
|
832
|
+
applyStop = stop;
|
|
833
|
+
},
|
|
722
834
|
close,
|
|
723
835
|
push(message) {
|
|
724
836
|
if (closed || seen.has(message.id)) {
|
|
@@ -729,6 +841,17 @@ function inputChannel(options = {}) {
|
|
|
729
841
|
wakeUp();
|
|
730
842
|
return true;
|
|
731
843
|
},
|
|
844
|
+
note(text2, mode) {
|
|
845
|
+
if (closed) {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
queue.push({ ids: [], message: { ...userMessage(text2), priority: priorityOf(mode) } });
|
|
849
|
+
wakeUp();
|
|
850
|
+
return true;
|
|
851
|
+
},
|
|
852
|
+
async stopHelper(ref) {
|
|
853
|
+
return closed || !applyStop ? "unknown" : await applyStop(ref);
|
|
854
|
+
},
|
|
732
855
|
async checking(work2) {
|
|
733
856
|
const epoch = idle ? idleEpoch : null;
|
|
734
857
|
inFlight += 1;
|
|
@@ -763,8 +886,8 @@ function inputChannel(options = {}) {
|
|
|
763
886
|
const earlier = waitingSwitch;
|
|
764
887
|
earlier?.resolve(false);
|
|
765
888
|
const merged = { model: change.model ?? earlier?.change.model ?? null, effort: change.effort ?? earlier?.change.effort ?? null };
|
|
766
|
-
return new Promise((
|
|
767
|
-
waitingSwitch = { change: merged, resolve:
|
|
889
|
+
return new Promise((resolve5) => {
|
|
890
|
+
waitingSwitch = { change: merged, resolve: resolve5 };
|
|
768
891
|
});
|
|
769
892
|
}
|
|
770
893
|
};
|
|
@@ -927,8 +1050,8 @@ function messageOf2(error) {
|
|
|
927
1050
|
async function decisionOf(tools, first, signal) {
|
|
928
1051
|
const now = tools.now ?? Date.now;
|
|
929
1052
|
const giveUpAt = Date.parse(first.waitUntil) + SITE_SILENCE_MS;
|
|
930
|
-
let
|
|
931
|
-
while (
|
|
1053
|
+
let reply3 = first;
|
|
1054
|
+
while (reply3.decision === "wait") {
|
|
932
1055
|
await tools.sleep(CHECK_INTERVAL_MS, signal);
|
|
933
1056
|
if (signal.aborted) {
|
|
934
1057
|
return STOPPED_TEXT2;
|
|
@@ -936,9 +1059,9 @@ async function decisionOf(tools, first, signal) {
|
|
|
936
1059
|
if (now() > giveUpAt) {
|
|
937
1060
|
return SITE_SILENT_TEXT;
|
|
938
1061
|
}
|
|
939
|
-
|
|
1062
|
+
reply3 = await tools.check(reply3.id).catch(() => reply3);
|
|
940
1063
|
}
|
|
941
|
-
return
|
|
1064
|
+
return reply3;
|
|
942
1065
|
}
|
|
943
1066
|
function askPermission(tools, lastWords, folder) {
|
|
944
1067
|
return async (toolName, input, options) => {
|
|
@@ -959,106 +1082,31 @@ function askPermission(tools, lastWords, folder) {
|
|
|
959
1082
|
} catch (error) {
|
|
960
1083
|
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
1084
|
}
|
|
962
|
-
const
|
|
963
|
-
if (typeof
|
|
964
|
-
return deny(
|
|
1085
|
+
const reply3 = await decisionOf(tools, first, options.signal);
|
|
1086
|
+
if (typeof reply3 === "string") {
|
|
1087
|
+
return deny(reply3);
|
|
965
1088
|
}
|
|
966
|
-
return
|
|
1089
|
+
return reply3.decision === "allow" ? { behavior: "allow", updatedInput: input } : deny(reply3.message);
|
|
967
1090
|
};
|
|
968
1091
|
}
|
|
969
1092
|
var denyWhenAsked = async () => deny(NOBODY_TO_ASK);
|
|
970
1093
|
|
|
971
|
-
// src/
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
}
|
|
975
|
-
function text(value) {
|
|
976
|
-
return typeof value === "string" ? value.trim() : "";
|
|
977
|
-
}
|
|
978
|
-
function texts(value) {
|
|
979
|
-
return Array.isArray(value) ? value.map(text).filter(Boolean) : [];
|
|
980
|
-
}
|
|
981
|
-
function records(value) {
|
|
982
|
-
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
983
|
-
}
|
|
984
|
-
function section(title, lines) {
|
|
985
|
-
return lines.length > 0 ? `${title}
|
|
986
|
-
${lines.join("\n")}` : null;
|
|
987
|
-
}
|
|
988
|
-
function criterionLine(value) {
|
|
989
|
-
if (typeof value === "string") {
|
|
990
|
-
return value.trim() ? `- ${value.trim()}` : null;
|
|
991
|
-
}
|
|
992
|
-
if (!isRecord(value) || !text(value.text)) {
|
|
993
|
-
return null;
|
|
994
|
-
}
|
|
995
|
-
const kind = ["main", "edge", "keep"].includes(value.kind) ? `(${value.kind})` : "";
|
|
996
|
-
const head = [text(value.id), kind].filter(Boolean).join(" ");
|
|
997
|
-
return `- ${head ? `${head}: ` : ""}${text(value.text)}`;
|
|
998
|
-
}
|
|
999
|
-
function assumptionLine(value) {
|
|
1000
|
-
if (typeof value === "string") {
|
|
1001
|
-
return value.trim() ? `- ${value.trim()}` : null;
|
|
1002
|
-
}
|
|
1003
|
-
if (!isRecord(value) || !text(value.text)) {
|
|
1004
|
-
return null;
|
|
1005
|
-
}
|
|
1006
|
-
return `- ${text(value.text)}${text(value.reason) ? ` (${text(value.reason)})` : ""}`;
|
|
1007
|
-
}
|
|
1008
|
-
function changeLines(change, index) {
|
|
1009
|
-
const action = ["create", "modify", "delete"].includes(change.action) ? ` (${change.action})` : "";
|
|
1010
|
-
const [produces, consumes, acceptance] = [texts(change.produces), texts(change.consumes), texts(change.acceptance)];
|
|
1011
|
-
return [
|
|
1012
|
-
`${index + 1}. ${text(change.repo) ? `${text(change.repo)} ` : ""}${text(change.file)}${action}: ${text(change.description)}`,
|
|
1013
|
-
...produces.length > 0 ? [` Produces: ${produces.join("; ")}`] : [],
|
|
1014
|
-
...consumes.length > 0 ? [` Consumes: ${consumes.join("; ")}`] : [],
|
|
1015
|
-
...acceptance.length > 0 ? [` Makes true: ${acceptance.join(", ")}`] : []
|
|
1016
|
-
];
|
|
1017
|
-
}
|
|
1018
|
-
var CODE_USE_TEXT = { reuse: "reuse", change: "change", keep: "do not touch" };
|
|
1019
|
-
var RISK_LEVELS = ["low", "medium", "high"];
|
|
1020
|
-
function present(lines) {
|
|
1021
|
-
return lines.filter((line) => line !== null);
|
|
1022
|
-
}
|
|
1023
|
-
function specPromptText(spec) {
|
|
1024
|
-
if (!isRecord(spec)) {
|
|
1025
|
-
return null;
|
|
1026
|
-
}
|
|
1027
|
-
const structured = Array.isArray(spec.outOfScope) || isRecord(spec.testPlan);
|
|
1028
|
-
const plan = isRecord(spec.testPlan) ? spec.testPlan : {};
|
|
1029
|
-
const risks = records(spec.risks).filter((item) => text(item.risk));
|
|
1030
|
-
const riskLevel = RISK_LEVELS.includes(spec.riskLevel) ? spec.riskLevel : null;
|
|
1031
|
-
return [
|
|
1032
|
-
text(spec.goal) && `Goal: ${text(spec.goal)}`,
|
|
1033
|
-
text(spec.context) && `${structured ? "Summary" : "Context"}: ${text(spec.context)}`,
|
|
1034
|
-
section("In scope:", texts(spec.inScope).map((item) => `- ${item}`)),
|
|
1035
|
-
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"})`)),
|
|
1037
|
-
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
|
-
section("Changes, in order:", records(spec.changes).filter((item) => text(item.file)).flatMap(changeLines)),
|
|
1039
|
-
section("Acceptance criteria:", present(Array.isArray(spec.acceptance) ? spec.acceptance.map(criterionLine) : [])),
|
|
1040
|
-
section("Test plan:", [
|
|
1041
|
-
...records(plan.automated).filter((item) => text(item.command)).map((item) => `- Run in ${text(item.repo) || "the repository"}: ${text(item.command)}${text(item.expect) ? `. Passing: ${text(item.expect)}` : ""}`),
|
|
1042
|
-
...records(plan.newTests).filter((item) => text(item.seam)).map((item) => `- New test at ${text(item.seam)}${texts(item.covers).length > 0 ? `, covers ${texts(item.covers).join(", ")}` : ""}`),
|
|
1043
|
-
...records(plan.manual).filter((item) => text(item.steps)).map((item) => `- By hand${text(item.page) ? ` on ${text(item.page)}` : ""}: ${text(item.steps)}`)
|
|
1044
|
-
]),
|
|
1045
|
-
risks.length > 0 ? section(`Risks${riskLevel ? ` (risk level ${riskLevel})` : ""}:`, risks.map((item) => `- ${text(item.risk)}${text(item.mitigation) ? `. Mitigation: ${text(item.mitigation)}` : ""}`)) : riskLevel && `Risk level: ${riskLevel}`,
|
|
1046
|
-
section("Assumptions:", present(Array.isArray(spec.assumptions) ? spec.assumptions.map(assumptionLine) : [])),
|
|
1047
|
-
section("Open questions (not answered yet):", records(spec.openQuestions).filter((item) => text(item.question)).map((item) => `- ${text(item.question)}`))
|
|
1048
|
-
].filter(Boolean).join("\n\n");
|
|
1049
|
-
}
|
|
1094
|
+
// src/localWorkspace.ts
|
|
1095
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1096
|
+
import { join as join4, resolve as resolve3 } from "path";
|
|
1050
1097
|
|
|
1051
|
-
// src/
|
|
1052
|
-
import { existsSync as existsSync2 } from "fs";
|
|
1053
|
-
import {
|
|
1054
|
-
import
|
|
1098
|
+
// src/folders.ts
|
|
1099
|
+
import { existsSync as existsSync2, realpathSync, statSync } from "fs";
|
|
1100
|
+
import { basename, isAbsolute, join as join2, relative, resolve } from "path";
|
|
1101
|
+
import process3 from "process";
|
|
1055
1102
|
|
|
1056
1103
|
// src/git.ts
|
|
1057
|
-
import { Buffer } from "buffer";
|
|
1104
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
1058
1105
|
import { spawn as spawn2 } from "child_process";
|
|
1106
|
+
import process2 from "process";
|
|
1059
1107
|
var GITHUB_ORIGIN = "https://github.com/";
|
|
1060
1108
|
function githubAuthEnv(token) {
|
|
1061
|
-
const credentials =
|
|
1109
|
+
const credentials = Buffer2.from(`x-access-token:${token}`).toString("base64");
|
|
1062
1110
|
return {
|
|
1063
1111
|
GIT_CONFIG_COUNT: "1",
|
|
1064
1112
|
GIT_CONFIG_KEY_0: `http.${GITHUB_ORIGIN}.extraheader`,
|
|
@@ -1066,10 +1114,13 @@ function githubAuthEnv(token) {
|
|
|
1066
1114
|
GIT_TERMINAL_PROMPT: "0"
|
|
1067
1115
|
};
|
|
1068
1116
|
}
|
|
1117
|
+
function personalGitEnv(env = process2.env) {
|
|
1118
|
+
return { GIT_TERMINAL_PROMPT: "0", ...env.GIT_SSH_COMMAND ? {} : { GIT_SSH_COMMAND: "ssh -o BatchMode=yes" } };
|
|
1119
|
+
}
|
|
1069
1120
|
function runGit(args, options) {
|
|
1070
|
-
const env = { ...
|
|
1071
|
-
return new Promise((
|
|
1072
|
-
const child = spawn2("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1121
|
+
const env = { ...process2.env, ...options.env, ...options.token ? githubAuthEnv(options.token) : {} };
|
|
1122
|
+
return new Promise((resolve5, reject) => {
|
|
1123
|
+
const child = spawn2("git", args, { cwd: options.cwd, env, stdio: ["ignore", "pipe", "pipe"], ...options.timeoutMs ? { timeout: options.timeoutMs } : {} });
|
|
1073
1124
|
let stdout = "";
|
|
1074
1125
|
let stderr = "";
|
|
1075
1126
|
child.stdout.on("data", (chunk) => {
|
|
@@ -1079,7 +1130,7 @@ function runGit(args, options) {
|
|
|
1079
1130
|
stderr += chunk.toString();
|
|
1080
1131
|
});
|
|
1081
1132
|
child.on("error", reject);
|
|
1082
|
-
child.on("close", (code) =>
|
|
1133
|
+
child.on("close", (code) => resolve5({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }));
|
|
1083
1134
|
});
|
|
1084
1135
|
}
|
|
1085
1136
|
async function git(args, options) {
|
|
@@ -1090,6 +1141,127 @@ async function git(args, options) {
|
|
|
1090
1141
|
return result.stdout;
|
|
1091
1142
|
}
|
|
1092
1143
|
|
|
1144
|
+
// src/folders.ts
|
|
1145
|
+
var FIRST_REMOTE = "origin";
|
|
1146
|
+
var USUAL_BASES = ["main", "master"];
|
|
1147
|
+
var CHECKS_INTERVAL_MS = 5e3;
|
|
1148
|
+
var WEB_REMOTE = /^[a-z][\w+.-]*:\/\//i;
|
|
1149
|
+
var NETWORK_REMOTE = /^(?!file:)[a-z][\w+.-]*:\/\/|^[^/@]+@[^/:]+:/i;
|
|
1150
|
+
function problem(text2) {
|
|
1151
|
+
return { ok: false, problem: text2 };
|
|
1152
|
+
}
|
|
1153
|
+
async function gitText(args, cwd) {
|
|
1154
|
+
const result = await runGit(args, { cwd });
|
|
1155
|
+
return result.code === 0 && result.stdout ? result.stdout : null;
|
|
1156
|
+
}
|
|
1157
|
+
function expandHome(path, home) {
|
|
1158
|
+
const trimmed = path.trim();
|
|
1159
|
+
return trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\") ? join2(home, trimmed.slice(1)) : trimmed;
|
|
1160
|
+
}
|
|
1161
|
+
function remoteWithoutSecrets(url) {
|
|
1162
|
+
if (!WEB_REMOTE.test(url)) {
|
|
1163
|
+
return url;
|
|
1164
|
+
}
|
|
1165
|
+
try {
|
|
1166
|
+
const parsed = new URL(url);
|
|
1167
|
+
if (parsed.username || parsed.password) {
|
|
1168
|
+
parsed.username = "";
|
|
1169
|
+
parsed.password = "";
|
|
1170
|
+
}
|
|
1171
|
+
return parsed.toString().replace(/\/$/, "");
|
|
1172
|
+
} catch {
|
|
1173
|
+
return url;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
function repositoryName(remoteUrl, folder) {
|
|
1177
|
+
const parts = remoteUrl && NETWORK_REMOTE.test(remoteUrl) ? remoteUrl.replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).filter(Boolean) : [];
|
|
1178
|
+
return parts.length >= 3 ? `${parts.at(-2)}/${parts.at(-1)}` : basename(folder);
|
|
1179
|
+
}
|
|
1180
|
+
async function remoteOf(folder) {
|
|
1181
|
+
const remotes = (await gitText(["remote"], folder))?.split("\n").map((line) => line.trim()).filter(Boolean) ?? [];
|
|
1182
|
+
return remotes.includes(FIRST_REMOTE) ? FIRST_REMOTE : remotes[0] ?? null;
|
|
1183
|
+
}
|
|
1184
|
+
async function hasRef(folder, ref) {
|
|
1185
|
+
return (await runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: folder })).code === 0;
|
|
1186
|
+
}
|
|
1187
|
+
async function baseBranchOf(folder, remote, current) {
|
|
1188
|
+
const remoteHead = remote ? await gitText(["symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`], folder) : null;
|
|
1189
|
+
if (remoteHead?.startsWith(`${remote}/`)) {
|
|
1190
|
+
return remoteHead.slice(remote.length + 1);
|
|
1191
|
+
}
|
|
1192
|
+
for (const name of USUAL_BASES) {
|
|
1193
|
+
if (await hasRef(folder, `refs/heads/${name}`) || remote && await hasRef(folder, `refs/remotes/${remote}/${name}`)) {
|
|
1194
|
+
return name;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return current;
|
|
1198
|
+
}
|
|
1199
|
+
function samePath(first, second) {
|
|
1200
|
+
const [a, b] = [realpathSync.native(first), realpathSync.native(second)];
|
|
1201
|
+
return process3.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
1202
|
+
}
|
|
1203
|
+
function isInside(path, folder) {
|
|
1204
|
+
const between = relative(resolve(folder), resolve(path));
|
|
1205
|
+
return between === "" || !between.startsWith("..") && !isAbsolute(between);
|
|
1206
|
+
}
|
|
1207
|
+
async function inspectFolder(path, home) {
|
|
1208
|
+
const folder = expandHome(path, home);
|
|
1209
|
+
if (!isAbsolute(folder)) {
|
|
1210
|
+
return problem("Enter the full path of the folder, for example /Users/anna/code/shop or C:\\code\\shop.");
|
|
1211
|
+
}
|
|
1212
|
+
if (!existsSync2(folder) || !statSync(folder).isDirectory()) {
|
|
1213
|
+
return problem(`There is no folder ${folder} on this computer.`);
|
|
1214
|
+
}
|
|
1215
|
+
if (isInside(folder, join2(home, ".tamsense"))) {
|
|
1216
|
+
return problem("This is a TAMsense work folder. Choose your own copy of the repository.");
|
|
1217
|
+
}
|
|
1218
|
+
const top = await gitText(["rev-parse", "--show-toplevel"], folder);
|
|
1219
|
+
if (!top) {
|
|
1220
|
+
return problem(`${folder} is not a git repository. Choose the folder of a cloned repository, or run git init there.`);
|
|
1221
|
+
}
|
|
1222
|
+
if (!samePath(top, folder)) {
|
|
1223
|
+
return problem(`${folder} is inside the repository ${resolve(top)}. Add the top folder of the repository instead.`);
|
|
1224
|
+
}
|
|
1225
|
+
if (!await hasRef(folder, "HEAD")) {
|
|
1226
|
+
return problem("The repository has no commits yet. Make the first commit, then check the folder again.");
|
|
1227
|
+
}
|
|
1228
|
+
const remote = await remoteOf(folder);
|
|
1229
|
+
const remoteUrl = remote ? await gitText(["remote", "get-url", remote], folder) : null;
|
|
1230
|
+
const currentBranch = await gitText(["symbolic-ref", "--quiet", "--short", "HEAD"], folder);
|
|
1231
|
+
const defaultBranch = await baseBranchOf(folder, remote, currentBranch);
|
|
1232
|
+
if (!defaultBranch) {
|
|
1233
|
+
return problem("Could not tell the main branch of the repository. Check out the main branch in this folder, then check it again.");
|
|
1234
|
+
}
|
|
1235
|
+
const safeUrl = remoteUrl && remoteWithoutSecrets(remoteUrl);
|
|
1236
|
+
return { ok: true, repository: repositoryName(safeUrl, folder), currentBranch, defaultBranch, remoteUrl: safeUrl };
|
|
1237
|
+
}
|
|
1238
|
+
function reportLine(path, report) {
|
|
1239
|
+
return report.ok ? `Checked the folder ${path}: ${report.repository}, main branch ${report.defaultBranch}.` : `The folder ${path} cannot be used: ${report.problem}`;
|
|
1240
|
+
}
|
|
1241
|
+
async function checkFolders(tools, stop) {
|
|
1242
|
+
let complained = false;
|
|
1243
|
+
while (!stop.aborted) {
|
|
1244
|
+
try {
|
|
1245
|
+
const { checks } = await tools.api.request("POST", FOLDER_CHECKS_PATH);
|
|
1246
|
+
for (const check of checks) {
|
|
1247
|
+
const report = await tools.inspect(check.path).catch((error) => problem(error instanceof Error ? error.message : String(error)));
|
|
1248
|
+
await tools.api.request("POST", folderCheckPath(check.id), report);
|
|
1249
|
+
tools.print(reportLine(check.path, report));
|
|
1250
|
+
}
|
|
1251
|
+
complained = false;
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (!complained && !stop.aborted) {
|
|
1257
|
+
tools.print(`Could not check the folders for TAMsense: ${error instanceof Error ? error.message : String(error)}`);
|
|
1258
|
+
complained = true;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
await tools.sleep(CHECKS_INTERVAL_MS, stop);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1093
1265
|
// src/lock.ts
|
|
1094
1266
|
function keyedLock() {
|
|
1095
1267
|
const tails = /* @__PURE__ */ new Map();
|
|
@@ -1098,8 +1270,8 @@ function keyedLock() {
|
|
|
1098
1270
|
const previous = tails.get(key) ?? Promise.resolve();
|
|
1099
1271
|
let release = () => {
|
|
1100
1272
|
};
|
|
1101
|
-
const done2 = new Promise((
|
|
1102
|
-
release =
|
|
1273
|
+
const done2 = new Promise((resolve5) => {
|
|
1274
|
+
release = resolve5;
|
|
1103
1275
|
});
|
|
1104
1276
|
const tail = previous.then(() => done2);
|
|
1105
1277
|
tails.set(key, tail);
|
|
@@ -1117,13 +1289,16 @@ function keyedLock() {
|
|
|
1117
1289
|
}
|
|
1118
1290
|
|
|
1119
1291
|
// src/workspace.ts
|
|
1292
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1293
|
+
import { rm as rm2 } from "fs/promises";
|
|
1294
|
+
import { join as join3, resolve as resolve2 } from "path";
|
|
1120
1295
|
var FALLBACK_IDENTITY = { name: "TAMsense", email: "noreply@tamsense.com" };
|
|
1121
1296
|
var cacheLock = keyedLock();
|
|
1122
1297
|
function githubRemoteUrl(fullName) {
|
|
1123
1298
|
return `https://github.com/${fullName}.git`;
|
|
1124
1299
|
}
|
|
1125
1300
|
function tamsenseFolder(home, ...parts) {
|
|
1126
|
-
return
|
|
1301
|
+
return join3(home, ".tamsense", ...parts);
|
|
1127
1302
|
}
|
|
1128
1303
|
function folderNames(repositories) {
|
|
1129
1304
|
const shortName = (fullName) => fullName.split("/").pop() ?? fullName;
|
|
@@ -1134,7 +1309,7 @@ function folderNames(repositories) {
|
|
|
1134
1309
|
return repositories.map((repository) => counts.get(shortName(repository.fullName)) === 1 ? shortName(repository.fullName) : repository.fullName.replace("/", "__"));
|
|
1135
1310
|
}
|
|
1136
1311
|
async function refreshCache(cache, repository, token, tools) {
|
|
1137
|
-
if (
|
|
1312
|
+
if (existsSync3(join3(cache, ".git"))) {
|
|
1138
1313
|
await git(["fetch", "--prune", "origin"], { cwd: cache, token });
|
|
1139
1314
|
return;
|
|
1140
1315
|
}
|
|
@@ -1166,7 +1341,7 @@ function branchHolders(porcelain, branch) {
|
|
|
1166
1341
|
}
|
|
1167
1342
|
async function releaseBranch(cache, branch, keep) {
|
|
1168
1343
|
const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: cache });
|
|
1169
|
-
for (const path of branchHolders(listed.stdout, branch).filter((holder) =>
|
|
1344
|
+
for (const path of branchHolders(listed.stdout, branch).filter((holder) => resolve2(holder) !== resolve2(keep))) {
|
|
1170
1345
|
await removeWorktree(cache, path);
|
|
1171
1346
|
}
|
|
1172
1347
|
}
|
|
@@ -1175,7 +1350,7 @@ async function baseRef(cache, job, repository) {
|
|
|
1175
1350
|
return pushed.code === 0 ? `origin/${job.branch}` : `origin/${repository.defaultBranch}`;
|
|
1176
1351
|
}
|
|
1177
1352
|
async function keptBase(cache, path, job, repository) {
|
|
1178
|
-
if (!job.keepWorkspace || !
|
|
1353
|
+
if (!job.keepWorkspace || !existsSync3(join3(path, ".git"))) {
|
|
1179
1354
|
return null;
|
|
1180
1355
|
}
|
|
1181
1356
|
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
|
|
@@ -1184,14 +1359,17 @@ async function keptBase(cache, path, job, repository) {
|
|
|
1184
1359
|
}
|
|
1185
1360
|
return git(["rev-parse", await baseRef(cache, job, repository)], { cwd: cache });
|
|
1186
1361
|
}
|
|
1362
|
+
function taskFolder(home, job) {
|
|
1363
|
+
return tamsenseFolder(home, "work", ...stepFolder(job));
|
|
1364
|
+
}
|
|
1187
1365
|
async function prepareWorkspace(job, token, tools) {
|
|
1188
|
-
const root =
|
|
1366
|
+
const root = taskFolder(tools.home, job);
|
|
1189
1367
|
const folders = folderNames(job.repositories);
|
|
1190
1368
|
const repositories = [];
|
|
1191
1369
|
for (const [index, repository] of job.repositories.entries()) {
|
|
1192
1370
|
const cache = tamsenseFolder(tools.home, "repos", repository.fullName.replace("/", "__"));
|
|
1193
1371
|
const folder = folders[index] ?? repository.fullName;
|
|
1194
|
-
const path =
|
|
1372
|
+
const path = join3(root, folder);
|
|
1195
1373
|
const baseSha = await cacheLock.run(repository.fullName, async () => {
|
|
1196
1374
|
await refreshCache(cache, repository, token, tools);
|
|
1197
1375
|
const kept = await keptBase(cache, path, job, repository);
|
|
@@ -1212,40 +1390,500 @@ async function prepareWorkspace(job, token, tools) {
|
|
|
1212
1390
|
}
|
|
1213
1391
|
return { root, repositories };
|
|
1214
1392
|
}
|
|
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 {};
|
|
1393
|
+
async function commitIdentityEnv(home) {
|
|
1394
|
+
const configured = await runGit(["config", "--get", "user.email"], { cwd: home });
|
|
1395
|
+
if (configured.code === 0 && configured.stdout) {
|
|
1396
|
+
return {};
|
|
1397
|
+
}
|
|
1398
|
+
return {
|
|
1399
|
+
GIT_AUTHOR_NAME: FALLBACK_IDENTITY.name,
|
|
1400
|
+
GIT_AUTHOR_EMAIL: FALLBACK_IDENTITY.email,
|
|
1401
|
+
GIT_COMMITTER_NAME: FALLBACK_IDENTITY.name,
|
|
1402
|
+
GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
async function publishWorkspace(workspace, job, token, identity) {
|
|
1406
|
+
const pushed = [];
|
|
1407
|
+
for (const repository of workspace.repositories) {
|
|
1408
|
+
const options = { cwd: repository.path, env: identity };
|
|
1409
|
+
const sha = await cacheLock.run(repository.fullName, async () => {
|
|
1410
|
+
await git(["add", "--all"], options);
|
|
1411
|
+
const hasStaged = (await runGit(["diff", "--cached", "--quiet"], options)).code === 1;
|
|
1412
|
+
if (hasStaged) {
|
|
1413
|
+
await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
|
|
1414
|
+
}
|
|
1415
|
+
const newCommits = Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options));
|
|
1416
|
+
if (newCommits === 0) {
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
await git(["push", "origin", `HEAD:refs/heads/${job.branch}`], { ...options, token });
|
|
1420
|
+
return git(["rev-parse", "HEAD"], options);
|
|
1421
|
+
});
|
|
1422
|
+
if (sha) {
|
|
1423
|
+
pushed.push({ repository: repository.fullName, branch: job.branch, sha });
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return pushed;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// src/localWorkspace.ts
|
|
1430
|
+
var NETWORK_TIMEOUT_MS = 12e4;
|
|
1431
|
+
var sourceLock = keyedLock();
|
|
1432
|
+
function isLocalJob(job) {
|
|
1433
|
+
return job.workspace === "local";
|
|
1434
|
+
}
|
|
1435
|
+
function localBranchLine(branch) {
|
|
1436
|
+
const local = branch.local;
|
|
1437
|
+
if (!local) {
|
|
1438
|
+
return `Pushed ${branch.branch} in ${branch.repository}.`;
|
|
1439
|
+
}
|
|
1440
|
+
const where = `Branch ${branch.branch} has ${local.commits} commit(s) in ${local.folder}`;
|
|
1441
|
+
if (local.pushProblem) {
|
|
1442
|
+
return `${where}. It was not pushed: ${local.pushProblem}`;
|
|
1443
|
+
}
|
|
1444
|
+
return local.remoteUrl ? `${where}, pushed to ${local.remoteUrl}.` : `${where}.`;
|
|
1445
|
+
}
|
|
1446
|
+
function readsOnly(job) {
|
|
1447
|
+
return isReadOnlyCheck(job) || job.stage.kind === "interview" || job.stage.kind === "spec";
|
|
1448
|
+
}
|
|
1449
|
+
async function isAncestor(folder, older, newer) {
|
|
1450
|
+
return (await runGit(["merge-base", "--is-ancestor", older, newer], { cwd: folder })).code === 0;
|
|
1451
|
+
}
|
|
1452
|
+
async function fetchRemote(source, remote, print2) {
|
|
1453
|
+
const fetched = await runGit(["fetch", "--prune", remote], { cwd: source, env: personalGitEnv(), timeoutMs: NETWORK_TIMEOUT_MS });
|
|
1454
|
+
if (fetched.code !== 0) {
|
|
1455
|
+
print2(`Could not fetch from ${remote} in ${source}, going on with what the folder has: ${fetched.stderr || `exit code ${fetched.code}`}`);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
async function baseBranchRef(source, branch, remote) {
|
|
1459
|
+
const own = await hasRef(source, `refs/heads/${branch}`) ? branch : null;
|
|
1460
|
+
const fetched = remote && await hasRef(source, `refs/remotes/${remote}/${branch}`) ? `${remote}/${branch}` : null;
|
|
1461
|
+
if (own && fetched) {
|
|
1462
|
+
return await isAncestor(source, own, fetched) ? fetched : own;
|
|
1463
|
+
}
|
|
1464
|
+
const found = own ?? fetched;
|
|
1465
|
+
if (!found) {
|
|
1466
|
+
throw new Error(`The main branch ${branch} is not in ${source}. Check the folder again in the board settings, on the Code tab.`);
|
|
1467
|
+
}
|
|
1468
|
+
return found;
|
|
1469
|
+
}
|
|
1470
|
+
async function taskBranchRef(source, branch, remote, base) {
|
|
1471
|
+
if (await hasRef(source, `refs/heads/${branch}`) && Number(await git(["rev-list", "--count", `${base}..${branch}`], { cwd: source })) > 0) {
|
|
1472
|
+
return branch;
|
|
1473
|
+
}
|
|
1474
|
+
return remote && await hasRef(source, `refs/remotes/${remote}/${branch}`) ? `${remote}/${branch}` : null;
|
|
1475
|
+
}
|
|
1476
|
+
async function startRef(source, job, local) {
|
|
1477
|
+
const base = await baseBranchRef(source, local.baseBranch, local.remote);
|
|
1478
|
+
const task = await taskBranchRef(source, job.branch, local.remote, base);
|
|
1479
|
+
if (task || !isReadOnlyCheck(job)) {
|
|
1480
|
+
return task ?? base;
|
|
1481
|
+
}
|
|
1482
|
+
const where = local.remote ? `${source} or on its remote ${local.remote}` : source;
|
|
1483
|
+
throw new Error(`Branch ${job.branch} is not in ${where}. It was made on another computer: push it to a remote this folder can fetch, or run this check on that computer.`);
|
|
1484
|
+
}
|
|
1485
|
+
async function releaseBranch2(source, branch, keep, home) {
|
|
1486
|
+
const listed = await runGit(["worktree", "list", "--porcelain"], { cwd: source });
|
|
1487
|
+
const work2 = tamsenseFolder(home, "work");
|
|
1488
|
+
for (const holder of branchHolders(listed.stdout, branch).filter((path) => resolve3(path) !== resolve3(keep))) {
|
|
1489
|
+
if (!isInside(holder, work2)) {
|
|
1490
|
+
throw new Error(`Branch ${branch} is checked out in ${resolve3(holder)}. Switch that folder to another branch, then start the step again.`);
|
|
1491
|
+
}
|
|
1492
|
+
await removeWorktree(source, holder);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async function keptBase2(path, job) {
|
|
1496
|
+
if (!job.keepWorkspace || !existsSync4(join4(path, ".git"))) {
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path });
|
|
1500
|
+
if (branch.code !== 0 || branch.stdout !== (readsOnly(job) ? "HEAD" : job.branch)) {
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1503
|
+
return git(["rev-parse", "HEAD"], { cwd: path });
|
|
1504
|
+
}
|
|
1505
|
+
function sourceOf(repository) {
|
|
1506
|
+
if (!repository.folder) {
|
|
1507
|
+
throw new Error(`TAMsense did not say which folder holds ${repository.fullName} on this computer.`);
|
|
1508
|
+
}
|
|
1509
|
+
return repository.folder;
|
|
1510
|
+
}
|
|
1511
|
+
async function prepareRepository(job, repository, path, tools) {
|
|
1512
|
+
const source = sourceOf(repository);
|
|
1513
|
+
if ((await runGit(["rev-parse", "--show-toplevel"], { cwd: source }).catch(() => null))?.code !== 0) {
|
|
1514
|
+
throw new Error(`${source} is no longer a git repository on this computer. Check the folder again in the board settings, on the Code tab.`);
|
|
1515
|
+
}
|
|
1516
|
+
const local = { source, remote: await remoteOf(source), baseBranch: repository.defaultBranch };
|
|
1517
|
+
if (local.remote) {
|
|
1518
|
+
await fetchRemote(source, local.remote, tools.print);
|
|
1519
|
+
}
|
|
1520
|
+
const kept = await keptBase2(path, job);
|
|
1521
|
+
if (kept) {
|
|
1522
|
+
return { baseSha: kept, local };
|
|
1523
|
+
}
|
|
1524
|
+
await runGit(["worktree", "prune"], { cwd: source });
|
|
1525
|
+
if (existsSync4(path)) {
|
|
1526
|
+
await removeWorktree(source, path);
|
|
1527
|
+
}
|
|
1528
|
+
const start = await startRef(source, job, local);
|
|
1529
|
+
if (readsOnly(job)) {
|
|
1530
|
+
await git(["worktree", "add", "--detach", path, start], { cwd: source });
|
|
1531
|
+
} else {
|
|
1532
|
+
await releaseBranch2(source, job.branch, path, tools.home);
|
|
1533
|
+
await git(["worktree", "add", "-B", job.branch, path, start], { cwd: source });
|
|
1534
|
+
}
|
|
1535
|
+
return { baseSha: await git(["rev-parse", "HEAD"], { cwd: path }), local };
|
|
1536
|
+
}
|
|
1537
|
+
async function prepareLocalWorkspace(job, tools) {
|
|
1538
|
+
const root = taskFolder(tools.home, job);
|
|
1539
|
+
const folders = folderNames(job.repositories);
|
|
1540
|
+
const repositories = [];
|
|
1541
|
+
for (const [index, repository] of job.repositories.entries()) {
|
|
1542
|
+
const folder = folders[index] ?? repository.fullName;
|
|
1543
|
+
const path = join4(root, folder);
|
|
1544
|
+
const prepared = await sourceLock.run(resolve3(sourceOf(repository)), () => prepareRepository(job, repository, path, tools));
|
|
1545
|
+
repositories.push({ fullName: repository.fullName, folder, path, baseSha: prepared.baseSha, local: prepared.local });
|
|
1546
|
+
}
|
|
1547
|
+
return { root, repositories };
|
|
1548
|
+
}
|
|
1549
|
+
async function pushBranch(path, local, branch) {
|
|
1550
|
+
if (!local.remote) {
|
|
1551
|
+
return { remoteUrl: null, pushProblem: null };
|
|
1552
|
+
}
|
|
1553
|
+
if (branch === local.baseBranch) {
|
|
1554
|
+
return { remoteUrl: null, pushProblem: `TAMsense never pushes the main branch ${branch}.` };
|
|
1555
|
+
}
|
|
1556
|
+
const pushed = await runGit(["push", local.remote, `HEAD:refs/heads/${branch}`], { cwd: path, env: personalGitEnv(), timeoutMs: NETWORK_TIMEOUT_MS });
|
|
1557
|
+
if (pushed.code !== 0) {
|
|
1558
|
+
return { remoteUrl: null, pushProblem: pushed.stderr || `exit code ${pushed.code}` };
|
|
1559
|
+
}
|
|
1560
|
+
return { remoteUrl: await git(["remote", "get-url", local.remote], { cwd: path }), pushProblem: null };
|
|
1561
|
+
}
|
|
1562
|
+
async function commitAndPush(repository, local, job, identity) {
|
|
1563
|
+
const options = { cwd: repository.path, env: identity };
|
|
1564
|
+
await git(["add", "--all"], options);
|
|
1565
|
+
if ((await runGit(["diff", "--cached", "--quiet"], options)).code === 1) {
|
|
1566
|
+
await git(["commit", "--message", `Task #${job.taskId}: ${job.title}`], options);
|
|
1567
|
+
}
|
|
1568
|
+
if (Number(await git(["rev-list", "--count", `${repository.baseSha}..HEAD`], options)) === 0) {
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
const base = await baseBranchRef(repository.path, local.baseBranch, local.remote);
|
|
1572
|
+
const commits = Number(await git(["rev-list", "--count", `${base}..HEAD`], options));
|
|
1573
|
+
const sent2 = await pushBranch(repository.path, local, job.branch);
|
|
1574
|
+
return { sha: await git(["rev-parse", "HEAD"], options), commits, ...sent2 };
|
|
1575
|
+
}
|
|
1576
|
+
async function publishLocalWorkspace(workspace, job, identity) {
|
|
1577
|
+
const pushed = [];
|
|
1578
|
+
for (const repository of workspace.repositories) {
|
|
1579
|
+
const local = repository.local;
|
|
1580
|
+
if (!local) {
|
|
1581
|
+
continue;
|
|
1582
|
+
}
|
|
1583
|
+
const branch = await sourceLock.run(resolve3(local.source), async () => {
|
|
1584
|
+
const published = await commitAndPush(repository, local, job, identity);
|
|
1585
|
+
await runGit(["checkout", "--quiet", "--detach"], { cwd: repository.path });
|
|
1586
|
+
return published;
|
|
1587
|
+
});
|
|
1588
|
+
if (branch) {
|
|
1589
|
+
pushed.push({
|
|
1590
|
+
repository: repository.fullName,
|
|
1591
|
+
branch: job.branch,
|
|
1592
|
+
sha: branch.sha,
|
|
1593
|
+
local: { folder: local.source, baseBranch: local.baseBranch, commits: branch.commits, remoteUrl: branch.remoteUrl && remoteWithoutSecrets(branch.remoteUrl), pushProblem: branch.pushProblem }
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return pushed;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// src/results.ts
|
|
1601
|
+
import { mkdir as mkdir2, readFile as readFile2, rm as rm3, stat, writeFile as writeFile2 } from "fs/promises";
|
|
1602
|
+
import { basename as basename2, extname, isAbsolute as isAbsolute2, join as join5, resolve as resolve4 } from "path";
|
|
1603
|
+
import { tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
|
|
1604
|
+
import { z as z2 } from "zod";
|
|
1605
|
+
var SAVE_DOCUMENT_TOOL_NAME = "save_result_document";
|
|
1606
|
+
var SAVE_TABLE_TOOL_NAME = "save_result_table";
|
|
1607
|
+
var ATTACH_FILE_TOOL_NAME = "attach_result_file";
|
|
1608
|
+
var ADD_LINK_TOOL_NAME = "add_result_link";
|
|
1609
|
+
var HAND_OVER_TOOL_NAME = "hand_over";
|
|
1610
|
+
var RESULTS_FOLDER = "results";
|
|
1611
|
+
var MAX_NAME_LENGTH = 60;
|
|
1612
|
+
var TYPES_BY_EXTENSION = {
|
|
1613
|
+
".pdf": "application/pdf",
|
|
1614
|
+
".png": "image/png",
|
|
1615
|
+
".jpg": "image/jpeg",
|
|
1616
|
+
".jpeg": "image/jpeg",
|
|
1617
|
+
".webp": "image/webp",
|
|
1618
|
+
".gif": "image/gif",
|
|
1619
|
+
".csv": "text/csv",
|
|
1620
|
+
".txt": "text/plain",
|
|
1621
|
+
".md": "text/markdown",
|
|
1622
|
+
".json": "application/json",
|
|
1623
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1624
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1625
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
1626
|
+
".zip": "application/zip"
|
|
1627
|
+
};
|
|
1628
|
+
var EXTENSIONS = { document: ".md", table: ".csv", file: "", link: "" };
|
|
1629
|
+
function isFolderJob(job) {
|
|
1630
|
+
return job.workspace === "folder";
|
|
1631
|
+
}
|
|
1632
|
+
function resultTools(job, api, root) {
|
|
1633
|
+
if (!isFolderJob(job) || job.stage.kind !== "code") {
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
return {
|
|
1637
|
+
root,
|
|
1638
|
+
save: (body) => api.request("POST", runPath(job.runId, "results"), body),
|
|
1639
|
+
attach: (body) => api.request("POST", runPath(job.runId, "result-files"), body)
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
function handOverTools(job, api) {
|
|
1643
|
+
const boards = job.handOverBoards ?? [];
|
|
1644
|
+
if (boards.length === 0 || job.stage.kind !== "code") {
|
|
1645
|
+
return null;
|
|
1646
|
+
}
|
|
1647
|
+
return { boards, send: (body) => api.request("POST", runPath(job.runId, "hand-over"), body) };
|
|
1648
|
+
}
|
|
1649
|
+
function resultFileName(result, taken) {
|
|
1650
|
+
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";
|
|
1651
|
+
const extension = result.kind === "file" ? extname(result.fileName ?? "") : EXTENSIONS[result.kind];
|
|
1652
|
+
let name = `${stem}${extension}`;
|
|
1653
|
+
for (let index = 2; taken.has(name.toLowerCase()); index += 1) {
|
|
1654
|
+
name = `${stem}-${index}${extension}`;
|
|
1655
|
+
}
|
|
1656
|
+
taken.add(name.toLowerCase());
|
|
1657
|
+
return name;
|
|
1658
|
+
}
|
|
1659
|
+
function linksText(links) {
|
|
1660
|
+
return links.map((link) => `- ${link.title}: ${link.url ?? ""}`).join("\n");
|
|
1661
|
+
}
|
|
1662
|
+
async function writeResults(folder, job, api) {
|
|
1663
|
+
const results = job.results ?? [];
|
|
1664
|
+
await rm3(folder, { recursive: true, force: true });
|
|
1665
|
+
if (results.length === 0) {
|
|
1666
|
+
return [];
|
|
1667
|
+
}
|
|
1668
|
+
await mkdir2(folder, { recursive: true });
|
|
1669
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1670
|
+
const lines = [];
|
|
1671
|
+
const missing = [];
|
|
1672
|
+
for (const result of results.filter((item) => item.kind !== "link")) {
|
|
1673
|
+
const name = resultFileName(result, taken);
|
|
1674
|
+
if (result.kind === "file") {
|
|
1675
|
+
try {
|
|
1676
|
+
const file = await api.request("GET", resultFilePath(job.runId, result.id));
|
|
1677
|
+
await writeFile2(join5(folder, name), Buffer.from(file.data, "base64"));
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
missing.push(`${result.title}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
} else {
|
|
1683
|
+
await writeFile2(join5(folder, name), result.content ?? "");
|
|
1684
|
+
}
|
|
1685
|
+
lines.push(`${RESULTS_FOLDER}/${name}: ${result.title}${result.version ? `, version ${result.version}` : ""}`);
|
|
1686
|
+
}
|
|
1687
|
+
const links = results.filter((item) => item.kind === "link");
|
|
1688
|
+
if (links.length > 0) {
|
|
1689
|
+
await writeFile2(join5(folder, "links.md"), `${linksText(links)}
|
|
1690
|
+
`);
|
|
1691
|
+
lines.push(`${RESULTS_FOLDER}/links.md: ${links.length} link(s)`);
|
|
1692
|
+
}
|
|
1693
|
+
if (missing.length > 0) {
|
|
1694
|
+
await writeFile2(join5(folder, "notes.md"), `These files could not be downloaded:
|
|
1695
|
+
${missing.map((line) => `- ${line}`).join("\n")}
|
|
1696
|
+
`);
|
|
1697
|
+
}
|
|
1698
|
+
return lines;
|
|
1699
|
+
}
|
|
1700
|
+
async function prepareFolder(job, root, api) {
|
|
1701
|
+
if (!job.keepWorkspace) {
|
|
1702
|
+
await rm3(root, { recursive: true, force: true });
|
|
1703
|
+
}
|
|
1704
|
+
await mkdir2(root, { recursive: true });
|
|
1705
|
+
return { root, repositories: [], results: await writeResults(join5(root, RESULTS_FOLDER), job, api) };
|
|
1706
|
+
}
|
|
1707
|
+
async function readResultFile(root, path, title) {
|
|
1708
|
+
const full = isAbsolute2(path) ? path : resolve4(root, path);
|
|
1709
|
+
const info = await stat(full).catch(() => null);
|
|
1710
|
+
if (!info?.isFile()) {
|
|
1711
|
+
return `There is no file at ${full}.`;
|
|
1712
|
+
}
|
|
1713
|
+
if (info.size > MAX_RESULT_FILE_BYTES) {
|
|
1714
|
+
return `The file is larger than ${MAX_RESULT_FILE_BYTES / 1024 / 1024} MB. Attach a smaller one or split it.`;
|
|
1715
|
+
}
|
|
1716
|
+
const name = basename2(full);
|
|
1717
|
+
return { name, title: title.trim() || name, contentType: TYPES_BY_EXTENSION[extname(full).toLowerCase()] ?? "application/octet-stream", data: (await readFile2(full)).toString("base64") };
|
|
1718
|
+
}
|
|
1719
|
+
function reply(text2, isError = false) {
|
|
1720
|
+
return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
|
|
1721
|
+
}
|
|
1722
|
+
async function sent(send) {
|
|
1723
|
+
try {
|
|
1724
|
+
return reply((await send()).text);
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
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);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
function resultToolset(tools) {
|
|
1730
|
+
return [
|
|
1731
|
+
tool2(
|
|
1732
|
+
SAVE_DOCUMENT_TOOL_NAME,
|
|
1733
|
+
"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.",
|
|
1734
|
+
{
|
|
1735
|
+
title: z2.string().describe("short name of the document, the same for every version of it"),
|
|
1736
|
+
content: z2.string().describe("the full text in Markdown, never a diff or a part"),
|
|
1737
|
+
note: z2.string().describe('one line on what this version is or what changed: "First draft", "Fixed the prices after the check"')
|
|
1738
|
+
},
|
|
1739
|
+
async (args) => await sent(() => tools.save({ kind: "document", ...args }))
|
|
1740
|
+
),
|
|
1741
|
+
tool2(
|
|
1742
|
+
SAVE_TABLE_TOOL_NAME,
|
|
1743
|
+
"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.",
|
|
1744
|
+
{
|
|
1745
|
+
title: z2.string().describe("short name of the table, the same for every version of it"),
|
|
1746
|
+
csv: z2.string().describe("the full table as CSV, the first row holds the column names"),
|
|
1747
|
+
note: z2.string().describe("one line on what this version is or what changed")
|
|
1748
|
+
},
|
|
1749
|
+
async (args) => await sent(() => tools.save({ kind: "table", title: args.title, content: args.csv, note: args.note }))
|
|
1750
|
+
),
|
|
1751
|
+
tool2(
|
|
1752
|
+
ATTACH_FILE_TOOL_NAME,
|
|
1753
|
+
`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.`,
|
|
1754
|
+
{
|
|
1755
|
+
path: z2.string().describe("path of the file, from the task folder or absolute"),
|
|
1756
|
+
title: z2.string().describe("what the file is, in a few words")
|
|
1757
|
+
},
|
|
1758
|
+
async (args) => {
|
|
1759
|
+
const body = await readResultFile(tools.root, args.path, args.title);
|
|
1760
|
+
return typeof body === "string" ? reply(body, true) : await sent(() => tools.attach(body));
|
|
1761
|
+
}
|
|
1762
|
+
),
|
|
1763
|
+
tool2(
|
|
1764
|
+
ADD_LINK_TOOL_NAME,
|
|
1765
|
+
"Attach a link to the TAMsense task as part of the result: a page or a file the result relies on.",
|
|
1766
|
+
{
|
|
1767
|
+
title: z2.string().describe("what the link is, in a few words"),
|
|
1768
|
+
url: z2.string().describe("the full address starting with https://")
|
|
1769
|
+
},
|
|
1770
|
+
async (args) => await sent(() => tools.save({ kind: "link", ...args }))
|
|
1771
|
+
)
|
|
1772
|
+
];
|
|
1773
|
+
}
|
|
1774
|
+
function handOverTool(tools) {
|
|
1775
|
+
return tool2(
|
|
1776
|
+
HAND_OVER_TOOL_NAME,
|
|
1777
|
+
`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.`,
|
|
1778
|
+
{
|
|
1779
|
+
board: z2.string().describe("name of the board"),
|
|
1780
|
+
title: z2.string().describe("title of the new task on that board, one plain sentence"),
|
|
1781
|
+
note: z2.string().describe("what you found and what that board should do; never names, emails or other personal data of clients"),
|
|
1782
|
+
wait: z2.boolean().describe("true when this task should wait on its step until the linked task is done")
|
|
1783
|
+
},
|
|
1784
|
+
async (args) => await sent(() => tools.send(args))
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// src/specText.ts
|
|
1789
|
+
function isRecord(value) {
|
|
1790
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1791
|
+
}
|
|
1792
|
+
function text(value) {
|
|
1793
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1794
|
+
}
|
|
1795
|
+
function texts(value) {
|
|
1796
|
+
return Array.isArray(value) ? value.map(text).filter(Boolean) : [];
|
|
1797
|
+
}
|
|
1798
|
+
function records(value) {
|
|
1799
|
+
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
1800
|
+
}
|
|
1801
|
+
function section(title, lines) {
|
|
1802
|
+
return lines.length > 0 ? `${title}
|
|
1803
|
+
${lines.join("\n")}` : null;
|
|
1804
|
+
}
|
|
1805
|
+
function criterionLine(value) {
|
|
1806
|
+
if (typeof value === "string") {
|
|
1807
|
+
return value.trim() ? `- ${value.trim()}` : null;
|
|
1808
|
+
}
|
|
1809
|
+
if (!isRecord(value) || !text(value.text)) {
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
const kind = ["main", "edge", "keep"].includes(value.kind) ? `(${value.kind})` : "";
|
|
1813
|
+
const head = [text(value.id), kind].filter(Boolean).join(" ");
|
|
1814
|
+
return `- ${head ? `${head}: ` : ""}${text(value.text)}`;
|
|
1815
|
+
}
|
|
1816
|
+
function assumptionLine(value) {
|
|
1817
|
+
if (typeof value === "string") {
|
|
1818
|
+
return value.trim() ? `- ${value.trim()}` : null;
|
|
1219
1819
|
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
GIT_COMMITTER_EMAIL: FALLBACK_IDENTITY.email
|
|
1225
|
-
};
|
|
1820
|
+
if (!isRecord(value) || !text(value.text)) {
|
|
1821
|
+
return null;
|
|
1822
|
+
}
|
|
1823
|
+
return `- ${text(value.text)}${text(value.reason) ? ` (${text(value.reason)})` : ""}`;
|
|
1226
1824
|
}
|
|
1227
|
-
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1825
|
+
function changeLines(change, index) {
|
|
1826
|
+
const action = ["create", "modify", "delete"].includes(change.action) ? ` (${change.action})` : "";
|
|
1827
|
+
const [produces, consumes, acceptance] = [texts(change.produces), texts(change.consumes), texts(change.acceptance)];
|
|
1828
|
+
return [
|
|
1829
|
+
`${index + 1}. ${text(change.repo) ? `${text(change.repo)} ` : ""}${text(change.file)}${action}: ${text(change.description)}`,
|
|
1830
|
+
...produces.length > 0 ? [` Produces: ${produces.join("; ")}`] : [],
|
|
1831
|
+
...consumes.length > 0 ? [` Consumes: ${consumes.join("; ")}`] : [],
|
|
1832
|
+
...acceptance.length > 0 ? [` Makes true: ${acceptance.join(", ")}`] : []
|
|
1833
|
+
];
|
|
1834
|
+
}
|
|
1835
|
+
var CODE_USE_TEXT = { reuse: "reuse", change: "change", keep: "do not touch" };
|
|
1836
|
+
var RISK_LEVELS = ["low", "medium", "high"];
|
|
1837
|
+
function present(lines) {
|
|
1838
|
+
return lines.filter((line) => line !== null);
|
|
1839
|
+
}
|
|
1840
|
+
function decisionLines(spec) {
|
|
1841
|
+
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"})`);
|
|
1842
|
+
}
|
|
1843
|
+
function briefPromptText(brief) {
|
|
1844
|
+
return [
|
|
1845
|
+
text(brief.goal) && `Goal: ${text(brief.goal)}`,
|
|
1846
|
+
text(brief.context) && `Summary: ${text(brief.context)}`,
|
|
1847
|
+
section("The result must contain:", texts(brief.contains).map((item) => `- ${item}`)),
|
|
1848
|
+
section("Acceptance criteria:", present(Array.isArray(brief.acceptance) ? brief.acceptance.map(criterionLine) : [])),
|
|
1849
|
+
section("Constraints:", texts(brief.constraints).map((item) => `- ${item}`)),
|
|
1850
|
+
section("Out of scope (do not make):", texts(brief.outOfScope).map((item) => `- ${item}`)),
|
|
1851
|
+
section("Sources to use:", records(brief.sources).filter((item) => text(item.source)).map((item) => `- ${text(item.source)}${text(item.use) ? `: ${text(item.use)}` : ""}`)),
|
|
1852
|
+
text(brief.approver) && `Approved by: ${text(brief.approver)}`,
|
|
1853
|
+
section("Decisions:", decisionLines(brief)),
|
|
1854
|
+
section("Assumptions:", present(Array.isArray(brief.assumptions) ? brief.assumptions.map(assumptionLine) : [])),
|
|
1855
|
+
section("Open questions (not answered yet):", records(brief.openQuestions).filter((item) => text(item.question)).map((item) => `- ${text(item.question)}`))
|
|
1856
|
+
].filter(Boolean).join("\n\n");
|
|
1857
|
+
}
|
|
1858
|
+
function specPromptText(spec) {
|
|
1859
|
+
if (!isRecord(spec)) {
|
|
1860
|
+
return null;
|
|
1247
1861
|
}
|
|
1248
|
-
|
|
1862
|
+
if (spec.format === "brief") {
|
|
1863
|
+
return briefPromptText(spec);
|
|
1864
|
+
}
|
|
1865
|
+
const structured = Array.isArray(spec.outOfScope) || isRecord(spec.testPlan);
|
|
1866
|
+
const plan = isRecord(spec.testPlan) ? spec.testPlan : {};
|
|
1867
|
+
const risks = records(spec.risks).filter((item) => text(item.risk));
|
|
1868
|
+
const riskLevel = RISK_LEVELS.includes(spec.riskLevel) ? spec.riskLevel : null;
|
|
1869
|
+
return [
|
|
1870
|
+
text(spec.goal) && `Goal: ${text(spec.goal)}`,
|
|
1871
|
+
text(spec.context) && `${structured ? "Summary" : "Context"}: ${text(spec.context)}`,
|
|
1872
|
+
section("In scope:", texts(spec.inScope).map((item) => `- ${item}`)),
|
|
1873
|
+
section("Out of scope (do not build):", texts(spec.outOfScope).map((item) => `- ${item}`)),
|
|
1874
|
+
section("Decisions:", decisionLines(spec)),
|
|
1875
|
+
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]}]` : ""}`)),
|
|
1876
|
+
section("Changes, in order:", records(spec.changes).filter((item) => text(item.file)).flatMap(changeLines)),
|
|
1877
|
+
section("Acceptance criteria:", present(Array.isArray(spec.acceptance) ? spec.acceptance.map(criterionLine) : [])),
|
|
1878
|
+
section("Test plan:", [
|
|
1879
|
+
...records(plan.automated).filter((item) => text(item.command)).map((item) => `- Run in ${text(item.repo) || "the repository"}: ${text(item.command)}${text(item.expect) ? `. Passing: ${text(item.expect)}` : ""}`),
|
|
1880
|
+
...records(plan.newTests).filter((item) => text(item.seam)).map((item) => `- New test at ${text(item.seam)}${texts(item.covers).length > 0 ? `, covers ${texts(item.covers).join(", ")}` : ""}`),
|
|
1881
|
+
...records(plan.manual).filter((item) => text(item.steps)).map((item) => `- By hand${text(item.page) ? ` on ${text(item.page)}` : ""}: ${text(item.steps)}`)
|
|
1882
|
+
]),
|
|
1883
|
+
risks.length > 0 ? section(`Risks${riskLevel ? ` (risk level ${riskLevel})` : ""}:`, risks.map((item) => `- ${text(item.risk)}${text(item.mitigation) ? `. Mitigation: ${text(item.mitigation)}` : ""}`)) : riskLevel && `Risk level: ${riskLevel}`,
|
|
1884
|
+
section("Assumptions:", present(Array.isArray(spec.assumptions) ? spec.assumptions.map(assumptionLine) : [])),
|
|
1885
|
+
section("Open questions (not answered yet):", records(spec.openQuestions).filter((item) => text(item.question)).map((item) => `- ${text(item.question)}`))
|
|
1886
|
+
].filter(Boolean).join("\n\n");
|
|
1249
1887
|
}
|
|
1250
1888
|
|
|
1251
1889
|
// src/prompt.ts
|
|
@@ -1268,8 +1906,15 @@ var CODE_RULES = [
|
|
|
1268
1906
|
"Check your work with the project's own tools: run the tests, type checks and linters that exist, and fix what you broke.",
|
|
1269
1907
|
"Commit your work in every repository you changed, on the current branch. Do not push, do not switch or create branches, and do not rewrite history: TAMsense pushes the branch and opens the pull request."
|
|
1270
1908
|
];
|
|
1909
|
+
var LOCAL_COMMIT_RULE = "Commit your work in every repository you changed, on the current branch. Do not push, do not switch or create branches, and do not rewrite history: TAMsense pushes the branch when the folder has a remote, and the person merges it.";
|
|
1910
|
+
var LOCAL_FOLDERS_RULE = "Each repository here is a git worktree of the person's own folder on this computer. Work only here: never open, change or check out branches in the person's original folder.";
|
|
1271
1911
|
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.";
|
|
1272
1912
|
var CODE_REPORT_RULE = "End with a short report for the reviewer: what you changed, how you checked it, and anything left open.";
|
|
1913
|
+
var READ_ONLY_CODE_RULES = [
|
|
1914
|
+
"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.",
|
|
1915
|
+
"Check what you report with the project's own tools where they exist, and say how you checked it."
|
|
1916
|
+
];
|
|
1917
|
+
var READ_ONLY_REPORT_RULE = "End with a short report: what you found, how you checked it, and what is left open.";
|
|
1273
1918
|
var CHECK_RULES = [
|
|
1274
1919
|
"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.",
|
|
1275
1920
|
"Do not change, commit or push product code. You may run anything and create throwaway files outside the repositories.",
|
|
@@ -1280,6 +1925,21 @@ var SCREENSHOT_RULE = `If you render screens (screenshots of the interface), att
|
|
|
1280
1925
|
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".';
|
|
1281
1926
|
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.`;
|
|
1282
1927
|
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.";
|
|
1928
|
+
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.";
|
|
1929
|
+
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.";
|
|
1930
|
+
var FOLDER_DO_RULES = [
|
|
1931
|
+
`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.`,
|
|
1932
|
+
"Use only facts from the task, the notes, the earlier results and what you read yourself; never invent numbers, names or dates.",
|
|
1933
|
+
RESULT_HOME_RULE
|
|
1934
|
+
];
|
|
1935
|
+
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.";
|
|
1936
|
+
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.";
|
|
1937
|
+
var FOLDER_CHECK_RULES = [
|
|
1938
|
+
`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.`,
|
|
1939
|
+
`Do not change the result. You may create throwaway files outside the ${RESULTS_FOLDER} folder.`,
|
|
1940
|
+
"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.",
|
|
1941
|
+
`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.`
|
|
1942
|
+
];
|
|
1283
1943
|
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.";
|
|
1284
1944
|
function composeProjectName(job) {
|
|
1285
1945
|
return ["tamsense", ...stepFolder(job)].join("-");
|
|
@@ -1302,6 +1962,10 @@ var STAGE_GOALS = {
|
|
|
1302
1962
|
review: "Review the code for",
|
|
1303
1963
|
test: "Test"
|
|
1304
1964
|
};
|
|
1965
|
+
var READ_ONLY_GOAL = "Work on";
|
|
1966
|
+
function isReadOnlyCode(stage) {
|
|
1967
|
+
return stage.kind === "code" && stage.role?.access === "read";
|
|
1968
|
+
}
|
|
1305
1969
|
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.`;
|
|
1306
1970
|
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.";
|
|
1307
1971
|
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.";
|
|
@@ -1319,12 +1983,29 @@ var SPEC_RULES = [
|
|
|
1319
1983
|
"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."
|
|
1320
1984
|
];
|
|
1321
1985
|
var SPEC_WORK_MISSING = "TAMsense sent a spec step without its round and brief.";
|
|
1986
|
+
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.`;
|
|
1987
|
+
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.";
|
|
1988
|
+
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).";
|
|
1989
|
+
var RESULT_BRIEF_RULES = [
|
|
1990
|
+
'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.',
|
|
1991
|
+
'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".',
|
|
1992
|
+
"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.",
|
|
1993
|
+
`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.`,
|
|
1994
|
+
"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.",
|
|
1995
|
+
"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."
|
|
1996
|
+
];
|
|
1997
|
+
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.";
|
|
1322
1998
|
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.";
|
|
1323
1999
|
function specText(spec) {
|
|
1324
2000
|
const text2 = specPromptText(spec);
|
|
1325
2001
|
return text2 ? `Approved spec:
|
|
1326
2002
|
${text2}` : null;
|
|
1327
2003
|
}
|
|
2004
|
+
function briefText(spec) {
|
|
2005
|
+
const text2 = specPromptText(spec);
|
|
2006
|
+
return text2 ? `Approved brief:
|
|
2007
|
+
${text2}` : null;
|
|
2008
|
+
}
|
|
1328
2009
|
function contextText(context) {
|
|
1329
2010
|
return context.trim() ? `Project context from the team (follow it like the project's own rules):
|
|
1330
2011
|
${context.trim()}` : null;
|
|
@@ -1336,13 +2017,17 @@ function bulletList(lines) {
|
|
|
1336
2017
|
return lines.map((line) => `- ${line}`).join("\n");
|
|
1337
2018
|
}
|
|
1338
2019
|
function repositoryLines(job, workspace) {
|
|
1339
|
-
const
|
|
2020
|
+
const sourceOf2 = new Map(job.repositories.map((repository) => [repository.fullName, repository]));
|
|
1340
2021
|
return workspace.repositories.map((repository) => {
|
|
1341
|
-
const source =
|
|
2022
|
+
const source = sourceOf2.get(repository.fullName);
|
|
1342
2023
|
const pull = source?.pullRequestUrl ? `, pull request ${source.pullRequestUrl}` : "";
|
|
1343
|
-
|
|
2024
|
+
const base = repository.local ? repository.local.baseBranch : `origin/${source?.defaultBranch ?? "main"}`;
|
|
2025
|
+
return `${repository.folder} (${repository.fullName}) at commit ${repository.baseSha.slice(0, 7)}, base branch ${base}${pull}`;
|
|
1344
2026
|
});
|
|
1345
2027
|
}
|
|
2028
|
+
function codeRules(job) {
|
|
2029
|
+
return isLocalJob(job) ? [...CODE_RULES.slice(0, -1), LOCAL_COMMIT_RULE] : CODE_RULES;
|
|
2030
|
+
}
|
|
1346
2031
|
function stepLine(stage) {
|
|
1347
2032
|
return `This step of the board is "${stage.name}"${stage.helper ? `, and you work as the ${stage.helper}` : ""}.`;
|
|
1348
2033
|
}
|
|
@@ -1363,6 +2048,18 @@ function peopleText(people) {
|
|
|
1363
2048
|
function canProposeKnowledge(job) {
|
|
1364
2049
|
return job.knowledge !== void 0 && (job.people ?? []).length > 0 && job.stage.kind !== "interview" && job.stage.kind !== "spec";
|
|
1365
2050
|
}
|
|
2051
|
+
function folderText(root, results) {
|
|
2052
|
+
const lines = results ?? [];
|
|
2053
|
+
return lines.length > 0 ? `The task folder is ${root}. The results of the task so far, latest versions:
|
|
2054
|
+
${bulletList(lines)}` : `The task folder is ${root}. The task has no result yet.`;
|
|
2055
|
+
}
|
|
2056
|
+
function handOverText(boards) {
|
|
2057
|
+
const names = (boards ?? []).map((board) => board.name);
|
|
2058
|
+
if (names.length === 0) {
|
|
2059
|
+
return null;
|
|
2060
|
+
}
|
|
2061
|
+
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.`;
|
|
2062
|
+
}
|
|
1366
2063
|
function handoffText(targets, isCheck) {
|
|
1367
2064
|
if (targets.length === 0) {
|
|
1368
2065
|
return null;
|
|
@@ -1409,6 +2106,64 @@ function specGoal(job, work2) {
|
|
|
1409
2106
|
}
|
|
1410
2107
|
return work2.round > 1 ? `Follow-up questions for TAMsense task #${job.taskId}: ${job.title}` : `Prepare TAMsense task #${job.taskId} for its spec: ${job.title}`;
|
|
1411
2108
|
}
|
|
2109
|
+
function briefQuestionRules(work2) {
|
|
2110
|
+
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.`;
|
|
2111
|
+
if (work2.round === 1) {
|
|
2112
|
+
return [
|
|
2113
|
+
BRIEF_RESEARCH_RULE,
|
|
2114
|
+
RESEARCH_BRIEF_RULE,
|
|
2115
|
+
`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.`,
|
|
2116
|
+
QUESTION_SHAPE_RULE,
|
|
2117
|
+
handIn,
|
|
2118
|
+
QUESTIONS_REPLY_RULE
|
|
2119
|
+
];
|
|
2120
|
+
}
|
|
2121
|
+
const taken = work2.takenIds.length > 0 ? ` These ids are taken by round 1: ${work2.takenIds.join(", ")}.` : "";
|
|
2122
|
+
return [
|
|
2123
|
+
`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.`,
|
|
2124
|
+
`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}`,
|
|
2125
|
+
QUESTION_SHAPE_RULE,
|
|
2126
|
+
"Update the research brief with what the answers changed and leave pendingDecisions empty.",
|
|
2127
|
+
handIn,
|
|
2128
|
+
QUESTIONS_REPLY_RULE
|
|
2129
|
+
];
|
|
2130
|
+
}
|
|
2131
|
+
function briefGoal(job, work2) {
|
|
2132
|
+
if (work2.round === null) {
|
|
2133
|
+
return `Write the brief for TAMsense task #${job.taskId}: ${job.title}`;
|
|
2134
|
+
}
|
|
2135
|
+
return work2.round > 1 ? `Follow-up questions for TAMsense task #${job.taskId}: ${job.title}` : `Prepare TAMsense task #${job.taskId} for its brief: ${job.title}`;
|
|
2136
|
+
}
|
|
2137
|
+
function buildBriefPrompt(job, work2, workspace) {
|
|
2138
|
+
const prompt = [
|
|
2139
|
+
briefGoal(job, work2),
|
|
2140
|
+
job.description,
|
|
2141
|
+
work2.linkedAction,
|
|
2142
|
+
work2.brief ? `Research brief from the clarification phase (verify only what you rely on):
|
|
2143
|
+
${JSON.stringify(work2.brief)}` : null,
|
|
2144
|
+
work2.round === null && job.spec ? `Current saved brief (hand in the full updated version):
|
|
2145
|
+
${JSON.stringify(job.spec)}` : null,
|
|
2146
|
+
job.handoffNote && `Note from another step: ${job.handoffNote}`,
|
|
2147
|
+
threadText(work2.thread),
|
|
2148
|
+
teamMessagesText(job.messages)
|
|
2149
|
+
].filter(Boolean).join("\n\n");
|
|
2150
|
+
const instructions = [
|
|
2151
|
+
BRIEF_UNATTENDED_RULE,
|
|
2152
|
+
MACHINE_RULE,
|
|
2153
|
+
workplaceText(job.computer, workspace.root),
|
|
2154
|
+
stepLine(job.stage),
|
|
2155
|
+
job.stage.instructions,
|
|
2156
|
+
contextText(job.projectContext),
|
|
2157
|
+
FOLDER_RULE,
|
|
2158
|
+
RESULT_HOME_RULE,
|
|
2159
|
+
`How to work:
|
|
2160
|
+
${bulletList(work2.round === null ? RESULT_BRIEF_RULES : briefQuestionRules(work2))}`,
|
|
2161
|
+
safetyText(job),
|
|
2162
|
+
knowledgeText(job.knowledge, job.computer, false),
|
|
2163
|
+
BRIEF_STYLE_RULE
|
|
2164
|
+
].filter(Boolean).join("\n\n");
|
|
2165
|
+
return { prompt, instructions };
|
|
2166
|
+
}
|
|
1412
2167
|
function buildSpecPrompt(job, work2, workspace) {
|
|
1413
2168
|
const prompt = [
|
|
1414
2169
|
specGoal(job, work2),
|
|
@@ -1431,6 +2186,7 @@ ${JSON.stringify(job.spec)}` : null,
|
|
|
1431
2186
|
contextText(job.projectContext),
|
|
1432
2187
|
`The repositories for this task are checked out in the current folder, one subfolder each:
|
|
1433
2188
|
${bulletList(repositoryLines(job, workspace))}`,
|
|
2189
|
+
isLocalJob(job) && LOCAL_FOLDERS_RULE,
|
|
1434
2190
|
READ_ONLY_RULE,
|
|
1435
2191
|
`How to work:
|
|
1436
2192
|
${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
|
|
@@ -1440,19 +2196,63 @@ ${bulletList(work2.round === null ? SPEC_RULES : questionRules(work2))}`,
|
|
|
1440
2196
|
].filter(Boolean).join("\n\n");
|
|
1441
2197
|
return { prompt, instructions };
|
|
1442
2198
|
}
|
|
2199
|
+
var FOLDER_GOALS = {
|
|
2200
|
+
code: "Work on",
|
|
2201
|
+
review: "Check the result of",
|
|
2202
|
+
test: "Check the result of"
|
|
2203
|
+
};
|
|
2204
|
+
function buildFolderPrompt(job, workspace, options) {
|
|
2205
|
+
const kind = job.stage.kind === "review" || job.stage.kind === "test" ? job.stage.kind : "code";
|
|
2206
|
+
const isCheck = kind !== "code";
|
|
2207
|
+
const people = job.people ?? [];
|
|
2208
|
+
const canAsk = people.length > 0;
|
|
2209
|
+
const prompt = [
|
|
2210
|
+
`${FOLDER_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
|
|
2211
|
+
job.description,
|
|
2212
|
+
briefText(job.spec),
|
|
2213
|
+
job.handoffNote && `Note from the previous step: ${job.handoffNote}`,
|
|
2214
|
+
job.review && `Notes to address:
|
|
2215
|
+
${job.review}`,
|
|
2216
|
+
teamMessagesText(job.messages)
|
|
2217
|
+
].filter(Boolean).join("\n\n");
|
|
2218
|
+
const rules = isCheck ? FOLDER_CHECK_RULES : [...FOLDER_DO_RULES, ...job.review ? [FOLDER_REWORK_RULE] : [], FOLDER_REPORT_RULE];
|
|
2219
|
+
const instructions = [
|
|
2220
|
+
canAsk ? ASKING_RULE : UNATTENDED_RULE,
|
|
2221
|
+
MACHINE_RULE,
|
|
2222
|
+
workplaceText(job.computer, workspace.root),
|
|
2223
|
+
stepLine(job.stage),
|
|
2224
|
+
job.stage.instructions,
|
|
2225
|
+
contextText(job.projectContext),
|
|
2226
|
+
`${FOLDER_RULE} ${folderText(workspace.root, options.results)}`,
|
|
2227
|
+
`How to work:
|
|
2228
|
+
${bulletList(rules)}`,
|
|
2229
|
+
safetyText(job),
|
|
2230
|
+
knowledgeText(job.knowledge, job.computer, canProposeKnowledge(job)),
|
|
2231
|
+
APPROVAL_RULE,
|
|
2232
|
+
peopleText(people),
|
|
2233
|
+
isCheck ? null : handOverText(job.handOverBoards),
|
|
2234
|
+
handoffText(job.allowedHandoffs, isCheck),
|
|
2235
|
+
options.browser && BROWSER_RULE
|
|
2236
|
+
].filter(Boolean).join("\n\n");
|
|
2237
|
+
return { prompt, instructions };
|
|
2238
|
+
}
|
|
1443
2239
|
function buildTaskPrompt(job, workspace, options = {}) {
|
|
1444
2240
|
const { kind } = job.stage;
|
|
1445
2241
|
if (kind === "interview" || kind === "spec") {
|
|
1446
2242
|
if (!job.specWork) {
|
|
1447
2243
|
throw new Error(SPEC_WORK_MISSING);
|
|
1448
2244
|
}
|
|
1449
|
-
return buildSpecPrompt(job, job.specWork, workspace);
|
|
2245
|
+
return isFolderJob(job) ? buildBriefPrompt(job, job.specWork, workspace) : buildSpecPrompt(job, job.specWork, workspace);
|
|
2246
|
+
}
|
|
2247
|
+
if (isFolderJob(job)) {
|
|
2248
|
+
return buildFolderPrompt(job, workspace, options);
|
|
1450
2249
|
}
|
|
1451
2250
|
const isCheck = kind !== "code";
|
|
2251
|
+
const readOnly = isReadOnlyCode(job.stage);
|
|
1452
2252
|
const people = job.people ?? [];
|
|
1453
2253
|
const canAsk = people.length > 0;
|
|
1454
2254
|
const prompt = [
|
|
1455
|
-
`${STAGE_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
|
|
2255
|
+
`${readOnly ? READ_ONLY_GOAL : STAGE_GOALS[kind]} TAMsense task #${job.taskId}: ${job.title}`,
|
|
1456
2256
|
job.description,
|
|
1457
2257
|
specText(job.spec),
|
|
1458
2258
|
job.handoffNote && `Note from the previous step: ${job.handoffNote}`,
|
|
@@ -1462,7 +2262,7 @@ ${job.review}`,
|
|
|
1462
2262
|
].filter(Boolean).join("\n\n");
|
|
1463
2263
|
const evidence = [SCREENSHOT_RULE, ...options.recorder ? [RECORDING_RULE] : [], NAMING_RULE];
|
|
1464
2264
|
const localApp = localAppRule(job, isCheck, canAsk);
|
|
1465
|
-
const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : [...
|
|
2265
|
+
const rules = isCheck ? [...CHECK_RULES, DEPENDENCIES_RULE, localApp, ...evidence] : readOnly ? [...READ_ONLY_CODE_RULES, DEPENDENCIES_RULE, localApp, ...evidence, READ_ONLY_REPORT_RULE] : [...codeRules(job), ...job.review ? [REWORK_RULE] : [], DEPENDENCIES_RULE, localApp, ...evidence, CODE_REPORT_RULE];
|
|
1466
2266
|
const instructions = [
|
|
1467
2267
|
canAsk ? ASKING_RULE : UNATTENDED_RULE,
|
|
1468
2268
|
MACHINE_RULE,
|
|
@@ -1472,6 +2272,7 @@ ${job.review}`,
|
|
|
1472
2272
|
contextText(job.projectContext),
|
|
1473
2273
|
`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}:
|
|
1474
2274
|
${bulletList(repositoryLines(job, workspace))}`,
|
|
2275
|
+
isLocalJob(job) && LOCAL_FOLDERS_RULE,
|
|
1475
2276
|
`How to work:
|
|
1476
2277
|
${bulletList(rules)}`,
|
|
1477
2278
|
safetyText(job),
|
|
@@ -1485,108 +2286,135 @@ ${bulletList(rules)}`,
|
|
|
1485
2286
|
}
|
|
1486
2287
|
|
|
1487
2288
|
// src/specTools.ts
|
|
1488
|
-
import { tool as
|
|
1489
|
-
import { z as
|
|
2289
|
+
import { tool as tool3 } from "@anthropic-ai/claude-agent-sdk";
|
|
2290
|
+
import { z as z3 } from "zod";
|
|
1490
2291
|
var MAX_FIRST_ROUND = 4;
|
|
1491
|
-
var questionSchema =
|
|
1492
|
-
id:
|
|
1493
|
-
question:
|
|
1494
|
-
whyItMatters:
|
|
1495
|
-
options:
|
|
1496
|
-
label:
|
|
1497
|
-
description:
|
|
1498
|
-
recommended:
|
|
2292
|
+
var questionSchema = z3.object({
|
|
2293
|
+
id: z3.string().describe('Stable snake_case id, unique in the task, for example "empty_state"'),
|
|
2294
|
+
question: z3.string().describe("One plain sentence the owner can answer without reading code"),
|
|
2295
|
+
whyItMatters: z3.string().describe("One line: what changes in the result depending on the answer"),
|
|
2296
|
+
options: z3.array(z3.object({
|
|
2297
|
+
label: z3.string().describe('Short, concrete answer (1-6 words), without "(Recommended)": use the recommended flag'),
|
|
2298
|
+
description: z3.string().describe("One sentence: what happens if this option is chosen"),
|
|
2299
|
+
recommended: z3.boolean().describe("true on the option you recommend (in a multiSelect question, on every option you recommend). Recommended options come first")
|
|
1499
2300
|
})).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'),
|
|
1500
|
-
multiSelect:
|
|
2301
|
+
multiSelect: z3.boolean().describe("true only when the options are additive and several can apply together")
|
|
1501
2302
|
});
|
|
1502
|
-
var briefSchema =
|
|
1503
|
-
findings:
|
|
1504
|
-
fact:
|
|
1505
|
-
where:
|
|
2303
|
+
var briefSchema = z3.object({
|
|
2304
|
+
findings: z3.array(z3.object({
|
|
2305
|
+
fact: z3.string().describe("What the code does today that matters for this task"),
|
|
2306
|
+
where: z3.string().describe("Where it is: owner/repo path:line")
|
|
1506
2307
|
})).describe("Findings from the code"),
|
|
1507
|
-
dataFacts:
|
|
1508
|
-
approach:
|
|
1509
|
-
codeMap:
|
|
1510
|
-
repo:
|
|
1511
|
-
file:
|
|
1512
|
-
role:
|
|
2308
|
+
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"),
|
|
2309
|
+
approach: z3.string().describe("The approach you would take, in 2-4 sentences"),
|
|
2310
|
+
codeMap: z3.array(z3.object({
|
|
2311
|
+
repo: z3.string().describe("Repository full name (owner/name)"),
|
|
2312
|
+
file: z3.string().describe("Path relative to the repository root"),
|
|
2313
|
+
role: z3.string().describe("Why it matters: what to change or reuse there")
|
|
1513
2314
|
})).describe("The files the change will touch or must reuse"),
|
|
1514
|
-
assumptions:
|
|
1515
|
-
text:
|
|
1516
|
-
reason:
|
|
2315
|
+
assumptions: z3.array(z3.object({
|
|
2316
|
+
text: z3.string().describe("The default you chose"),
|
|
2317
|
+
reason: z3.string().describe("Why it is safe or why it was chosen")
|
|
1517
2318
|
})).describe("Corners settled with a default instead of a question"),
|
|
1518
|
-
pendingDecisions:
|
|
2319
|
+
pendingDecisions: z3.array(z3.string()).describe("Decisions that depend on an answer to this round and cannot be asked yet. Empty when none")
|
|
1519
2320
|
});
|
|
1520
|
-
var questionsPayloadSchema =
|
|
2321
|
+
var questionsPayloadSchema = z3.object({
|
|
1521
2322
|
brief: briefSchema,
|
|
1522
|
-
questions:
|
|
2323
|
+
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`)
|
|
1523
2324
|
});
|
|
1524
|
-
var specSchema =
|
|
1525
|
-
goal:
|
|
1526
|
-
context:
|
|
1527
|
-
inScope:
|
|
1528
|
-
outOfScope:
|
|
1529
|
-
decisions:
|
|
1530
|
-
question:
|
|
1531
|
-
answer:
|
|
1532
|
-
by:
|
|
2325
|
+
var specSchema = z3.object({
|
|
2326
|
+
goal: z3.string().describe("The problem solved, for whom, and the outcome a user or the business will notice"),
|
|
2327
|
+
context: z3.string().describe("Summary: the problem in 1-2 sentences, then the chosen approach in 1-2 sentences"),
|
|
2328
|
+
inScope: z3.array(z3.string()).describe("What this task delivers, one item each"),
|
|
2329
|
+
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"),
|
|
2330
|
+
decisions: z3.array(z3.object({
|
|
2331
|
+
question: z3.string(),
|
|
2332
|
+
answer: z3.string(),
|
|
2333
|
+
by: z3.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
|
|
1533
2334
|
})).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
|
|
1534
|
-
codeMap:
|
|
1535
|
-
repo:
|
|
1536
|
-
file:
|
|
1537
|
-
role:
|
|
1538
|
-
use:
|
|
2335
|
+
codeMap: z3.array(z3.object({
|
|
2336
|
+
repo: z3.string().describe("Repository full name (owner/name)"),
|
|
2337
|
+
file: z3.string().describe("Path relative to the repository root"),
|
|
2338
|
+
role: z3.string().describe("What the coder needs to know about this file"),
|
|
2339
|
+
use: z3.enum(["reuse", "change", "keep"]).describe("reuse: build on it; change: it is in changes; keep: do not touch")
|
|
1539
2340
|
})).describe("The files a coder must read before starting"),
|
|
1540
|
-
changes:
|
|
1541
|
-
repo:
|
|
1542
|
-
file:
|
|
1543
|
-
action:
|
|
1544
|
-
description:
|
|
1545
|
-
produces:
|
|
1546
|
-
consumes:
|
|
1547
|
-
acceptance:
|
|
2341
|
+
changes: z3.array(z3.object({
|
|
2342
|
+
repo: z3.string().describe("Repository full name (owner/name) where the change happens"),
|
|
2343
|
+
file: z3.string().describe("Path to the file, relative to the repository root"),
|
|
2344
|
+
action: z3.enum(["create", "modify", "delete"]),
|
|
2345
|
+
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"),
|
|
2346
|
+
produces: z3.array(z3.string()).describe("Names with signatures or data shapes this change creates or changes for other changes. Empty when none"),
|
|
2347
|
+
consumes: z3.array(z3.string()).describe("Names with signatures or data shapes this change uses from earlier changes. Empty when none"),
|
|
2348
|
+
acceptance: z3.array(z3.string()).describe("Ids of the criteria this change makes true, like AC-1")
|
|
1548
2349
|
})).min(1).describe("File changes in dependency order: each uses only what earlier changes or the code already provide"),
|
|
1549
|
-
acceptance:
|
|
1550
|
-
id:
|
|
1551
|
-
kind:
|
|
1552
|
-
text:
|
|
2350
|
+
acceptance: z3.array(z3.object({
|
|
2351
|
+
id: z3.string().describe("AC-1, AC-2 and so on, in order"),
|
|
2352
|
+
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"),
|
|
2353
|
+
text: z3.string().describe('"Given <state>, when <action>, then <observable result>", saying where to look. No file paths or function names')
|
|
1553
2354
|
})).min(1).describe("Acceptance criteria: main path first, then edge cases, then what must keep working"),
|
|
1554
|
-
testPlan:
|
|
1555
|
-
automated:
|
|
1556
|
-
repo:
|
|
1557
|
-
command:
|
|
1558
|
-
expect:
|
|
2355
|
+
testPlan: z3.object({
|
|
2356
|
+
automated: z3.array(z3.object({
|
|
2357
|
+
repo: z3.string(),
|
|
2358
|
+
command: z3.string().describe("The exact command to run"),
|
|
2359
|
+
expect: z3.string().describe("What passing looks like")
|
|
1559
2360
|
})).describe("At least one command"),
|
|
1560
|
-
newTests:
|
|
1561
|
-
seam:
|
|
1562
|
-
covers:
|
|
2361
|
+
newTests: z3.array(z3.object({
|
|
2362
|
+
seam: z3.string().describe("Where the test goes: the highest seam that covers the behaviour (an endpoint, a page, a public function)"),
|
|
2363
|
+
covers: z3.array(z3.string()).describe("Ids of the criteria it covers")
|
|
1563
2364
|
})),
|
|
1564
|
-
manual:
|
|
1565
|
-
page:
|
|
1566
|
-
steps:
|
|
2365
|
+
manual: z3.array(z3.object({
|
|
2366
|
+
page: z3.string().describe("The page or screen"),
|
|
2367
|
+
steps: z3.string().describe("What a person does and sees")
|
|
1567
2368
|
}))
|
|
1568
2369
|
}),
|
|
1569
|
-
risks:
|
|
1570
|
-
risk:
|
|
1571
|
-
mitigation:
|
|
2370
|
+
risks: z3.array(z3.object({
|
|
2371
|
+
risk: z3.string(),
|
|
2372
|
+
mitigation: z3.string()
|
|
1572
2373
|
})).describe("What could go wrong: migrations, existing data, access, performance, compatibility"),
|
|
1573
|
-
riskLevel:
|
|
1574
|
-
assumptions:
|
|
1575
|
-
text:
|
|
1576
|
-
reason:
|
|
2374
|
+
riskLevel: z3.enum(["low", "medium", "high"]).describe("How closely a person should review the result"),
|
|
2375
|
+
assumptions: z3.array(z3.object({
|
|
2376
|
+
text: z3.string().describe("The default you chose"),
|
|
2377
|
+
reason: z3.string().describe("Why, so the owner can confirm or correct it quickly")
|
|
1577
2378
|
})).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
|
|
1578
|
-
openQuestions:
|
|
1579
|
-
split:
|
|
2379
|
+
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"),
|
|
2380
|
+
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")
|
|
1580
2381
|
});
|
|
1581
|
-
|
|
2382
|
+
var resultBriefSchema = z3.object({
|
|
2383
|
+
format: z3.literal("brief").describe('Always "brief"'),
|
|
2384
|
+
goal: z3.string().describe("What the work is for, for whom, and the outcome people will notice"),
|
|
2385
|
+
context: z3.string().describe("Summary: the situation in 1-2 sentences, then how the work will be done in 1-2 sentences"),
|
|
2386
|
+
contains: z3.array(z3.string()).min(1).describe("What the result must contain, one item each: the parts, facts and attachments a reader expects"),
|
|
2387
|
+
acceptance: z3.array(z3.object({
|
|
2388
|
+
id: z3.string().describe("AC-1, AC-2 and so on, in order"),
|
|
2389
|
+
text: z3.string().describe("One observable property of the result that a checker can confirm by reading it")
|
|
2390
|
+
})).min(1).describe("Acceptance criteria the checks judge the result by, the most important first"),
|
|
2391
|
+
constraints: z3.array(z3.string()).describe("Rules the result must keep: length, tone, language, deadlines, what must not be promised. Empty when none"),
|
|
2392
|
+
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"),
|
|
2393
|
+
sources: z3.array(z3.object({
|
|
2394
|
+
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"),
|
|
2395
|
+
use: z3.string().describe("What to take from it")
|
|
2396
|
+
})).describe("Sources the work must use. Empty when the task itself is enough"),
|
|
2397
|
+
approver: z3.string().describe("Who approves the result, as the task or the board names them"),
|
|
2398
|
+
decisions: z3.array(z3.object({
|
|
2399
|
+
question: z3.string(),
|
|
2400
|
+
answer: z3.string(),
|
|
2401
|
+
by: z3.enum(["owner", "agent"]).describe("owner: answered by a person; agent: your own call")
|
|
2402
|
+
})).describe("Every answered question with by: owner, and your own calls that matter with by: agent"),
|
|
2403
|
+
assumptions: z3.array(z3.object({
|
|
2404
|
+
text: z3.string().describe("The default you chose"),
|
|
2405
|
+
reason: z3.string().describe("Why, so the owner can confirm or correct it quickly")
|
|
2406
|
+
})).describe("Defaults chosen where the task was silent. Empty when nothing was assumed"),
|
|
2407
|
+
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")
|
|
2408
|
+
});
|
|
2409
|
+
function reply2(text2, isError = false) {
|
|
1582
2410
|
return { content: [{ type: "text", text: text2 }], ...isError ? { isError: true } : {} };
|
|
1583
2411
|
}
|
|
1584
2412
|
function unreachable(error, toolName) {
|
|
1585
2413
|
const message = error instanceof Error ? error.message : String(error);
|
|
1586
|
-
return
|
|
2414
|
+
return reply2(`Could not reach TAMsense to check this: ${message}. Call ${toolName} again in a moment.`, true);
|
|
1587
2415
|
}
|
|
1588
2416
|
function questionsTool(review, onSubmit) {
|
|
1589
|
-
return
|
|
2417
|
+
return tool3(
|
|
1590
2418
|
SUBMIT_QUESTIONS_TOOL_NAME,
|
|
1591
2419
|
"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.",
|
|
1592
2420
|
questionsPayloadSchema.shape,
|
|
@@ -1598,10 +2426,10 @@ function questionsTool(review, onSubmit) {
|
|
|
1598
2426
|
return unreachable(error, SUBMIT_QUESTIONS_TOOL_NAME);
|
|
1599
2427
|
}
|
|
1600
2428
|
if (!verdict.accepted) {
|
|
1601
|
-
return
|
|
2429
|
+
return reply2(verdict.feedback, true);
|
|
1602
2430
|
}
|
|
1603
2431
|
onSubmit(args);
|
|
1604
|
-
return
|
|
2432
|
+
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.");
|
|
1605
2433
|
}
|
|
1606
2434
|
);
|
|
1607
2435
|
}
|
|
@@ -1609,7 +2437,7 @@ var BUILDER_CHECK_FIRST = "The spec passed the lint but was not saved yet: the b
|
|
|
1609
2437
|
function specTool(review, onSubmit, builderCheckRan) {
|
|
1610
2438
|
let repairs = 0;
|
|
1611
2439
|
let reminded = false;
|
|
1612
|
-
return
|
|
2440
|
+
return tool3(
|
|
1613
2441
|
SUBMIT_SPEC_TOOL_NAME,
|
|
1614
2442
|
"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.",
|
|
1615
2443
|
specSchema.shape,
|
|
@@ -1622,14 +2450,36 @@ function specTool(review, onSubmit, builderCheckRan) {
|
|
|
1622
2450
|
}
|
|
1623
2451
|
if (!verdict.accepted) {
|
|
1624
2452
|
repairs += 1;
|
|
1625
|
-
return
|
|
2453
|
+
return reply2(verdict.feedback, true);
|
|
1626
2454
|
}
|
|
1627
2455
|
if (!builderCheckRan() && !reminded) {
|
|
1628
2456
|
reminded = true;
|
|
1629
|
-
return
|
|
2457
|
+
return reply2(BUILDER_CHECK_FIRST, true);
|
|
2458
|
+
}
|
|
2459
|
+
onSubmit({ spec: args, problems: verdict.problems });
|
|
2460
|
+
return reply2(verdict.problems.length > 0 ? `Spec saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Spec saved.");
|
|
2461
|
+
}
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
function briefTool(review, onSubmit) {
|
|
2465
|
+
let repairs = 0;
|
|
2466
|
+
return tool3(
|
|
2467
|
+
SUBMIT_SPEC_TOOL_NAME,
|
|
2468
|
+
"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.",
|
|
2469
|
+
resultBriefSchema.shape,
|
|
2470
|
+
async (args) => {
|
|
2471
|
+
let verdict;
|
|
2472
|
+
try {
|
|
2473
|
+
verdict = await review({ spec: args, repairs });
|
|
2474
|
+
} catch (error) {
|
|
2475
|
+
return unreachable(error, SUBMIT_SPEC_TOOL_NAME);
|
|
2476
|
+
}
|
|
2477
|
+
if (!verdict.accepted) {
|
|
2478
|
+
repairs += 1;
|
|
2479
|
+
return reply2(verdict.feedback, true);
|
|
1630
2480
|
}
|
|
1631
2481
|
onSubmit({ spec: args, problems: verdict.problems });
|
|
1632
|
-
return
|
|
2482
|
+
return reply2(verdict.problems.length > 0 ? `Brief saved with these problems left: ${verdict.problems.join("; ")}. Mention them in your reply.` : "Brief saved.");
|
|
1633
2483
|
}
|
|
1634
2484
|
);
|
|
1635
2485
|
}
|
|
@@ -1731,10 +2581,9 @@ function usageLimitWatch() {
|
|
|
1731
2581
|
// src/claude.ts
|
|
1732
2582
|
var NO_RESULT_TEXT = "Claude finished without a result.";
|
|
1733
2583
|
var SERVER_NAME = "tamsense";
|
|
1734
|
-
var LIBRARY_PATH = fileURLToPath(new URL("../library", import.meta.url));
|
|
1735
2584
|
var CAPTION_FIELDS = {
|
|
1736
|
-
title:
|
|
1737
|
-
purpose:
|
|
2585
|
+
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"'),
|
|
2586
|
+
purpose: z4.string().describe('one line, at most 160 characters, on why it was made: "Comparing two layouts for the empty orders state"')
|
|
1738
2587
|
};
|
|
1739
2588
|
var ATTACH_TOOLS = {
|
|
1740
2589
|
screenshot: {
|
|
@@ -1755,21 +2604,21 @@ var ATTACH_TOOLS = {
|
|
|
1755
2604
|
};
|
|
1756
2605
|
function attachTool(kind, attach) {
|
|
1757
2606
|
const spec = ATTACH_TOOLS[kind];
|
|
1758
|
-
return
|
|
2607
|
+
return tool4(
|
|
1759
2608
|
spec.name,
|
|
1760
2609
|
spec.description,
|
|
1761
|
-
{ path:
|
|
2610
|
+
{ path: z4.string().describe(spec.path), ...CAPTION_FIELDS },
|
|
1762
2611
|
async (args) => ({ content: [{ type: "text", text: await attach({ kind, ...args }) }] })
|
|
1763
2612
|
);
|
|
1764
2613
|
}
|
|
1765
2614
|
function verdictTool(onVerdict) {
|
|
1766
|
-
return
|
|
2615
|
+
return tool4(
|
|
1767
2616
|
VERDICT_TOOL_NAME,
|
|
1768
2617
|
"Hand in the result of this check. Call it exactly once, at the very end.",
|
|
1769
2618
|
{
|
|
1770
|
-
passed:
|
|
1771
|
-
report:
|
|
1772
|
-
couldNotRun:
|
|
2619
|
+
passed: z4.boolean().describe("true only if nothing must be fixed"),
|
|
2620
|
+
report: z4.string().describe("what you checked and, if it failed, exactly what to fix with file and line"),
|
|
2621
|
+
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")
|
|
1773
2622
|
},
|
|
1774
2623
|
async (args) => {
|
|
1775
2624
|
onVerdict(args.couldNotRun ? { passed: false, report: args.report, couldNotRun: true } : { passed: args.passed, report: args.report });
|
|
@@ -1778,13 +2627,13 @@ function verdictTool(onVerdict) {
|
|
|
1778
2627
|
);
|
|
1779
2628
|
}
|
|
1780
2629
|
function handoffTool(targets, onHandoff) {
|
|
1781
|
-
return
|
|
2630
|
+
return tool4(
|
|
1782
2631
|
HANDOFF_TOOL_NAME,
|
|
1783
2632
|
"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.",
|
|
1784
2633
|
{
|
|
1785
|
-
to:
|
|
1786
|
-
outcome:
|
|
1787
|
-
note:
|
|
2634
|
+
to: z4.string().describe("name or id of the step to hand the task to"),
|
|
2635
|
+
outcome: z4.enum(HANDOFF_OUTCOMES).describe("pass: the work is done; problems: something must be fixed; ask: a question for that step"),
|
|
2636
|
+
note: z4.string().describe("for the next agent and the owner: what was done, what to check, what is open")
|
|
1788
2637
|
},
|
|
1789
2638
|
async (args) => {
|
|
1790
2639
|
const choice = chooseHandoff(targets, args);
|
|
@@ -1797,25 +2646,25 @@ function handoffTool(targets, onHandoff) {
|
|
|
1797
2646
|
);
|
|
1798
2647
|
}
|
|
1799
2648
|
function askPersonTool(asking, waiting) {
|
|
1800
|
-
return
|
|
2649
|
+
return tool4(
|
|
1801
2650
|
ASK_PERSON_TOOL_NAME,
|
|
1802
2651
|
"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.",
|
|
1803
2652
|
{
|
|
1804
|
-
to:
|
|
1805
|
-
why:
|
|
1806
|
-
question:
|
|
1807
|
-
context:
|
|
1808
|
-
tried:
|
|
1809
|
-
did:
|
|
1810
|
-
error:
|
|
1811
|
-
result:
|
|
2653
|
+
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"),
|
|
2654
|
+
why: z4.string().describe('one line on why this person: "assignee, and the run is on her computer", "Designer, owns empty states"'),
|
|
2655
|
+
question: z4.string().describe("one plain question the person can answer without reading the code"),
|
|
2656
|
+
context: z4.string().optional().describe("one line on what you saw that led to the question"),
|
|
2657
|
+
tried: z4.array(z4.object({
|
|
2658
|
+
did: z4.string().describe("the command or step"),
|
|
2659
|
+
error: z4.string().optional().describe("the exact error line, if there was one"),
|
|
2660
|
+
result: z4.string().describe("what it showed, one line")
|
|
1812
2661
|
})).max(MAX_ASK_TRIES).optional().describe("what you already tried, the most telling first"),
|
|
1813
|
-
options:
|
|
1814
|
-
label:
|
|
1815
|
-
detail:
|
|
2662
|
+
options: z4.array(z4.object({
|
|
2663
|
+
label: z4.string().describe("a short answer the person can pick"),
|
|
2664
|
+
detail: z4.string().describe("one line on what happens if it is picked")
|
|
1816
2665
|
})).max(MAX_ASK_OPTIONS).optional().describe("2 to 4 answers you suggest, the one you would pick first"),
|
|
1817
|
-
blocking:
|
|
1818
|
-
assumption:
|
|
2666
|
+
blocking: z4.boolean().describe("true when you cannot go on without the answer"),
|
|
2667
|
+
assumption: z4.string().optional().describe("when blocking is false: what you continue with until the answer comes")
|
|
1819
2668
|
},
|
|
1820
2669
|
async (args) => {
|
|
1821
2670
|
if (waiting.waitingFor !== null) {
|
|
@@ -1825,16 +2674,16 @@ function askPersonTool(asking, waiting) {
|
|
|
1825
2674
|
if ("error" in choice) {
|
|
1826
2675
|
return { content: [{ type: "text", text: choice.error }], isError: true };
|
|
1827
2676
|
}
|
|
1828
|
-
let
|
|
2677
|
+
let reply3;
|
|
1829
2678
|
try {
|
|
1830
|
-
|
|
2679
|
+
reply3 = await asking.send(choice.body);
|
|
1831
2680
|
} catch (error) {
|
|
1832
2681
|
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 };
|
|
1833
2682
|
}
|
|
1834
2683
|
if (choice.body.blocking) {
|
|
1835
|
-
waiting.waitingFor =
|
|
2684
|
+
waiting.waitingFor = reply3.id;
|
|
1836
2685
|
}
|
|
1837
|
-
return { content: [{ type: "text", text: askedText(
|
|
2686
|
+
return { content: [{ type: "text", text: askedText(reply3.name, choice.body) }] };
|
|
1838
2687
|
}
|
|
1839
2688
|
);
|
|
1840
2689
|
}
|
|
@@ -1842,8 +2691,13 @@ function specWorkTools(specWork, submitted) {
|
|
|
1842
2691
|
if (!specWork) {
|
|
1843
2692
|
return [];
|
|
1844
2693
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
2694
|
+
if (specWork.stage === "interview") {
|
|
2695
|
+
return [questionsTool(specWork.review, (questions) => {
|
|
2696
|
+
submitted.questions = questions;
|
|
2697
|
+
})];
|
|
2698
|
+
}
|
|
2699
|
+
return specWork.brief ? [briefTool(specWork.review, (brief) => {
|
|
2700
|
+
submitted.spec = brief;
|
|
1847
2701
|
})] : [specTool(specWork.review, (spec) => {
|
|
1848
2702
|
submitted.spec = spec;
|
|
1849
2703
|
}, () => submitted.helperStarted)];
|
|
@@ -1871,7 +2725,9 @@ function tamsenseServer(request, submitted) {
|
|
|
1871
2725
|
...knowledgeTools(request.knowledge),
|
|
1872
2726
|
...request.handoffs.length > 0 ? [handoffTool(request.handoffs, (handoff) => {
|
|
1873
2727
|
submitted.handoff = handoff;
|
|
1874
|
-
})] : []
|
|
2728
|
+
})] : [],
|
|
2729
|
+
...request.results ? resultToolset(request.results) : [],
|
|
2730
|
+
...request.handOver ? [handOverTool(request.handOver)] : []
|
|
1875
2731
|
];
|
|
1876
2732
|
return {
|
|
1877
2733
|
server: createSdkMcpServer({ name: SERVER_NAME, version: "1.0.0", tools }),
|
|
@@ -1898,6 +2754,7 @@ async function attempt(request, context, resume) {
|
|
|
1898
2754
|
let outcome = { completed: false, report: NO_RESULT_TEXT, model: null, usage: null, verdict: null };
|
|
1899
2755
|
const tamsense = tamsenseServer(request, context.submitted);
|
|
1900
2756
|
const shown = hiddenStepsFilter();
|
|
2757
|
+
const helpers = helperTasks();
|
|
1901
2758
|
const messages = context.startQuery({
|
|
1902
2759
|
prompt: request.input.start(request.prompt.prompt),
|
|
1903
2760
|
options: {
|
|
@@ -1909,7 +2766,7 @@ async function attempt(request, context, resume) {
|
|
|
1909
2766
|
thinking: { type: "adaptive", display: "summarized" },
|
|
1910
2767
|
// режим auto сам решает, что можно; о чём он спросил бы человека, решают правила проекта или люди задачи
|
|
1911
2768
|
canUseTool: request.permissions ? askPermission(request.permissions, () => context.lastWords.text, request.cwd) : denyWhenAsked,
|
|
1912
|
-
plugins: [{ type: "local", path:
|
|
2769
|
+
plugins: [{ type: "local", path: request.library }],
|
|
1913
2770
|
mcpServers: { [SERVER_NAME]: tamsense.server },
|
|
1914
2771
|
allowedTools: tamsense.allowed,
|
|
1915
2772
|
abortController: context.abortController,
|
|
@@ -1919,6 +2776,7 @@ async function attempt(request, context, resume) {
|
|
|
1919
2776
|
}
|
|
1920
2777
|
});
|
|
1921
2778
|
request.input.onSwitch((change) => applySwitch(messages, change));
|
|
2779
|
+
request.input.onStopHelper((ref) => helpers.stop(messages, ref));
|
|
1922
2780
|
const failedToResume = () => resume !== null && sessionId === null && !request.signal.aborted;
|
|
1923
2781
|
try {
|
|
1924
2782
|
for await (const message of messages) {
|
|
@@ -1930,7 +2788,7 @@ async function attempt(request, context, resume) {
|
|
|
1930
2788
|
messages.close();
|
|
1931
2789
|
return { outcome, sessionId, resumeFailed: true };
|
|
1932
2790
|
}
|
|
1933
|
-
for (const draft of feedOf(message).filter(shown)) {
|
|
2791
|
+
for (const draft of [...helpers.observe(message), ...feedOf(message).filter(shown).flatMap(helpers.adjust)]) {
|
|
1934
2792
|
if (draft.kind === "agent" && draft.parent === null) {
|
|
1935
2793
|
context.submitted.helperStarted = true;
|
|
1936
2794
|
}
|
|
@@ -1981,13 +2839,13 @@ function claudeRunner(startQuery) {
|
|
|
1981
2839
|
var runClaude = claudeRunner(query2);
|
|
1982
2840
|
|
|
1983
2841
|
// src/config.ts
|
|
1984
|
-
import { mkdir as
|
|
2842
|
+
import { mkdir as mkdir3, readFile as readFile3, rm as rm4, writeFile as writeFile3 } from "fs/promises";
|
|
1985
2843
|
import { homedir as homedir2 } from "os";
|
|
1986
|
-
import { dirname as dirname2, join as
|
|
2844
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
1987
2845
|
var OWNER_ONLY_FILE = 384;
|
|
1988
2846
|
var OWNER_ONLY_FOLDER = 448;
|
|
1989
2847
|
function configPath(home = homedir2()) {
|
|
1990
|
-
return
|
|
2848
|
+
return join6(home, ".tamsense", "runner.json");
|
|
1991
2849
|
}
|
|
1992
2850
|
function isRecord2(value) {
|
|
1993
2851
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -2005,7 +2863,7 @@ function isMissingFile(error) {
|
|
|
2005
2863
|
}
|
|
2006
2864
|
async function readConfig(path = configPath()) {
|
|
2007
2865
|
try {
|
|
2008
|
-
return parseConfig(JSON.parse(await
|
|
2866
|
+
return parseConfig(JSON.parse(await readFile3(path, "utf8")));
|
|
2009
2867
|
} catch (error) {
|
|
2010
2868
|
if (isMissingFile(error) || error instanceof SyntaxError) {
|
|
2011
2869
|
return null;
|
|
@@ -2014,34 +2872,12 @@ async function readConfig(path = configPath()) {
|
|
|
2014
2872
|
}
|
|
2015
2873
|
}
|
|
2016
2874
|
async function writeConfig(config, path = configPath()) {
|
|
2017
|
-
await
|
|
2018
|
-
await
|
|
2875
|
+
await mkdir3(dirname2(path), { recursive: true, mode: OWNER_ONLY_FOLDER });
|
|
2876
|
+
await writeFile3(path, `${JSON.stringify(config, null, 4)}
|
|
2019
2877
|
`, { mode: OWNER_ONLY_FILE });
|
|
2020
2878
|
}
|
|
2021
2879
|
async function removeConfig(path = configPath()) {
|
|
2022
|
-
await
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
// src/protocol.ts
|
|
2026
|
-
var MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
2027
|
-
var MAX_ATTACHMENTS_PER_RUN = 20;
|
|
2028
|
-
var MAX_VIDEO_BYTES = 200 * 1024 * 1024;
|
|
2029
|
-
var PAIRINGS_PATH = "/api/runner/pairings";
|
|
2030
|
-
var PAIRING_TOKEN_PATH = "/api/runner/pairings/token";
|
|
2031
|
-
var IDENTITY_PATH = "/api/runner/me";
|
|
2032
|
-
var NEXT_RUN_PATH = "/api/runner/runs/next";
|
|
2033
|
-
var CAPABILITIES_PATH = "/api/runner/me/capabilities";
|
|
2034
|
-
function runPath(runId, action) {
|
|
2035
|
-
return `/api/runner/runs/${runId}/${action}`;
|
|
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
|
-
}
|
|
2043
|
-
function recordingUploadPath(runId, upload, part) {
|
|
2044
|
-
return `/api/runner/runs/${runId}/recordings/${upload}/${part}`;
|
|
2880
|
+
await rm4(path, { force: true });
|
|
2045
2881
|
}
|
|
2046
2882
|
|
|
2047
2883
|
// src/login.ts
|
|
@@ -2058,14 +2894,14 @@ async function login(tools, request) {
|
|
|
2058
2894
|
tools.openBrowser(pageUrl);
|
|
2059
2895
|
while (tools.now() < expiresAt) {
|
|
2060
2896
|
await tools.sleep(pairing.intervalSeconds * MS_PER_SECOND);
|
|
2061
|
-
const
|
|
2062
|
-
if (
|
|
2063
|
-
return { token:
|
|
2897
|
+
const reply3 = await tools.api.request("POST", PAIRING_TOKEN_PATH, { deviceCode: pairing.deviceCode });
|
|
2898
|
+
if (reply3.status === "connected") {
|
|
2899
|
+
return { token: reply3.token, device: reply3.device };
|
|
2064
2900
|
}
|
|
2065
|
-
if (
|
|
2901
|
+
if (reply3.status === "denied") {
|
|
2066
2902
|
throw new Error(DENIED_TEXT);
|
|
2067
2903
|
}
|
|
2068
|
-
if (
|
|
2904
|
+
if (reply3.status === "expired") {
|
|
2069
2905
|
break;
|
|
2070
2906
|
}
|
|
2071
2907
|
}
|
|
@@ -2073,15 +2909,15 @@ async function login(tools, request) {
|
|
|
2073
2909
|
}
|
|
2074
2910
|
|
|
2075
2911
|
// src/recorderEnv.ts
|
|
2076
|
-
import { existsSync as
|
|
2077
|
-
import { join as
|
|
2078
|
-
import { fileURLToPath
|
|
2912
|
+
import { existsSync as existsSync5 } from "fs";
|
|
2913
|
+
import { join as join8 } from "path";
|
|
2914
|
+
import { fileURLToPath } from "url";
|
|
2079
2915
|
|
|
2080
2916
|
// src/recorder.ts
|
|
2081
2917
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2082
2918
|
import Module, { createRequire } from "module";
|
|
2083
|
-
import { basename, dirname as dirname3, join as
|
|
2084
|
-
import
|
|
2919
|
+
import { basename as basename3, dirname as dirname3, join as join7 } from "path";
|
|
2920
|
+
import process4 from "process";
|
|
2085
2921
|
var RECORDINGS_DIR_ENV = "TAMSENSE_RECORDINGS_DIR";
|
|
2086
2922
|
var SERVER_ENV = "TAMSENSE_SERVER";
|
|
2087
2923
|
var DEFAULT_TITLE_ENV = "TAMSENSE_RECORDING_TITLE";
|
|
@@ -2105,14 +2941,14 @@ function state() {
|
|
|
2105
2941
|
return holder[STATE_KEY];
|
|
2106
2942
|
}
|
|
2107
2943
|
function recordingsDir() {
|
|
2108
|
-
const dir =
|
|
2944
|
+
const dir = process4.env[RECORDINGS_DIR_ENV];
|
|
2109
2945
|
if (!dir) {
|
|
2110
2946
|
throw new Error(`${RECORDINGS_DIR_ENV} is not set: recordings work only inside a TAMsense run`);
|
|
2111
2947
|
}
|
|
2112
2948
|
return dir;
|
|
2113
2949
|
}
|
|
2114
2950
|
function warn(text2) {
|
|
2115
|
-
|
|
2951
|
+
process4.stderr.write(`[tamsense recorder] ${text2}
|
|
2116
2952
|
`);
|
|
2117
2953
|
}
|
|
2118
2954
|
function isTrackerIngest(url, tamsenseOrigins) {
|
|
@@ -2128,7 +2964,7 @@ function isLocalHost(hostname2) {
|
|
|
2128
2964
|
function isExternalWrite(method, url) {
|
|
2129
2965
|
return !READ_METHODS.includes(method.toUpperCase()) && !isLocalHost(url.hostname);
|
|
2130
2966
|
}
|
|
2131
|
-
function trackerOrigins(env =
|
|
2967
|
+
function trackerOrigins(env = process4.env) {
|
|
2132
2968
|
const origins = new Set(DEFAULT_TRACKER_ORIGINS);
|
|
2133
2969
|
try {
|
|
2134
2970
|
if (env[SERVER_ENV]) {
|
|
@@ -2152,8 +2988,8 @@ function checkCaption(caption) {
|
|
|
2152
2988
|
function clip2(text2, max) {
|
|
2153
2989
|
return text2.length > max ? `${text2.slice(0, max - 1).trimEnd()}\u2026` : text2;
|
|
2154
2990
|
}
|
|
2155
|
-
function automaticCaption(env =
|
|
2156
|
-
const from = script2 ? `, opened by ${
|
|
2991
|
+
function automaticCaption(env = process4.env, script2 = process4.argv[1]) {
|
|
2992
|
+
const from = script2 ? `, opened by ${basename3(script2)}` : "";
|
|
2157
2993
|
return {
|
|
2158
2994
|
title: clip2(env[DEFAULT_TITLE_ENV]?.trim() || "Browser session", MAX_TITLE_LENGTH),
|
|
2159
2995
|
purpose: clip2(`${env[DEFAULT_PURPOSE_ENV]?.trim() || "Recorded automatically while the agent used a browser"}${from}`, MAX_PURPOSE_LENGTH)
|
|
@@ -2161,7 +2997,7 @@ function automaticCaption(env = process2.env, script2 = process2.argv[1]) {
|
|
|
2161
2997
|
}
|
|
2162
2998
|
function rrwebSource() {
|
|
2163
2999
|
const entry = createRequire(import.meta.url).resolve("rrweb");
|
|
2164
|
-
return readFileSync(
|
|
3000
|
+
return readFileSync(join7(dirname3(entry), "rrweb.umd.min.cjs"), "utf8");
|
|
2165
3001
|
}
|
|
2166
3002
|
function pageScript(rrweb) {
|
|
2167
3003
|
return `(() => {
|
|
@@ -2212,8 +3048,8 @@ var Session = class {
|
|
|
2212
3048
|
bytes = 0;
|
|
2213
3049
|
constructor(dir, recording, url) {
|
|
2214
3050
|
const id = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
2215
|
-
this.file =
|
|
2216
|
-
this.metaFile =
|
|
3051
|
+
this.file = join7(dir, `${id}${REPLAY_SUFFIX}`);
|
|
3052
|
+
this.metaFile = join7(dir, `${id}${META_SUFFIX}`);
|
|
2217
3053
|
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 };
|
|
2218
3054
|
writeFileSync(this.file, "");
|
|
2219
3055
|
this.save();
|
|
@@ -2378,18 +3214,18 @@ function installAutoRecording() {
|
|
|
2378
3214
|
return exports;
|
|
2379
3215
|
};
|
|
2380
3216
|
}
|
|
2381
|
-
if (
|
|
3217
|
+
if (process4.env[RECORDINGS_DIR_ENV]) {
|
|
2382
3218
|
installAutoRecording();
|
|
2383
3219
|
}
|
|
2384
3220
|
|
|
2385
3221
|
// src/recorderEnv.ts
|
|
2386
3222
|
var RECORDER_ENV = "TAMSENSE_RECORDER";
|
|
2387
3223
|
function bundledRecorder() {
|
|
2388
|
-
const path =
|
|
2389
|
-
return
|
|
3224
|
+
const path = fileURLToPath(new URL("./recorder.cjs", import.meta.url));
|
|
3225
|
+
return existsSync5(path) ? path : null;
|
|
2390
3226
|
}
|
|
2391
3227
|
function recordingsFolder(home, runId) {
|
|
2392
|
-
return
|
|
3228
|
+
return join8(home, ".tamsense", "recordings", `run-${runId}`);
|
|
2393
3229
|
}
|
|
2394
3230
|
function nodeOptionsWithRecorder(recorder, nodeOptions) {
|
|
2395
3231
|
return [nodeOptions?.trim(), `--require "${recorder.replaceAll("\\", "/")}"`].filter(Boolean).join(" ");
|
|
@@ -2425,12 +3261,12 @@ var PLATFORM_LABELS = {
|
|
|
2425
3261
|
darwin: "macOS",
|
|
2426
3262
|
linux: "Linux"
|
|
2427
3263
|
};
|
|
2428
|
-
var
|
|
3264
|
+
var MAX_NAME_LENGTH2 = 80;
|
|
2429
3265
|
function platformLabel(platform = process.platform) {
|
|
2430
3266
|
return PLATFORM_LABELS[platform] ?? platform;
|
|
2431
3267
|
}
|
|
2432
3268
|
function defaultDeviceName() {
|
|
2433
|
-
return hostname().slice(0,
|
|
3269
|
+
return hostname().slice(0, MAX_NAME_LENGTH2) || "My computer";
|
|
2434
3270
|
}
|
|
2435
3271
|
function browserCommand(url, platform) {
|
|
2436
3272
|
if (platform === "win32") {
|
|
@@ -2462,7 +3298,7 @@ async function sleep(ms, signal) {
|
|
|
2462
3298
|
// src/update.ts
|
|
2463
3299
|
import { spawn as spawn4 } from "child_process";
|
|
2464
3300
|
import { readFileSync as readFileSync2 } from "fs";
|
|
2465
|
-
import
|
|
3301
|
+
import process5 from "process";
|
|
2466
3302
|
var PACKAGE_NAME = "@tamsensedev/runner";
|
|
2467
3303
|
var RELAUNCHED_ENV = "TAMSENSE_RELAUNCHED";
|
|
2468
3304
|
var NO_UPDATE_ENV = "TAMSENSE_NO_UPDATE";
|
|
@@ -2514,27 +3350,27 @@ function relaunchArgs(argv) {
|
|
|
2514
3350
|
return ["--yes", `${PACKAGE_NAME}@latest`, ...argv];
|
|
2515
3351
|
}
|
|
2516
3352
|
function relaunch(argv, env) {
|
|
2517
|
-
const isWindows =
|
|
2518
|
-
return new Promise((
|
|
3353
|
+
const isWindows = process5.platform === "win32";
|
|
3354
|
+
return new Promise((resolve5, reject) => {
|
|
2519
3355
|
const child = spawn4(isWindows ? "npx.cmd" : "npx", relaunchArgs(argv), { stdio: "inherit", shell: isWindows, env: { ...env, [RELAUNCHED_ENV]: "1" } });
|
|
2520
3356
|
const ignoreInterrupt = () => {
|
|
2521
3357
|
};
|
|
2522
|
-
|
|
3358
|
+
process5.on("SIGINT", ignoreInterrupt);
|
|
2523
3359
|
child.on("error", (error) => {
|
|
2524
|
-
|
|
3360
|
+
process5.off("SIGINT", ignoreInterrupt);
|
|
2525
3361
|
reject(error);
|
|
2526
3362
|
});
|
|
2527
3363
|
child.on("close", (code) => {
|
|
2528
|
-
|
|
2529
|
-
|
|
3364
|
+
process5.off("SIGINT", ignoreInterrupt);
|
|
3365
|
+
resolve5(code ?? 1);
|
|
2530
3366
|
});
|
|
2531
3367
|
});
|
|
2532
3368
|
}
|
|
2533
3369
|
|
|
2534
3370
|
// src/worker.ts
|
|
2535
|
-
import { rm as
|
|
3371
|
+
import { rm as rm6 } from "fs/promises";
|
|
2536
3372
|
import { dirname as dirname5 } from "path";
|
|
2537
|
-
import
|
|
3373
|
+
import process6 from "process";
|
|
2538
3374
|
|
|
2539
3375
|
// src/feedQueue.ts
|
|
2540
3376
|
var MAX_BATCH = 200;
|
|
@@ -2572,11 +3408,43 @@ function feedQueue(send) {
|
|
|
2572
3408
|
};
|
|
2573
3409
|
}
|
|
2574
3410
|
|
|
3411
|
+
// src/library.ts
|
|
3412
|
+
import { existsSync as existsSync6 } from "fs";
|
|
3413
|
+
import { cp, mkdir as mkdir4, rm as rm5 } from "fs/promises";
|
|
3414
|
+
import { join as join9 } from "path";
|
|
3415
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3416
|
+
var LIBRARY_PATH = fileURLToPath2(new URL("../library", import.meta.url));
|
|
3417
|
+
var SKILL_NAME = /^[a-z0-9-]+$/;
|
|
3418
|
+
var STAGE_SKILLS = {
|
|
3419
|
+
interview: ["task-interview"],
|
|
3420
|
+
spec: ["spec-writing", "task-interview"]
|
|
3421
|
+
};
|
|
3422
|
+
function allowedSkills(stage) {
|
|
3423
|
+
const skills = stage.role?.skills ?? null;
|
|
3424
|
+
return skills && [.../* @__PURE__ */ new Set([...skills, ...STAGE_SKILLS[stage.kind] ?? []])].filter((name) => SKILL_NAME.test(name));
|
|
3425
|
+
}
|
|
3426
|
+
function roleLibraryFolder(home, runId) {
|
|
3427
|
+
return join9(home, ".tamsense", "libraries", `run-${runId}`);
|
|
3428
|
+
}
|
|
3429
|
+
async function prepareLibrary(folder, skills, source = LIBRARY_PATH) {
|
|
3430
|
+
if (skills === null) {
|
|
3431
|
+
return source;
|
|
3432
|
+
}
|
|
3433
|
+
await rm5(folder, { recursive: true, force: true });
|
|
3434
|
+
await mkdir4(join9(folder, "skills"), { recursive: true });
|
|
3435
|
+
await cp(join9(source, ".claude-plugin"), join9(folder, ".claude-plugin"), { recursive: true });
|
|
3436
|
+
await cp(join9(source, "agents"), join9(folder, "agents"), { recursive: true });
|
|
3437
|
+
for (const skill of skills.filter((name) => SKILL_NAME.test(name) && existsSync6(join9(source, "skills", name)))) {
|
|
3438
|
+
await cp(join9(source, "skills", skill), join9(folder, "skills", skill), { recursive: true });
|
|
3439
|
+
}
|
|
3440
|
+
return folder;
|
|
3441
|
+
}
|
|
3442
|
+
|
|
2575
3443
|
// src/recordings.ts
|
|
2576
3444
|
import { randomUUID } from "crypto";
|
|
2577
3445
|
import { createReadStream } from "fs";
|
|
2578
|
-
import { readdir, readFile as
|
|
2579
|
-
import { basename as
|
|
3446
|
+
import { readdir, readFile as readFile4, stat as stat2, writeFile as writeFile4 } from "fs/promises";
|
|
3447
|
+
import { basename as basename4, dirname as dirname4, extname as extname2, isAbsolute as isAbsolute3, join as join10 } from "path";
|
|
2580
3448
|
import { createInterface as createInterface2 } from "readline";
|
|
2581
3449
|
import { Readable } from "stream";
|
|
2582
3450
|
import { promisify } from "util";
|
|
@@ -2637,23 +3505,23 @@ function marksFrom(marks, startTs) {
|
|
|
2637
3505
|
return marks.map((mark) => ({ at: Math.max(0, mark.ts - startTs), label: mark.label })).sort((a, b) => a.at - b.at);
|
|
2638
3506
|
}
|
|
2639
3507
|
function sessionIdOf(file) {
|
|
2640
|
-
return
|
|
3508
|
+
return basename4(file).replace(REPLAY_SUFFIX, "");
|
|
2641
3509
|
}
|
|
2642
3510
|
async function readMeta(file) {
|
|
2643
3511
|
try {
|
|
2644
|
-
return JSON.parse(await
|
|
3512
|
+
return JSON.parse(await readFile4(file, "utf8"));
|
|
2645
3513
|
} catch {
|
|
2646
3514
|
return null;
|
|
2647
3515
|
}
|
|
2648
3516
|
}
|
|
2649
3517
|
async function metaFor(kind, file) {
|
|
2650
3518
|
if (kind === "replay") {
|
|
2651
|
-
return readMeta(
|
|
3519
|
+
return readMeta(join10(dirname4(file), `${sessionIdOf(file)}${META_SUFFIX}`));
|
|
2652
3520
|
}
|
|
2653
|
-
const folder =
|
|
3521
|
+
const folder = basename4(dirname4(file)) === "video" ? dirname4(dirname4(file)) : dirname4(file);
|
|
2654
3522
|
for (const name of await readdir(folder).catch(() => [])) {
|
|
2655
3523
|
if (name.endsWith(META_SUFFIX) && name !== LEDGER_FILE) {
|
|
2656
|
-
const meta = await readMeta(
|
|
3524
|
+
const meta = await readMeta(join10(folder, name));
|
|
2657
3525
|
if (meta?.video === file) {
|
|
2658
3526
|
return meta;
|
|
2659
3527
|
}
|
|
@@ -2663,7 +3531,7 @@ async function metaFor(kind, file) {
|
|
|
2663
3531
|
}
|
|
2664
3532
|
async function ledger(dir) {
|
|
2665
3533
|
try {
|
|
2666
|
-
return new Set(JSON.parse(await
|
|
3534
|
+
return new Set(JSON.parse(await readFile4(join10(dir, LEDGER_FILE), "utf8")));
|
|
2667
3535
|
} catch {
|
|
2668
3536
|
return /* @__PURE__ */ new Set();
|
|
2669
3537
|
}
|
|
@@ -2671,10 +3539,10 @@ async function ledger(dir) {
|
|
|
2671
3539
|
async function remember(dir, file) {
|
|
2672
3540
|
const done2 = await ledger(dir);
|
|
2673
3541
|
done2.add(file);
|
|
2674
|
-
await
|
|
3542
|
+
await writeFile4(join10(dir, LEDGER_FILE), JSON.stringify([...done2]));
|
|
2675
3543
|
}
|
|
2676
3544
|
async function nonEmpty(file) {
|
|
2677
|
-
return file !== null && ((await
|
|
3545
|
+
return file !== null && ((await stat2(file).catch(() => null))?.size ?? 0) > 0;
|
|
2678
3546
|
}
|
|
2679
3547
|
async function pendingRecordings(dir) {
|
|
2680
3548
|
const done2 = await ledger(dir);
|
|
@@ -2684,8 +3552,8 @@ async function pendingRecordings(dir) {
|
|
|
2684
3552
|
if (!name.endsWith(META_SUFFIX) || name === LEDGER_FILE) {
|
|
2685
3553
|
continue;
|
|
2686
3554
|
}
|
|
2687
|
-
const meta = await readMeta(
|
|
2688
|
-
const replay =
|
|
3555
|
+
const meta = await readMeta(join10(dir, name));
|
|
3556
|
+
const replay = join10(dir, `${name.slice(0, -META_SUFFIX.length)}${REPLAY_SUFFIX}`);
|
|
2689
3557
|
if (!done2.has(replay) && await nonEmpty(replay)) {
|
|
2690
3558
|
pending.push({ kind: "replay", file: replay, meta });
|
|
2691
3559
|
}
|
|
@@ -2705,11 +3573,11 @@ async function uploadReplay(upload, file, caption) {
|
|
|
2705
3573
|
if (!scan.snapshot) {
|
|
2706
3574
|
throw new Error("This replay has no page snapshot, so it cannot be played. Was the page ever opened?");
|
|
2707
3575
|
}
|
|
2708
|
-
return finish(upload, { kind: "replay", upload: id, name:
|
|
3576
|
+
return finish(upload, { kind: "replay", upload: id, name: basename4(file), ...caption, contentType: "application/x-ndjson", durationMs: null, marks: marksFrom(scan.marks, scan.startTs) });
|
|
2709
3577
|
}
|
|
2710
3578
|
async function uploadVideo(upload, file, caption, meta) {
|
|
2711
|
-
const contentType = VIDEO_TYPES_BY_EXTENSION[
|
|
2712
|
-
const info = await
|
|
3579
|
+
const contentType = VIDEO_TYPES_BY_EXTENSION[extname2(file).toLowerCase()];
|
|
3580
|
+
const info = await stat2(file);
|
|
2713
3581
|
if (info.size > MAX_VIDEO_BYTES) {
|
|
2714
3582
|
throw new Error(`The video is larger than ${MAX_VIDEO_BYTES / 1024 / 1024} MB. Record a shorter one.`);
|
|
2715
3583
|
}
|
|
@@ -2720,16 +3588,16 @@ async function uploadVideo(upload, file, caption, meta) {
|
|
|
2720
3588
|
const endedAt = meta?.endedAt ?? info.mtimeMs;
|
|
2721
3589
|
const durationMs = Math.max(0, Math.round(endedAt - startedAt));
|
|
2722
3590
|
const marks = marksFrom(meta?.marks ?? [], startedAt).filter((mark) => mark.at <= durationMs);
|
|
2723
|
-
return finish(upload, { kind: "video", upload: id, name:
|
|
3591
|
+
return finish(upload, { kind: "video", upload: id, name: basename4(file), ...caption, contentType, durationMs, marks });
|
|
2724
3592
|
}
|
|
2725
3593
|
async function finish(upload, body) {
|
|
2726
3594
|
return (await upload.api.request("POST", runPath(upload.runId, "recordings"), body)).id;
|
|
2727
3595
|
}
|
|
2728
3596
|
async function attachRecording(api, runId, recordingsDir2, request) {
|
|
2729
|
-
if (!
|
|
3597
|
+
if (!isAbsolute3(request.path)) {
|
|
2730
3598
|
return `Give the absolute path of the ${request.kind === "replay" ? "replay (.rrweb.ndjson)" : "video (.webm or .mp4)"} file.`;
|
|
2731
3599
|
}
|
|
2732
|
-
const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) :
|
|
3600
|
+
const fitsKind = request.kind === "replay" ? request.path.endsWith(REPLAY_SUFFIX) : extname2(request.path).toLowerCase() in VIDEO_TYPES_BY_EXTENSION;
|
|
2733
3601
|
if (!fitsKind) {
|
|
2734
3602
|
return request.kind === "replay" ? `A replay is the ${REPLAY_SUFFIX} file the TAMsense recorder wrote.` : "Only WebM and MP4 videos can be attached.";
|
|
2735
3603
|
}
|
|
@@ -2794,19 +3662,19 @@ async function uploadPendingRecordings(api, runId, dir) {
|
|
|
2794
3662
|
}
|
|
2795
3663
|
|
|
2796
3664
|
// src/screenshot.ts
|
|
2797
|
-
import { readFile as
|
|
2798
|
-
import { basename as
|
|
2799
|
-
var
|
|
3665
|
+
import { readFile as readFile5, stat as stat3 } from "fs/promises";
|
|
3666
|
+
import { basename as basename5, extname as extname3, isAbsolute as isAbsolute4 } from "path";
|
|
3667
|
+
var TYPES_BY_EXTENSION2 = {
|
|
2800
3668
|
".png": "image/png",
|
|
2801
3669
|
".jpg": "image/jpeg",
|
|
2802
3670
|
".jpeg": "image/jpeg",
|
|
2803
3671
|
".webp": "image/webp"
|
|
2804
3672
|
};
|
|
2805
3673
|
async function readScreenshot(request) {
|
|
2806
|
-
if (!
|
|
3674
|
+
if (!isAbsolute4(request.path)) {
|
|
2807
3675
|
return "Give the absolute path of the image file.";
|
|
2808
3676
|
}
|
|
2809
|
-
const contentType =
|
|
3677
|
+
const contentType = TYPES_BY_EXTENSION2[extname3(request.path).toLowerCase()];
|
|
2810
3678
|
if (!contentType) {
|
|
2811
3679
|
return "Only PNG, JPEG and WebP images can be attached.";
|
|
2812
3680
|
}
|
|
@@ -2816,15 +3684,15 @@ async function readScreenshot(request) {
|
|
|
2816
3684
|
} catch (error) {
|
|
2817
3685
|
return error instanceof Error ? error.message : String(error);
|
|
2818
3686
|
}
|
|
2819
|
-
const info = await
|
|
3687
|
+
const info = await stat3(request.path).catch(() => null);
|
|
2820
3688
|
if (!info?.isFile()) {
|
|
2821
3689
|
return `There is no file at ${request.path}.`;
|
|
2822
3690
|
}
|
|
2823
3691
|
if (info.size > MAX_ATTACHMENT_BYTES) {
|
|
2824
3692
|
return "The image is larger than 5 MB. Take a smaller screenshot (viewport instead of full page, or JPEG).";
|
|
2825
3693
|
}
|
|
2826
|
-
const data = (await
|
|
2827
|
-
return { name:
|
|
3694
|
+
const data = (await readFile5(request.path)).toString("base64");
|
|
3695
|
+
return { name: basename5(request.path), ...caption, contentType, data };
|
|
2828
3696
|
}
|
|
2829
3697
|
|
|
2830
3698
|
// src/worker.ts
|
|
@@ -2887,17 +3755,31 @@ function messageLine(message) {
|
|
|
2887
3755
|
const line = `Message from ${message.authorName}: ${message.text}`.replace(/\s+/g, " ").trim();
|
|
2888
3756
|
return line.length > MAX_MESSAGE_LINE ? `${line.slice(0, MAX_MESSAGE_LINE - 1)}\u2026` : line;
|
|
2889
3757
|
}
|
|
3758
|
+
async function stopHelper(input, stop, print2) {
|
|
3759
|
+
try {
|
|
3760
|
+
const result = await input.stopHelper(stop.ref);
|
|
3761
|
+
if (result === "stopped") {
|
|
3762
|
+
input.note(helperStoppedNote(stop), "queue");
|
|
3763
|
+
print2(` ${stop.byName} stopped the helper ${stop.name}`);
|
|
3764
|
+
} else if (result === "unknown" && input.note(helperStopRequest(stop), "steer")) {
|
|
3765
|
+
print2(` ${stop.byName} asked Claude to stop the helper ${stop.name}`);
|
|
3766
|
+
}
|
|
3767
|
+
} catch (error) {
|
|
3768
|
+
print2(`Could not stop the helper ${stop.name}: ${messageOf3(error)}`);
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
2890
3771
|
function inbox(job, tools, record, closedOnSite) {
|
|
2891
3772
|
const input = inputChannel({ onIdle: () => void poll() });
|
|
3773
|
+
const stopsSeen = /* @__PURE__ */ new Set();
|
|
2892
3774
|
let running = null;
|
|
2893
3775
|
let again = false;
|
|
2894
3776
|
let failing = false;
|
|
2895
3777
|
async function check() {
|
|
2896
3778
|
const ack = input.takeDelivered();
|
|
2897
3779
|
const body = { ack };
|
|
2898
|
-
let
|
|
3780
|
+
let reply3;
|
|
2899
3781
|
try {
|
|
2900
|
-
|
|
3782
|
+
reply3 = await tools.api.request("POST", runPath(job.runId, "inbox"), body);
|
|
2901
3783
|
} catch (error) {
|
|
2902
3784
|
input.restoreDelivered(ack);
|
|
2903
3785
|
if (!failing) {
|
|
@@ -2907,17 +3789,23 @@ function inbox(job, tools, record, closedOnSite) {
|
|
|
2907
3789
|
return;
|
|
2908
3790
|
}
|
|
2909
3791
|
failing = false;
|
|
2910
|
-
if (!
|
|
3792
|
+
if (!reply3.keepGoing) {
|
|
2911
3793
|
closedOnSite();
|
|
2912
3794
|
return;
|
|
2913
3795
|
}
|
|
2914
|
-
for (const message of
|
|
3796
|
+
for (const message of reply3.messages) {
|
|
2915
3797
|
if (input.push(message)) {
|
|
2916
3798
|
tools.print(` ${messageLine(message)}`);
|
|
2917
3799
|
}
|
|
2918
3800
|
}
|
|
2919
|
-
|
|
2920
|
-
|
|
3801
|
+
for (const stop of reply3.helperStops ?? []) {
|
|
3802
|
+
if (!stopsSeen.has(stop.ref)) {
|
|
3803
|
+
stopsSeen.add(stop.ref);
|
|
3804
|
+
void stopHelper(input, stop, tools.print);
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
if (reply3.model !== null || reply3.effort !== null) {
|
|
3808
|
+
const change = { model: reply3.model, effort: reply3.effort };
|
|
2921
3809
|
void input.switchModel(change).then(
|
|
2922
3810
|
(applied) => {
|
|
2923
3811
|
if (applied) {
|
|
@@ -2957,7 +3845,7 @@ function specWorkTools2(job, api) {
|
|
|
2957
3845
|
if (kind !== "interview" && kind !== "spec") {
|
|
2958
3846
|
return null;
|
|
2959
3847
|
}
|
|
2960
|
-
return { stage: kind, review: (body) => api.request("POST", runPath(job.runId, "review"), body) };
|
|
3848
|
+
return { stage: kind, review: (body) => api.request("POST", runPath(job.runId, "review"), body), brief: isFolderJob(job) };
|
|
2961
3849
|
}
|
|
2962
3850
|
function askingTools(job, api) {
|
|
2963
3851
|
const people = job.people ?? [];
|
|
@@ -2972,11 +3860,11 @@ function permissionTools(job, tools) {
|
|
|
2972
3860
|
}
|
|
2973
3861
|
return {
|
|
2974
3862
|
send: async (body) => {
|
|
2975
|
-
const
|
|
2976
|
-
if (
|
|
3863
|
+
const reply3 = await tools.api.request("POST", runPath(job.runId, "permissions"), body);
|
|
3864
|
+
if (reply3.decision === "wait") {
|
|
2977
3865
|
tools.print(` Waiting for a person on TAMsense to allow: ${body.title || body.action}`);
|
|
2978
3866
|
}
|
|
2979
|
-
return
|
|
3867
|
+
return reply3;
|
|
2980
3868
|
},
|
|
2981
3869
|
check: (id) => tools.api.request("GET", permissionPath(job.runId, id)),
|
|
2982
3870
|
sleep: tools.sleep
|
|
@@ -2995,21 +3883,46 @@ function knowledgeTools2(job, api) {
|
|
|
2995
3883
|
read: (entryId) => api.request("GET", knowledgePath(job.runId, entryId))
|
|
2996
3884
|
};
|
|
2997
3885
|
}
|
|
2998
|
-
|
|
2999
|
-
|
|
3886
|
+
function skillsLine(skills) {
|
|
3887
|
+
return `This role uses ${skills.length} skill(s) from the library: ${skills.join(", ")}.`;
|
|
3888
|
+
}
|
|
3889
|
+
async function prepare(job, tools) {
|
|
3890
|
+
if (isFolderJob(job)) {
|
|
3891
|
+
tools.print("Preparing the task folder\u2026");
|
|
3892
|
+
const { results, ...workspace2 } = await prepareFolder(job, taskFolder(tools.home, job), tools.api);
|
|
3893
|
+
return { workspace: workspace2, results, identity: {} };
|
|
3894
|
+
}
|
|
3895
|
+
if (isLocalJob(job)) {
|
|
3896
|
+
tools.print("Preparing the code from the folders on this computer\u2026");
|
|
3897
|
+
const workspace2 = await prepareLocalWorkspace(job, tools);
|
|
3898
|
+
return { workspace: workspace2, results: null, identity: await commitIdentityEnv(job.repositories[0]?.folder ?? tools.home) };
|
|
3899
|
+
}
|
|
3000
3900
|
tools.print("Preparing the code\u2026");
|
|
3001
3901
|
const workspace = await prepareWorkspace(job, await gitToken(tools.api, job), tools);
|
|
3002
|
-
|
|
3902
|
+
return { workspace, results: null, identity: await commitIdentityEnv(tools.home) };
|
|
3903
|
+
}
|
|
3904
|
+
async function publish(workspace, job, api, identity) {
|
|
3905
|
+
return isLocalJob(job) ? await publishLocalWorkspace(workspace, job, identity) : await publishWorkspace(workspace, job, await gitToken(api, job), identity);
|
|
3906
|
+
}
|
|
3907
|
+
async function executeJob(job, tools, signal, input, record) {
|
|
3908
|
+
const isCheck = job.stage.kind === "review" || job.stage.kind === "test";
|
|
3909
|
+
const { workspace, results, identity } = await prepare(job, tools);
|
|
3910
|
+
const skills = allowedSkills(job.stage);
|
|
3911
|
+
if (skills) {
|
|
3912
|
+
tools.print(skillsLine(skills));
|
|
3913
|
+
}
|
|
3914
|
+
const library = await prepareLibrary(roleLibraryFolder(tools.home, job.runId), skills);
|
|
3003
3915
|
signal.throwIfAborted();
|
|
3004
3916
|
tools.print("Claude is working\u2026");
|
|
3005
3917
|
const attached = { count: 0 };
|
|
3006
3918
|
const session = sessionReporter(tools.api, job, tools.print);
|
|
3007
3919
|
const recordings = recordingsFolder(tools.home, job.runId);
|
|
3008
|
-
const baseEnv = withNodeOnPath(
|
|
3920
|
+
const baseEnv = withNodeOnPath(process6.env, dirname5(process6.execPath));
|
|
3009
3921
|
const recording = tools.recorder ? recorderEnv({ recorder: tools.recorder, folder: recordings, server: tools.api.server, job, nodeOptions: baseEnv.NODE_OPTIONS }) : {};
|
|
3010
3922
|
const outcome = await tools.runAgent({
|
|
3011
3923
|
cwd: workspace.root,
|
|
3012
|
-
|
|
3924
|
+
library,
|
|
3925
|
+
prompt: buildTaskPrompt(job, workspace, { browser: Boolean(tools.browser), recorder: Boolean(tools.recorder), results }),
|
|
3013
3926
|
input,
|
|
3014
3927
|
env: { ...baseEnv, ...tools.browser ? browserEnv(tools.browser) : {}, ...recording, ...identity },
|
|
3015
3928
|
signal,
|
|
@@ -3024,13 +3937,15 @@ async function executeJob(job, tools, signal, input, record) {
|
|
|
3024
3937
|
effort: job.effort,
|
|
3025
3938
|
onFeed: record,
|
|
3026
3939
|
onSession: session.report,
|
|
3027
|
-
attach: (request) => request.kind === "screenshot" ? attachScreenshot(tools.api, job, request, attached) : attachRecording(tools.api, job.runId, recordings, request)
|
|
3940
|
+
attach: (request) => request.kind === "screenshot" ? attachScreenshot(tools.api, job, request, attached) : attachRecording(tools.api, job.runId, recordings, request),
|
|
3941
|
+
results: resultTools(job, tools.api, workspace.root),
|
|
3942
|
+
handOver: handOverTools(job, tools.api)
|
|
3028
3943
|
});
|
|
3029
3944
|
await session.sent();
|
|
3030
3945
|
signal.throwIfAborted();
|
|
3031
3946
|
const waitingFor = outcome.waitingFor ?? null;
|
|
3032
|
-
const shouldPublish = job.stage.kind === "code" && outcome.completed && waitingFor === null;
|
|
3033
|
-
const pushed = shouldPublish ? await
|
|
3947
|
+
const shouldPublish = job.stage.kind === "code" && !isFolderJob(job) && !isReadOnlyCode(job.stage) && outcome.completed && waitingFor === null;
|
|
3948
|
+
const pushed = shouldPublish ? await publish(workspace, job, tools.api, identity) : [];
|
|
3034
3949
|
return {
|
|
3035
3950
|
outcome: outcome.completed ? "succeeded" : "failed",
|
|
3036
3951
|
report: outcome.report,
|
|
@@ -3050,7 +3965,7 @@ function clockOf(iso) {
|
|
|
3050
3965
|
const time = new Date(iso);
|
|
3051
3966
|
return `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
|
|
3052
3967
|
}
|
|
3053
|
-
function resultLine(finish2) {
|
|
3968
|
+
function resultLine(finish2, workspace = "repositories") {
|
|
3054
3969
|
if (finish2.pausedUntil) {
|
|
3055
3970
|
return `Paused: ${USAGE_LIMIT_REPORT}, resumes at ${clockOf(finish2.pausedUntil)}`;
|
|
3056
3971
|
}
|
|
@@ -3069,7 +3984,16 @@ function resultLine(finish2) {
|
|
|
3069
3984
|
if (finish2.spec) {
|
|
3070
3985
|
return "Done. The spec waits for the owner on TAMsense.";
|
|
3071
3986
|
}
|
|
3072
|
-
|
|
3987
|
+
if (finish2.outcome !== "succeeded") {
|
|
3988
|
+
return `Failed: ${finish2.report}`;
|
|
3989
|
+
}
|
|
3990
|
+
if (workspace === "folder") {
|
|
3991
|
+
return "Done. The result is on the task on TAMsense.";
|
|
3992
|
+
}
|
|
3993
|
+
if (workspace === "local") {
|
|
3994
|
+
return finish2.pushed.length > 0 ? `Done. ${finish2.pushed.map(localBranchLine).join(" ")}` : "Done. Nothing new to commit.";
|
|
3995
|
+
}
|
|
3996
|
+
return `Done. Pushed ${finish2.pushed.length} branch(es), the pull requests open on TAMsense.`;
|
|
3073
3997
|
}
|
|
3074
3998
|
async function sendRecordings(job, tools) {
|
|
3075
3999
|
const folder = recordingsFolder(tools.home, job.runId);
|
|
@@ -3085,7 +4009,7 @@ async function sendRecordings(job, tools) {
|
|
|
3085
4009
|
tools.print(`Could not attach a recording: ${line}`);
|
|
3086
4010
|
}
|
|
3087
4011
|
} finally {
|
|
3088
|
-
await
|
|
4012
|
+
await rm6(folder, { recursive: true, force: true });
|
|
3089
4013
|
}
|
|
3090
4014
|
}
|
|
3091
4015
|
function failureReport(error, signal) {
|
|
@@ -3113,6 +4037,7 @@ async function runJob(job, tools, stop) {
|
|
|
3113
4037
|
} catch (error) {
|
|
3114
4038
|
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 };
|
|
3115
4039
|
} finally {
|
|
4040
|
+
await rm6(roleLibraryFolder(tools.home, job.runId), { recursive: true, force: true });
|
|
3116
4041
|
clearInterval(polling);
|
|
3117
4042
|
clearInterval(feeding);
|
|
3118
4043
|
stop.removeEventListener("abort", stopRun);
|
|
@@ -3124,14 +4049,14 @@ async function runJob(job, tools, stop) {
|
|
|
3124
4049
|
}
|
|
3125
4050
|
await feed.flush();
|
|
3126
4051
|
if (run2.signal.reason === CLOSED_ON_SITE) {
|
|
3127
|
-
await
|
|
4052
|
+
await rm6(recordingsFolder(tools.home, job.runId), { recursive: true, force: true });
|
|
3128
4053
|
tools.print("TAMsense closed this run while it was in progress. The result was not sent.");
|
|
3129
4054
|
return;
|
|
3130
4055
|
}
|
|
3131
4056
|
await sendRecordings(job, tools).catch((error) => tools.print(`Could not attach the browser recordings: ${messageOf3(error)}`));
|
|
3132
4057
|
try {
|
|
3133
4058
|
await tools.api.request("POST", runPath(job.runId, "finish"), finish2);
|
|
3134
|
-
tools.print(resultLine(finish2));
|
|
4059
|
+
tools.print(resultLine(finish2, job.workspace));
|
|
3135
4060
|
} catch (error) {
|
|
3136
4061
|
tools.print(`Could not send the result to TAMsense: ${messageOf3(error)}`);
|
|
3137
4062
|
}
|
|
@@ -3169,6 +4094,7 @@ async function work(tools, stop, parallel = 1) {
|
|
|
3169
4094
|
const running = /* @__PURE__ */ new Set();
|
|
3170
4095
|
const folderLock = keyedLock();
|
|
3171
4096
|
const reporting = reportCapabilities(tools, stopAll);
|
|
4097
|
+
const checking = tools.inspectFolder ? checkFolders({ ...tools, inspect: tools.inspectFolder }, stopAll) : Promise.resolve();
|
|
3172
4098
|
const start = (job) => {
|
|
3173
4099
|
const runTools = jobTools(job, tools, parallel);
|
|
3174
4100
|
const task = folderLock.run(stepFolder(job).join("/"), () => runJob(job, runTools, stopAll)).catch((error) => runTools.print(`The run stopped: ${messageOf3(error)}`)).finally(() => running.delete(task));
|
|
@@ -3180,9 +4106,9 @@ async function work(tools, stop, parallel = 1) {
|
|
|
3180
4106
|
await Promise.race(running);
|
|
3181
4107
|
continue;
|
|
3182
4108
|
}
|
|
3183
|
-
let
|
|
4109
|
+
let reply3;
|
|
3184
4110
|
try {
|
|
3185
|
-
|
|
4111
|
+
reply3 = await tools.api.request("POST", NEXT_RUN_PATH);
|
|
3186
4112
|
} catch (error) {
|
|
3187
4113
|
if (isFatal(error)) {
|
|
3188
4114
|
halt.abort();
|
|
@@ -3192,14 +4118,14 @@ async function work(tools, stop, parallel = 1) {
|
|
|
3192
4118
|
await tools.sleep(RETRY_AFTER_ERROR_MS, stopAll);
|
|
3193
4119
|
continue;
|
|
3194
4120
|
}
|
|
3195
|
-
if (
|
|
3196
|
-
start(
|
|
4121
|
+
if (reply3.job) {
|
|
4122
|
+
start(reply3.job);
|
|
3197
4123
|
} else {
|
|
3198
4124
|
await tools.sleep(POLL_INTERVAL_MS, stopAll);
|
|
3199
4125
|
}
|
|
3200
4126
|
}
|
|
3201
4127
|
} finally {
|
|
3202
|
-
await Promise.allSettled([...running, reporting]);
|
|
4128
|
+
await Promise.allSettled([...running, reporting, checking]);
|
|
3203
4129
|
}
|
|
3204
4130
|
}
|
|
3205
4131
|
|
|
@@ -3210,11 +4136,11 @@ var NO_TASKS_ACCESS_TEXT = "Your account no longer has access to Tasks. Ask the
|
|
|
3210
4136
|
var ACCESS_PROBLEMS = { 401: DISCONNECTED_TEXT, 403: NO_TASKS_ACCESS_TEXT };
|
|
3211
4137
|
var INTERRUPTED_EXIT_CODE = 130;
|
|
3212
4138
|
function print(line) {
|
|
3213
|
-
|
|
4139
|
+
process7.stdout.write(`${line}
|
|
3214
4140
|
`);
|
|
3215
4141
|
}
|
|
3216
4142
|
function serverFor(flag, saved) {
|
|
3217
|
-
return serverOrigin(flag ??
|
|
4143
|
+
return serverOrigin(flag ?? process7.env.TAMSENSE_URL ?? saved?.server ?? DEFAULT_SERVER);
|
|
3218
4144
|
}
|
|
3219
4145
|
function accessProblem(error) {
|
|
3220
4146
|
return error instanceof ApiError ? ACCESS_PROBLEMS[error.status] ?? null : null;
|
|
@@ -3240,15 +4166,15 @@ async function runLogin(server, deviceName) {
|
|
|
3240
4166
|
}
|
|
3241
4167
|
function stopOnInterrupt() {
|
|
3242
4168
|
const stop = new AbortController();
|
|
3243
|
-
|
|
4169
|
+
process7.once("SIGINT", () => {
|
|
3244
4170
|
print("Stopping\u2026 Press Ctrl+C again to quit at once.");
|
|
3245
4171
|
stop.abort();
|
|
3246
|
-
|
|
4172
|
+
process7.once("SIGINT", () => process7.exit(INTERRUPTED_EXIT_CODE));
|
|
3247
4173
|
});
|
|
3248
4174
|
return stop.signal;
|
|
3249
4175
|
}
|
|
3250
4176
|
function installTools(home) {
|
|
3251
|
-
return { home, nodePath:
|
|
4177
|
+
return { home, nodePath: process7.execPath, env: process7.env, print, run: runProcess, exists: existsSync7 };
|
|
3252
4178
|
}
|
|
3253
4179
|
async function browserForStart(home, install) {
|
|
3254
4180
|
const installed = await installedBrowser(home);
|
|
@@ -3258,13 +4184,13 @@ async function browserForStart(home, install) {
|
|
|
3258
4184
|
return ensureBrowser({ print, install: () => installBrowser(installTools(home)) });
|
|
3259
4185
|
}
|
|
3260
4186
|
async function startedNewVersion() {
|
|
3261
|
-
const update = await updateToRun(
|
|
4187
|
+
const update = await updateToRun(process7.env, currentVersion(), () => latestVersion());
|
|
3262
4188
|
if (!update) {
|
|
3263
4189
|
return false;
|
|
3264
4190
|
}
|
|
3265
4191
|
print(`TAMsense runner ${update.latest} is out, this is ${update.current}. Starting the new version...`);
|
|
3266
4192
|
try {
|
|
3267
|
-
|
|
4193
|
+
process7.exitCode = await relaunch(process7.argv.slice(2), process7.env);
|
|
3268
4194
|
return true;
|
|
3269
4195
|
} catch {
|
|
3270
4196
|
print("Could not start the new version. Going on with this one.");
|
|
@@ -3282,14 +4208,14 @@ async function runStart(parallel, installsBrowser) {
|
|
|
3282
4208
|
}
|
|
3283
4209
|
const api = createApi(saved.server, saved.token);
|
|
3284
4210
|
const identity = await api.request("GET", IDENTITY_PATH);
|
|
3285
|
-
const
|
|
3286
|
-
if (
|
|
3287
|
-
throw new Error(
|
|
4211
|
+
const problem2 = teamKeyProblem(identity.device.team, process7.env);
|
|
4212
|
+
if (problem2) {
|
|
4213
|
+
throw new Error(problem2);
|
|
3288
4214
|
}
|
|
3289
4215
|
print(`${identity.device.name} is connected to ${saved.server} as ${describeDevice(identity)}.`);
|
|
3290
4216
|
const home = homedir3();
|
|
3291
4217
|
const browser = await browserForStart(home, installsBrowser);
|
|
3292
|
-
await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder() }, stopOnInterrupt(), parallel);
|
|
4218
|
+
await work({ api, runAgent: runClaude, capabilities: probeCapabilities, print, sleep, home, remoteUrl: githubRemoteUrl, browser, recorder: bundledRecorder(), inspectFolder: (path) => inspectFolder(path, home) }, stopOnInterrupt(), parallel);
|
|
3293
4219
|
}
|
|
3294
4220
|
async function runBrowser(remove) {
|
|
3295
4221
|
const home = homedir3();
|
|
@@ -3341,10 +4267,10 @@ async function run(command) {
|
|
|
3341
4267
|
}
|
|
3342
4268
|
}
|
|
3343
4269
|
try {
|
|
3344
|
-
await run(parseCommand(
|
|
4270
|
+
await run(parseCommand(process7.argv.slice(2), process7.env));
|
|
3345
4271
|
} catch (error) {
|
|
3346
|
-
|
|
4272
|
+
process7.stderr.write(`${accessProblem(error) ?? (error instanceof Error ? error.message : String(error))}
|
|
3347
4273
|
`);
|
|
3348
|
-
|
|
4274
|
+
process7.exitCode = 1;
|
|
3349
4275
|
}
|
|
3350
4276
|
//# sourceMappingURL=cli.js.map
|