@teammates/cli 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,9 +8,9 @@
8
8
  * teammates --dir <path> Override .teammates/ location
9
9
  */
10
10
  import { exec as execCb, execSync, spawnSync } from "node:child_process";
11
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
12
12
  import { mkdir, readdir, rm, stat, unlink } from "node:fs/promises";
13
- import { dirname, join, resolve, sep } from "node:path";
13
+ import { basename, dirname, join, resolve, sep } from "node:path";
14
14
  import { createInterface } from "node:readline";
15
15
  import { App, ChatView, concat, esc, pen, renderMarkdown, stripAnsi, } from "@teammates/consolonia";
16
16
  import chalk from "chalk";
@@ -18,10 +18,11 @@ import ora from "ora";
18
18
  import { DAILY_LOG_BUDGET_TOKENS, syncRecallIndex } from "./adapter.js";
19
19
  import { AnimatedBanner, } from "./banner.js";
20
20
  import { findTeammatesDir, PKG_VERSION, parseCliArgs, printUsage, resolveAdapter, } from "./cli-args.js";
21
- import { buildConversationContext as buildConvCtx, buildSummarizationPrompt, cleanResponseBody, findAtMention, findSummarizationSplit, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
22
- import { autoCompactForBudget, buildWisdomPrompt, compactEpisodic, purgeStaleDailies, } from "./compact.js";
21
+ import { buildConversationContext as buildConvCtx, buildSummarizationPrompt, cleanResponseBody, compressConversationEntries, findAtMention, findSummarizationSplit, formatConversationEntry, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
22
+ import { autoCompactForBudget, buildDailyCompressionPrompt, buildMigrationCompressionPrompt, buildWisdomPrompt, compactEpisodic, findUncompressedDailies, purgeStaleDailies, } from "./compact.js";
23
23
  import { PromptInput } from "./console/prompt-input.js";
24
24
  import { buildTitle } from "./console/startup.js";
25
+ import { buildConversationLog } from "./log-parser.js";
25
26
  import { buildImportAdaptationPrompt, copyTemplateFiles, getOnboardingPrompt, importTeammates, } from "./onboard.js";
26
27
  import { Orchestrator } from "./orchestrator.js";
27
28
  import { loadPersonas, scaffoldFromPersona } from "./personas.js";
@@ -120,8 +121,11 @@ class TeammatesREPL {
120
121
  if (this.chatView && cleaned) {
121
122
  const t = theme();
122
123
  const teammate = result.teammate;
123
- const replyId = `reply-${teammate}-${Date.now()}`;
124
+ const ts = Date.now();
125
+ const replyId = `reply-${teammate}-${ts}`;
126
+ const copyId = `copy-${teammate}-${ts}`;
124
127
  this._replyContexts.set(replyId, { teammate, message: cleaned });
128
+ this._copyContexts.set(copyId, cleaned);
125
129
  this.chatView.appendActionList([
126
130
  {
127
131
  id: replyId,
@@ -135,7 +139,7 @@ class TeammatesREPL {
135
139
  }),
136
140
  },
137
141
  {
138
- id: "copy",
142
+ id: copyId,
139
143
  normalStyle: this.makeSpan({
140
144
  text: " [copy]",
141
145
  style: { fg: t.textDim },
@@ -152,15 +156,25 @@ class TeammatesREPL {
152
156
  this.refreshTeammates();
153
157
  this.showPrompt();
154
158
  }
155
- /** Token budget for recent conversation history (24k tokens 96k chars). */
156
- static CONV_HISTORY_CHARS = 24_000 * 4;
157
- buildConversationContext() {
158
- return buildConvCtx(this.conversationHistory, this.conversationSummary, TeammatesREPL.CONV_HISTORY_CHARS);
159
+ /** Target context window in tokens. Conversation history budget is derived from this. */
160
+ static TARGET_CONTEXT_TOKENS = 128_000;
161
+ /** Estimated tokens used by non-conversation prompt sections (identity, wisdom, logs, recall, instructions, task). */
162
+ static PROMPT_OVERHEAD_TOKENS = 32_000;
163
+ /** Chars-per-token approximation (matches adapter.ts). */
164
+ static CHARS_PER_TOKEN = 4;
165
+ /** Character budget for conversation history = (target − overhead) × chars/token. */
166
+ static CONV_HISTORY_CHARS = (TeammatesREPL.TARGET_CONTEXT_TOKENS -
167
+ TeammatesREPL.PROMPT_OVERHEAD_TOKENS) *
168
+ TeammatesREPL.CHARS_PER_TOKEN;
169
+ buildConversationContext(_teammate, snapshot) {
170
+ const history = snapshot ? snapshot.history : this.conversationHistory;
171
+ const summary = snapshot ? snapshot.summary : this.conversationSummary;
172
+ return buildConvCtx(history, summary, TeammatesREPL.CONV_HISTORY_CHARS);
159
173
  }
160
174
  /**
161
- * Check if conversation history exceeds the 24k token budget.
175
+ * Check if conversation history exceeds the token budget.
162
176
  * If so, take the older entries that won't fit, combine with existing summary,
163
- * and queue a summarization task to the coding agent.
177
+ * and queue a summarization task to the coding agent for high-quality compression.
164
178
  */
165
179
  maybeQueueSummarization() {
166
180
  const splitIdx = findSummarizationSplit(this.conversationHistory, TeammatesREPL.CONV_HISTORY_CHARS);
@@ -178,6 +192,23 @@ class TeammatesREPL {
178
192
  });
179
193
  this.kickDrain();
180
194
  }
195
+ /**
196
+ * Pre-dispatch compression: if conversation history exceeds the token budget,
197
+ * mechanically compress older entries into bullet summaries BEFORE building the
198
+ * prompt. This ensures the prompt always fits within the target context window,
199
+ * even if the async agent-quality summarization hasn't completed yet.
200
+ */
201
+ preDispatchCompress() {
202
+ const totalChars = this.conversationHistory.reduce((sum, e) => sum + formatConversationEntry(e.role, e.text).length, 0);
203
+ if (totalChars <= TeammatesREPL.CONV_HISTORY_CHARS)
204
+ return;
205
+ const splitIdx = findSummarizationSplit(this.conversationHistory, TeammatesREPL.CONV_HISTORY_CHARS);
206
+ if (splitIdx === 0)
207
+ return;
208
+ const toCompress = this.conversationHistory.slice(0, splitIdx);
209
+ this.conversationSummary = compressConversationEntries(toCompress, this.conversationSummary);
210
+ this.conversationHistory.splice(0, splitIdx);
211
+ }
181
212
  adapterName;
182
213
  teammatesDir;
183
214
  taskQueue = [];
@@ -187,6 +218,8 @@ class TeammatesREPL {
187
218
  systemActive = new Map();
188
219
  /** Agents currently in a silent retry — suppress all events. */
189
220
  silentAgents = new Set();
221
+ /** Counter for pending migration compression tasks — triggers re-index when it hits 0. */
222
+ pendingMigrationSyncs = 0;
190
223
  /** Per-agent drain locks — prevents double-draining a single agent. */
191
224
  agentDrainLocks = new Map();
192
225
  /** Stored pasted text keyed by paste number, expanded on Enter. */
@@ -199,6 +232,8 @@ class TeammatesREPL {
199
232
  ctrlcPending = false; // true after first Ctrl+C, waiting for second
200
233
  ctrlcTimer = null;
201
234
  lastCleanedOutput = ""; // last teammate output for clipboard copy
235
+ /** Maps copy action IDs to the cleaned output text for that response. */
236
+ _copyContexts = new Map();
202
237
  autoApproveHandoffs = false;
203
238
  /** Last debug log file path per teammate — for /debug analysis. */
204
239
  lastDebugFiles = new Map();
@@ -208,6 +243,8 @@ class TeammatesREPL {
208
243
  pendingHandoffs = [];
209
244
  /** Pending retro proposals awaiting user approval. */
210
245
  pendingRetroProposals = [];
246
+ /** Pending cross-folder violations awaiting user decision. */
247
+ pendingViolations = [];
211
248
  /** Maps reply action IDs to their context (teammate + message). */
212
249
  _replyContexts = new Map();
213
250
  /** Quoted reply text to expand on next submit. */
@@ -727,6 +764,135 @@ class TeammatesREPL {
727
764
  return;
728
765
  }
729
766
  }
767
+ /**
768
+ * Audit a task result for cross-folder writes.
769
+ * AI teammates must not write to another teammate's folder.
770
+ * Returns violating file paths (relative), or empty array if clean.
771
+ */
772
+ auditCrossFolderWrites(teammate, changedFiles) {
773
+ // Normalize .teammates/ prefix for comparison
774
+ const tmPrefix = ".teammates/";
775
+ const ownPrefix = `${tmPrefix}${teammate}/`;
776
+ return changedFiles.filter((f) => {
777
+ const normalized = f.replace(/\\/g, "/");
778
+ // Only care about files inside .teammates/
779
+ if (!normalized.startsWith(tmPrefix))
780
+ return false;
781
+ // Own folder is fine
782
+ if (normalized.startsWith(ownPrefix))
783
+ return false;
784
+ // Shared folders (_prefix) are fine
785
+ const subPath = normalized.slice(tmPrefix.length);
786
+ if (subPath.startsWith("_"))
787
+ return false;
788
+ // Ephemeral folders (.prefix) are fine
789
+ if (subPath.startsWith("."))
790
+ return false;
791
+ // Root-level shared files (USER.md, settings.json, CROSS-TEAM.md, etc.)
792
+ if (!subPath.includes("/"))
793
+ return false;
794
+ // Everything else is a violation
795
+ return true;
796
+ });
797
+ }
798
+ /**
799
+ * Show cross-folder violation warning with [revert] / [allow] actions.
800
+ */
801
+ showViolationWarning(teammate, violations) {
802
+ const t = theme();
803
+ this.feedLine(tp.warning(` ⚠ @${teammate} wrote to another teammate's folder:`));
804
+ for (const f of violations) {
805
+ this.feedLine(tp.muted(` ${f}`));
806
+ }
807
+ if (this.chatView) {
808
+ const violationId = `violation-${Date.now()}`;
809
+ const actionIdx = this.chatView.feedLineCount;
810
+ this.chatView.appendActionList([
811
+ {
812
+ id: `revert-${violationId}`,
813
+ normalStyle: this.makeSpan({
814
+ text: " [revert]",
815
+ style: { fg: t.error },
816
+ }),
817
+ hoverStyle: this.makeSpan({
818
+ text: " [revert]",
819
+ style: { fg: t.accent },
820
+ }),
821
+ },
822
+ {
823
+ id: `allow-${violationId}`,
824
+ normalStyle: this.makeSpan({
825
+ text: " [allow]",
826
+ style: { fg: t.textDim },
827
+ }),
828
+ hoverStyle: this.makeSpan({
829
+ text: " [allow]",
830
+ style: { fg: t.accent },
831
+ }),
832
+ },
833
+ ]);
834
+ this.pendingViolations.push({
835
+ id: violationId,
836
+ teammate,
837
+ files: violations,
838
+ actionIdx,
839
+ });
840
+ }
841
+ }
842
+ /**
843
+ * Handle revert/allow actions for cross-folder violations.
844
+ */
845
+ handleViolationAction(actionId) {
846
+ const revertMatch = actionId.match(/^revert-(violation-.+)$/);
847
+ if (revertMatch) {
848
+ const vId = revertMatch[1];
849
+ const idx = this.pendingViolations.findIndex((v) => v.id === vId);
850
+ if (idx >= 0 && this.chatView) {
851
+ const v = this.pendingViolations.splice(idx, 1)[0];
852
+ // Revert violating files via git checkout
853
+ for (const f of v.files) {
854
+ try {
855
+ execSync(`git checkout -- "${f}"`, {
856
+ cwd: resolve(this.teammatesDir, ".."),
857
+ stdio: "pipe",
858
+ });
859
+ }
860
+ catch {
861
+ // File might be untracked — try git rm
862
+ try {
863
+ execSync(`git rm -f "${f}"`, {
864
+ cwd: resolve(this.teammatesDir, ".."),
865
+ stdio: "pipe",
866
+ });
867
+ }
868
+ catch {
869
+ // Best effort — file may already be clean
870
+ }
871
+ }
872
+ }
873
+ this.chatView.updateFeedLine(v.actionIdx, this.makeSpan({
874
+ text: ` reverted ${v.files.length} file(s)`,
875
+ style: { fg: theme().success },
876
+ }));
877
+ this.refreshView();
878
+ }
879
+ return;
880
+ }
881
+ const allowMatch = actionId.match(/^allow-(violation-.+)$/);
882
+ if (allowMatch) {
883
+ const vId = allowMatch[1];
884
+ const idx = this.pendingViolations.findIndex((v) => v.id === vId);
885
+ if (idx >= 0 && this.chatView) {
886
+ const v = this.pendingViolations.splice(idx, 1)[0];
887
+ this.chatView.updateFeedLine(v.actionIdx, this.makeSpan({
888
+ text: " allowed",
889
+ style: { fg: theme().textDim },
890
+ }));
891
+ this.refreshView();
892
+ }
893
+ return;
894
+ }
895
+ }
730
896
  /** Handle bulk handoff actions. */
731
897
  handleBulkHandoff(action) {
732
898
  if (!this.chatView)
@@ -976,8 +1142,14 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
976
1142
  if (everyoneMatch) {
977
1143
  const task = everyoneMatch[1];
978
1144
  const names = allNames.filter((n) => n !== this.selfName && n !== this.adapterName);
1145
+ // Atomic snapshot: freeze conversation state ONCE so all agents see
1146
+ // the same context regardless of concurrent preDispatchCompress mutations.
1147
+ const contextSnapshot = {
1148
+ history: this.conversationHistory.map((e) => ({ ...e })),
1149
+ summary: this.conversationSummary,
1150
+ };
979
1151
  for (const teammate of names) {
980
- this.taskQueue.push({ type: "agent", teammate, task });
1152
+ this.taskQueue.push({ type: "agent", teammate, task, contextSnapshot });
981
1153
  }
982
1154
  const bg = this._userBg;
983
1155
  const t = theme();
@@ -1113,6 +1285,22 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
1113
1285
  }
1114
1286
  finally {
1115
1287
  this.systemActive.delete(taskId);
1288
+ // Migration tasks: decrement counter and re-index when all are done
1289
+ if (entry.type === "agent" && entry.migration) {
1290
+ this.pendingMigrationSyncs--;
1291
+ if (this.pendingMigrationSyncs <= 0) {
1292
+ try {
1293
+ await syncRecallIndex(this.teammatesDir);
1294
+ this.feedLine(tp.success(" ✔ v0.6.0 migration complete — indexes rebuilt"));
1295
+ this.refreshView();
1296
+ }
1297
+ catch {
1298
+ /* re-index failed — non-fatal, next startup will retry */
1299
+ }
1300
+ // Persist version LAST — only after all migration tasks finish
1301
+ this.commitVersionUpdate();
1302
+ }
1303
+ }
1116
1304
  }
1117
1305
  }
1118
1306
  // ─── Onboarding ───────────────────────────────────────────────────
@@ -1952,9 +2140,51 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
1952
2140
  compact: new Set([0]),
1953
2141
  debug: new Set([0]),
1954
2142
  retro: new Set([0]),
2143
+ interrupt: new Set([0]),
2144
+ int: new Set([0]),
1955
2145
  };
1956
2146
  /** Build param-completion items for the current line, if any. */
1957
2147
  getParamItems(cmdName, argsBefore, partial) {
2148
+ // Script subcommand + name completion for /script
2149
+ if (cmdName === "script") {
2150
+ const completedArgs = argsBefore.trim()
2151
+ ? argsBefore.trim().split(/\s+/).length
2152
+ : 0;
2153
+ const lower = partial.toLowerCase();
2154
+ if (completedArgs === 0) {
2155
+ // First arg — suggest subcommands
2156
+ const subs = [
2157
+ { name: "list", desc: "List saved scripts" },
2158
+ { name: "run", desc: "Run an existing script" },
2159
+ ];
2160
+ return subs
2161
+ .filter((s) => s.name.startsWith(lower))
2162
+ .map((s) => ({
2163
+ label: s.name,
2164
+ description: s.desc,
2165
+ completion: `/script ${s.name} `,
2166
+ }));
2167
+ }
2168
+ if (completedArgs === 1 && argsBefore.trim() === "run") {
2169
+ // Second arg after "run" — suggest script filenames
2170
+ const scriptsDir = join(this.teammatesDir, this.selfName, "scripts");
2171
+ let files = [];
2172
+ try {
2173
+ files = readdirSync(scriptsDir).filter((f) => !f.startsWith("."));
2174
+ }
2175
+ catch {
2176
+ // directory doesn't exist yet
2177
+ }
2178
+ return files
2179
+ .filter((f) => f.toLowerCase().startsWith(lower))
2180
+ .map((f) => ({
2181
+ label: f,
2182
+ description: "saved script",
2183
+ completion: `/script run ${f}`,
2184
+ }));
2185
+ }
2186
+ return [];
2187
+ }
1958
2188
  // Service name completion for /configure
1959
2189
  if (cmdName === "configure" || cmdName === "config") {
1960
2190
  const completedArgs = argsBefore.trim()
@@ -2543,13 +2773,17 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
2543
2773
  if (id.startsWith("copy-cmd:")) {
2544
2774
  this.doCopy(id.slice("copy-cmd:".length));
2545
2775
  }
2546
- else if (id === "copy") {
2547
- this.doCopy(this.lastCleanedOutput || undefined);
2776
+ else if (id.startsWith("copy-")) {
2777
+ const text = this._copyContexts.get(id);
2778
+ this.doCopy(text || this.lastCleanedOutput || undefined);
2548
2779
  }
2549
2780
  else if (id.startsWith("retro-approve-") ||
2550
2781
  id.startsWith("retro-reject-")) {
2551
2782
  this.handleRetroAction(id);
2552
2783
  }
2784
+ else if (id.startsWith("revert-") || id.startsWith("allow-")) {
2785
+ this.handleViolationAction(id);
2786
+ }
2553
2787
  else if (id.startsWith("approve-") || id.startsWith("reject-")) {
2554
2788
  this.handleHandoffAction(id);
2555
2789
  }
@@ -3046,6 +3280,13 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3046
3280
  description: "Cancel a queued task by number",
3047
3281
  run: (args) => this.cmdCancel(args),
3048
3282
  },
3283
+ {
3284
+ name: "interrupt",
3285
+ aliases: ["int"],
3286
+ usage: "/interrupt [teammate] [message]",
3287
+ description: "Interrupt a running agent and resume with a steering message",
3288
+ run: (args) => this.cmdInterrupt(args),
3289
+ },
3049
3290
  {
3050
3291
  name: "init",
3051
3292
  aliases: ["onboard", "setup"],
@@ -3095,6 +3336,13 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3095
3336
  description: "Ask a quick side question without interrupting the main conversation",
3096
3337
  run: (args) => this.cmdBtw(args),
3097
3338
  },
3339
+ {
3340
+ name: "script",
3341
+ aliases: [],
3342
+ usage: "/script [list | run <name> | what should the script do?]",
3343
+ description: "Write and run reusable scripts via the coding agent",
3344
+ run: (args) => this.cmdScript(args),
3345
+ },
3098
3346
  {
3099
3347
  name: "theme",
3100
3348
  aliases: [],
@@ -3365,6 +3613,114 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3365
3613
  this.feedLine(concat(tp.muted(" Cancelled: "), tp.accent(`@${cancelDisplay}`), tp.muted(" — "), tp.text(removed.task.slice(0, 60))));
3366
3614
  this.refreshView();
3367
3615
  }
3616
+ /**
3617
+ * /interrupt [teammate] [message] — Kill a running agent and resume with context.
3618
+ */
3619
+ async cmdInterrupt(argsStr) {
3620
+ const parts = argsStr.trim().split(/\s+/);
3621
+ const teammateName = parts[0]?.replace(/^@/, "").toLowerCase();
3622
+ const steeringMessage = parts.slice(1).join(" ").trim() ||
3623
+ "Wrap up your current work and report what you've done so far.";
3624
+ if (!teammateName) {
3625
+ this.feedLine(tp.warning(" Usage: /interrupt [teammate] [message]"));
3626
+ this.refreshView();
3627
+ return;
3628
+ }
3629
+ // Resolve display name → internal name
3630
+ const resolvedName = teammateName === this.adapterName ? this.selfName : teammateName;
3631
+ // Check if the teammate has an active task
3632
+ const activeEntry = this.agentActive.get(resolvedName);
3633
+ if (!activeEntry) {
3634
+ this.feedLine(tp.warning(` @${teammateName} has no active task to interrupt.`));
3635
+ this.refreshView();
3636
+ return;
3637
+ }
3638
+ // Check if the adapter supports killing
3639
+ const adapter = this.orchestrator.getAdapter();
3640
+ if (!adapter?.killAgent) {
3641
+ this.feedLine(tp.warning(" This adapter does not support interruption."));
3642
+ this.refreshView();
3643
+ return;
3644
+ }
3645
+ // Show interruption status
3646
+ const displayName = resolvedName === this.selfName ? this.adapterName : resolvedName;
3647
+ this.feedLine(concat(tp.warning(" ⚡ Interrupting "), tp.accent(`@${displayName}`), tp.warning("...")));
3648
+ this.refreshView();
3649
+ try {
3650
+ // Kill the agent process and capture its output
3651
+ const spawnResult = await adapter.killAgent(resolvedName);
3652
+ if (!spawnResult) {
3653
+ this.feedLine(tp.warning(` @${displayName} process already exited.`));
3654
+ this.refreshView();
3655
+ return;
3656
+ }
3657
+ // Get the original full prompt for this agent
3658
+ const _originalFullPrompt = this.lastTaskPrompts.get(resolvedName) ?? "";
3659
+ const originalTask = activeEntry.task;
3660
+ // Parse the conversation log from available sources
3661
+ const presetName = adapter.name ?? "unknown";
3662
+ const { log, toolCallCount, filesChanged } = buildConversationLog(spawnResult.debugFile, spawnResult.stdout, presetName);
3663
+ // Build the resume prompt
3664
+ const resumePrompt = this.buildResumePrompt(originalTask, log, steeringMessage, toolCallCount, filesChanged);
3665
+ // Report what happened
3666
+ const elapsed = this.activeTasks.get(resolvedName)?.startTime
3667
+ ? `${((Date.now() - this.activeTasks.get(resolvedName).startTime) / 1000).toFixed(0)}s`
3668
+ : "unknown";
3669
+ this.feedLine(concat(tp.success(" ⚡ Interrupted "), tp.accent(`@${displayName}`), tp.muted(` (${elapsed}, ${toolCallCount} tool calls, ${filesChanged.length} files changed)`)));
3670
+ this.feedLine(concat(tp.muted(" Resuming with: "), tp.text(steeringMessage.slice(0, 70))));
3671
+ this.refreshView();
3672
+ // Clean up the active task state — the drainAgentQueue loop will see
3673
+ // the agent as inactive and the queue entry was already removed
3674
+ this.activeTasks.delete(resolvedName);
3675
+ this.agentActive.delete(resolvedName);
3676
+ if (this.activeTasks.size === 0)
3677
+ this.stopStatusAnimation();
3678
+ // Queue the resumed task
3679
+ this.taskQueue.push({
3680
+ type: "agent",
3681
+ teammate: resolvedName,
3682
+ task: resumePrompt,
3683
+ });
3684
+ this.kickDrain();
3685
+ }
3686
+ catch (err) {
3687
+ this.feedLine(tp.error(` ✖ Failed to interrupt @${displayName}: ${err?.message ?? String(err)}`));
3688
+ this.refreshView();
3689
+ }
3690
+ }
3691
+ /**
3692
+ * Build a resume prompt from the original task, conversation log, and steering message.
3693
+ */
3694
+ buildResumePrompt(originalTask, conversationLog, steeringMessage, toolCallCount, filesChanged) {
3695
+ const parts = [];
3696
+ parts.push("<RESUME_CONTEXT>");
3697
+ parts.push("This is a resumed task. You were previously working on this task but were interrupted.");
3698
+ parts.push("Below is the log of what you accomplished before the interruption.");
3699
+ parts.push("");
3700
+ parts.push("DO NOT repeat work that is already done. Check the filesystem for files you already wrote.");
3701
+ parts.push("Continue from where you left off.");
3702
+ parts.push("");
3703
+ parts.push("## What You Did Before Interruption");
3704
+ parts.push("");
3705
+ parts.push(`Tool calls: ${toolCallCount}`);
3706
+ if (filesChanged.length > 0) {
3707
+ parts.push(`Files changed: ${filesChanged.slice(0, 20).join(", ")}${filesChanged.length > 20 ? ` (+${filesChanged.length - 20} more)` : ""}`);
3708
+ }
3709
+ parts.push("");
3710
+ parts.push(conversationLog);
3711
+ parts.push("");
3712
+ parts.push("## Interruption");
3713
+ parts.push("");
3714
+ parts.push(steeringMessage);
3715
+ parts.push("");
3716
+ parts.push("## Your Task Now");
3717
+ parts.push("");
3718
+ parts.push("Continue the original task from where you left off. The original task was:");
3719
+ parts.push("");
3720
+ parts.push(originalTask);
3721
+ parts.push("</RESUME_CONTEXT>");
3722
+ return parts.join("\n");
3723
+ }
3368
3724
  /** Drain user tasks for a single agent — runs in parallel with other agents.
3369
3725
  * System tasks are handled separately by runSystemTask(). */
3370
3726
  async drainAgentQueue(agent) {
@@ -3378,9 +3734,16 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3378
3734
  try {
3379
3735
  {
3380
3736
  // btw and debug tasks skip conversation context (not part of main thread)
3381
- const extraContext = entry.type === "btw" || entry.type === "debug"
3382
- ? ""
3383
- : this.buildConversationContext();
3737
+ const isMainThread = entry.type !== "btw" && entry.type !== "debug";
3738
+ // Snapshot-aware context building: if the entry has a frozen snapshot
3739
+ // (@everyone), use it directly — no mutation of shared state.
3740
+ // Otherwise, compress live state as before.
3741
+ const snapshot = entry.type === "agent" ? entry.contextSnapshot : undefined;
3742
+ if (isMainThread && !snapshot)
3743
+ this.preDispatchCompress();
3744
+ const extraContext = isMainThread
3745
+ ? this.buildConversationContext(entry.teammate, snapshot)
3746
+ : "";
3384
3747
  let result = await this.orchestrator.assign({
3385
3748
  teammate: entry.teammate,
3386
3749
  task: entry.task,
@@ -3429,6 +3792,14 @@ Do NOT modify any other teammate's files. Only edit your own SOUL.md and daily l
3429
3792
  }
3430
3793
  // Display the (possibly retried) result to the user
3431
3794
  this.displayTaskResult(result, entry.type);
3795
+ // Audit cross-folder writes for AI teammates
3796
+ const tmConfig = this.orchestrator.getRegistry().get(entry.teammate);
3797
+ if (tmConfig?.type === "ai" && result.changedFiles.length > 0) {
3798
+ const violations = this.auditCrossFolderWrites(entry.teammate, result.changedFiles);
3799
+ if (violations.length > 0) {
3800
+ this.showViolationWarning(entry.teammate, violations);
3801
+ }
3802
+ }
3432
3803
  // Write debug entry — skip for debug analysis tasks (avoid recursion)
3433
3804
  if (entry.type !== "debug") {
3434
3805
  this.writeDebugEntry(entry.teammate, entry.task, result, startTime);
@@ -3933,7 +4304,21 @@ Issues that can't be resolved unilaterally — they need input from other teamma
3933
4304
  }
3934
4305
  }
3935
4306
  }
4307
+ /** Compare two semver strings. Returns true if `a` is less than `b`. */
4308
+ static semverLessThan(a, b) {
4309
+ const pa = a.split(".").map(Number);
4310
+ const pb = b.split(".").map(Number);
4311
+ for (let i = 0; i < 3; i++) {
4312
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
4313
+ return true;
4314
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
4315
+ return false;
4316
+ }
4317
+ return false;
4318
+ }
3936
4319
  async startupMaintenance() {
4320
+ // Check and update installed CLI version
4321
+ const versionUpdate = this.checkVersionUpdate();
3937
4322
  const tmpDir = join(this.teammatesDir, ".tmp");
3938
4323
  // Clean up debug log files older than 1 day
3939
4324
  const debugDir = join(tmpDir, "debug");
@@ -3960,7 +4345,64 @@ Issues that can't be resolved unilaterally — they need input from other teamma
3960
4345
  for (const name of teammates) {
3961
4346
  await this.runCompact(name, true);
3962
4347
  }
3963
- // 2. Purge daily logs older than 30 days (disk + Vectra)
4348
+ // 2. Compress previous day's log for each teammate (queued as system tasks)
4349
+ for (const name of teammates) {
4350
+ try {
4351
+ const compression = await buildDailyCompressionPrompt(join(this.teammatesDir, name));
4352
+ if (compression) {
4353
+ this.taskQueue.push({
4354
+ type: "agent",
4355
+ teammate: name,
4356
+ task: compression.prompt,
4357
+ system: true,
4358
+ });
4359
+ }
4360
+ }
4361
+ catch {
4362
+ /* compression check failed — non-fatal */
4363
+ }
4364
+ }
4365
+ // 2b. v0.6.0 migration — compress ALL uncompressed daily logs + re-index
4366
+ const needsMigration = versionUpdate &&
4367
+ (versionUpdate.previous === "" ||
4368
+ TeammatesREPL.semverLessThan(versionUpdate.previous, "0.6.0"));
4369
+ if (needsMigration) {
4370
+ this.feedLine(tp.accent(" ℹ Migrating to v0.6.0 — compressing daily logs..."));
4371
+ this.refreshView();
4372
+ let migrationCount = 0;
4373
+ for (const name of teammates) {
4374
+ try {
4375
+ const uncompressed = await findUncompressedDailies(join(this.teammatesDir, name));
4376
+ if (uncompressed.length === 0)
4377
+ continue;
4378
+ const prompt = await buildMigrationCompressionPrompt(join(this.teammatesDir, name), name, uncompressed);
4379
+ if (prompt) {
4380
+ migrationCount++;
4381
+ this.taskQueue.push({
4382
+ type: "agent",
4383
+ teammate: name,
4384
+ task: prompt,
4385
+ system: true,
4386
+ migration: true,
4387
+ });
4388
+ }
4389
+ }
4390
+ catch {
4391
+ /* migration compression failed — non-fatal */
4392
+ }
4393
+ }
4394
+ this.pendingMigrationSyncs = migrationCount;
4395
+ // If no migration tasks were actually queued, commit version now
4396
+ if (migrationCount === 0) {
4397
+ this.commitVersionUpdate();
4398
+ }
4399
+ }
4400
+ else if (versionUpdate) {
4401
+ // No migration needed — commit the version update immediately
4402
+ this.commitVersionUpdate();
4403
+ }
4404
+ this.kickDrain();
4405
+ // 3. Purge daily logs older than 30 days (disk + Vectra)
3964
4406
  const { Indexer } = await import("@teammates/recall");
3965
4407
  const indexer = new Indexer({ teammatesDir: this.teammatesDir });
3966
4408
  for (const name of teammates) {
@@ -3975,7 +4417,7 @@ Issues that can't be resolved unilaterally — they need input from other teamma
3975
4417
  /* purge failed — non-fatal */
3976
4418
  }
3977
4419
  }
3978
- // 3. Sync recall indexes (bundled library call)
4420
+ // 4. Sync recall indexes (bundled library call)
3979
4421
  try {
3980
4422
  await syncRecallIndex(this.teammatesDir);
3981
4423
  }
@@ -3983,6 +4425,60 @@ Issues that can't be resolved unilaterally — they need input from other teamma
3983
4425
  /* sync failed — non-fatal */
3984
4426
  }
3985
4427
  }
4428
+ /**
4429
+ * Check if the CLI version has changed since last run.
4430
+ * Does NOT update settings.json — call `commitVersionUpdate()` after
4431
+ * migration tasks are complete to persist the new version.
4432
+ */
4433
+ checkVersionUpdate() {
4434
+ const settingsPath = join(this.teammatesDir, "settings.json");
4435
+ let settings = {};
4436
+ try {
4437
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
4438
+ }
4439
+ catch {
4440
+ // No settings file or invalid JSON
4441
+ }
4442
+ const previous = settings.cliVersion ?? "";
4443
+ const current = PKG_VERSION;
4444
+ if (previous === current)
4445
+ return null;
4446
+ return { previous, current };
4447
+ }
4448
+ /**
4449
+ * Persist the current CLI version to settings.json.
4450
+ * Called after all migration tasks complete (or immediately if no migration needed).
4451
+ */
4452
+ commitVersionUpdate() {
4453
+ const settingsPath = join(this.teammatesDir, "settings.json");
4454
+ let settings = {};
4455
+ try {
4456
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
4457
+ }
4458
+ catch {
4459
+ // No settings file or invalid JSON — create one
4460
+ }
4461
+ const previous = settings.cliVersion ?? "";
4462
+ const current = PKG_VERSION;
4463
+ settings.cliVersion = current;
4464
+ if (!settings.version)
4465
+ settings.version = 1;
4466
+ try {
4467
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
4468
+ }
4469
+ catch {
4470
+ /* write failed — non-fatal */
4471
+ }
4472
+ // Detect major/minor version change (not just patch)
4473
+ const [prevMajor, prevMinor] = previous.split(".").map(Number);
4474
+ const [curMajor, curMinor] = current.split(".").map(Number);
4475
+ const isMajorMinor = previous !== "" && (prevMajor !== curMajor || prevMinor !== curMinor);
4476
+ if (isMajorMinor) {
4477
+ this.feedLine(tp.accent(` ✔ Updated from v${previous} → v${current}`));
4478
+ this.feedLine();
4479
+ this.refreshView();
4480
+ }
4481
+ }
3986
4482
  async cmdCopy() {
3987
4483
  this.doCopy(); // copies entire session
3988
4484
  }
@@ -4136,6 +4632,116 @@ Issues that can't be resolved unilaterally — they need input from other teamma
4136
4632
  this.refreshView();
4137
4633
  this.kickDrain();
4138
4634
  }
4635
+ async cmdScript(argsStr) {
4636
+ const args = argsStr.trim();
4637
+ const scriptsDir = join(this.teammatesDir, this.selfName, "scripts");
4638
+ // /script (no args) — show usage
4639
+ if (!args) {
4640
+ this.feedLine();
4641
+ this.feedLine(tp.bold(" /script — write and run reusable scripts"));
4642
+ this.feedLine(tp.muted(` ${"─".repeat(50)}`));
4643
+ this.feedLine(concat(tp.accent(" /script list".padEnd(36)), tp.text("List saved scripts")));
4644
+ this.feedLine(concat(tp.accent(" /script run <name>".padEnd(36)), tp.text("Run an existing script")));
4645
+ this.feedLine(concat(tp.accent(" /script <description>".padEnd(36)), tp.text("Create and run a new script")));
4646
+ this.feedLine();
4647
+ this.feedLine(tp.muted(` Scripts are saved to ${scriptsDir}`));
4648
+ this.feedLine();
4649
+ this.refreshView();
4650
+ return;
4651
+ }
4652
+ // /script list — list saved scripts
4653
+ if (args === "list") {
4654
+ let files = [];
4655
+ try {
4656
+ files = readdirSync(scriptsDir).filter((f) => !f.startsWith("."));
4657
+ }
4658
+ catch {
4659
+ // directory doesn't exist yet
4660
+ }
4661
+ this.feedLine();
4662
+ if (files.length === 0) {
4663
+ this.feedLine(tp.muted(" No scripts saved yet."));
4664
+ this.feedLine(tp.muted(" Use /script <description> to create one."));
4665
+ }
4666
+ else {
4667
+ this.feedLine(tp.bold(" Saved scripts"));
4668
+ this.feedLine(tp.muted(` ${"─".repeat(50)}`));
4669
+ for (const f of files) {
4670
+ this.feedLine(concat(tp.accent(` ${f}`)));
4671
+ }
4672
+ }
4673
+ this.feedLine();
4674
+ this.refreshView();
4675
+ return;
4676
+ }
4677
+ // /script run <name> — run an existing script
4678
+ if (args.startsWith("run ")) {
4679
+ const name = args.slice(4).trim();
4680
+ if (!name) {
4681
+ this.feedLine(tp.muted(" Usage: /script run <name>"));
4682
+ this.refreshView();
4683
+ return;
4684
+ }
4685
+ // Find the script file (try exact match, then with common extensions)
4686
+ const candidates = [
4687
+ name,
4688
+ `${name}.sh`,
4689
+ `${name}.ts`,
4690
+ `${name}.js`,
4691
+ `${name}.ps1`,
4692
+ `${name}.py`,
4693
+ ];
4694
+ let scriptPath = null;
4695
+ for (const c of candidates) {
4696
+ const p = join(scriptsDir, c);
4697
+ if (existsSync(p)) {
4698
+ scriptPath = p;
4699
+ break;
4700
+ }
4701
+ }
4702
+ if (!scriptPath) {
4703
+ this.feedLine(tp.warning(` Script not found: ${name}`));
4704
+ this.feedLine(tp.muted(" Use /script list to see available scripts."));
4705
+ this.refreshView();
4706
+ return;
4707
+ }
4708
+ const scriptContent = readFileSync(scriptPath, "utf-8");
4709
+ const task = `Run the following script located at ${scriptPath}:\n\n\`\`\`\n${scriptContent}\n\`\`\`\n\nExecute it and report the results. If it fails, diagnose the issue and fix it.`;
4710
+ this.taskQueue.push({
4711
+ type: "script",
4712
+ teammate: this.selfName,
4713
+ task,
4714
+ });
4715
+ this.feedLine(concat(tp.muted(" Running script "), tp.accent(basename(scriptPath)), tp.muted(" → "), tp.accent(`@${this.adapterName}`)));
4716
+ this.feedLine();
4717
+ this.refreshView();
4718
+ this.kickDrain();
4719
+ return;
4720
+ }
4721
+ // /script <description> — create and run a new script
4722
+ const task = [
4723
+ `The user wants a reusable script. Their request:`,
4724
+ ``,
4725
+ args,
4726
+ ``,
4727
+ `Instructions:`,
4728
+ `1. Write the script and save it to the scripts directory: ${scriptsDir}`,
4729
+ `2. Create the directory if it doesn't exist.`,
4730
+ `3. Choose a short, descriptive filename (kebab-case, with appropriate extension like .sh, .ts, .js, .py, .ps1).`,
4731
+ `4. Make the script executable if applicable.`,
4732
+ `5. Run the script and report the results.`,
4733
+ `6. If the script needs to be parameterized, use command-line arguments.`,
4734
+ ].join("\n");
4735
+ this.taskQueue.push({
4736
+ type: "script",
4737
+ teammate: this.selfName,
4738
+ task,
4739
+ });
4740
+ this.feedLine(concat(tp.muted(" Script task → "), tp.accent(`@${this.adapterName}`)));
4741
+ this.feedLine();
4742
+ this.refreshView();
4743
+ this.kickDrain();
4744
+ }
4139
4745
  async cmdTheme() {
4140
4746
  const t = theme();
4141
4747
  this.feedLine();