@dungle-scrubs/tallow 0.8.25 → 0.8.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/auth-hardening.d.ts +12 -0
  2. package/dist/auth-hardening.d.ts.map +1 -1
  3. package/dist/auth-hardening.js +30 -7
  4. package/dist/auth-hardening.js.map +1 -1
  5. package/dist/cli.js +5 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +1 -1
  8. package/dist/config.js +1 -1
  9. package/dist/install.js +2 -2
  10. package/dist/install.js.map +1 -1
  11. package/dist/interactive-mode-patch.d.ts.map +1 -1
  12. package/dist/interactive-mode-patch.js +119 -7
  13. package/dist/interactive-mode-patch.js.map +1 -1
  14. package/dist/model-metadata-overrides.d.ts +19 -0
  15. package/dist/model-metadata-overrides.d.ts.map +1 -0
  16. package/dist/model-metadata-overrides.js +38 -0
  17. package/dist/model-metadata-overrides.js.map +1 -0
  18. package/dist/sdk.d.ts +2 -0
  19. package/dist/sdk.d.ts.map +1 -1
  20. package/dist/sdk.js +28 -1
  21. package/dist/sdk.js.map +1 -1
  22. package/extensions/__integration__/teams-runtime.test.ts +22 -1
  23. package/extensions/_shared/__tests__/shell-policy.test.ts +197 -0
  24. package/extensions/_shared/shell-policy.ts +27 -0
  25. package/extensions/background-task-tool/index.ts +2 -1
  26. package/extensions/bash-tool-enhanced/index.ts +2 -1
  27. package/extensions/custom-footer/__tests__/index.test.ts +29 -0
  28. package/extensions/custom-footer/context-display.ts +49 -0
  29. package/extensions/custom-footer/index.ts +10 -23
  30. package/extensions/permissions/index.ts +31 -10
  31. package/extensions/plan-mode-tool/__tests__/index.test.ts +32 -2
  32. package/extensions/plan-mode-tool/index.ts +6 -1
  33. package/extensions/slash-command-bridge/index.ts +30 -1
  34. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +42 -3
  35. package/extensions/subagent-tool/process.ts +132 -21
  36. package/extensions/tasks/__tests__/store.test.ts +26 -2
  37. package/extensions/tasks/commands/register-tasks-extension.ts +2 -2
  38. package/extensions/tasks/index.ts +5 -5
  39. package/extensions/tasks/state/index.ts +90 -36
  40. package/extensions/teams-tool/__tests__/archive-store.test.ts +98 -0
  41. package/extensions/teams-tool/__tests__/peer-messaging.test.ts +26 -0
  42. package/extensions/teams-tool/archive-store.ts +200 -0
  43. package/extensions/teams-tool/sessions/spawn.ts +244 -71
  44. package/extensions/teams-tool/tools/register-extension.ts +146 -105
  45. package/extensions/teams-tool/tools/teammate-tools.ts +43 -1
  46. package/package.json +4 -4
  47. package/skills/tallow-expert/SKILL.md +1 -1
  48. package/templates/agents/architect.md +13 -5
  49. package/templates/agents/debug.md +3 -3
  50. package/templates/agents/explore.md +9 -2
  51. package/templates/agents/refactor.md +2 -2
  52. package/templates/agents/scout.md +3 -2
@@ -5,7 +5,7 @@
5
5
  * - Three states: pending (☐), in-progress (◉), completed (☑)
6
6
  * - Bidirectional dependency tracking (blocks/blockedBy)
7
7
  * - Comments for cross-session handoff context
8
- * - Team-based sharing via ~/.tallow/teams/{team-name}/tasks/
8
+ * - Shared task groups via ~/.tallow/task-groups/{group-name}/tasks/
9
9
  * - Multi-session coordination via fs.watch
10
10
  * - One file per task (avoids write conflicts)
11
11
  * - Status widget with dynamic sizing
@@ -15,7 +15,7 @@
15
15
  * NOTE: This extension only runs in the main Pi process, not in subagent workers.
16
16
  *
17
17
  * This file is the composition root: it constructs shared infrastructure
18
- * (team name, file store) and delegates all registration to
18
+ * (shared task-group name, file store) and delegates all registration to
19
19
  * {@link registerTasksExtension}. Domain logic lives in sibling modules.
20
20
  */
21
21
 
@@ -43,10 +43,10 @@ export { shouldClearOnAgentEnd } from "./state/index.js";
43
43
  export default function tasksExtension(pi: ExtensionAPI): void {
44
44
  const isSubagent = process.env.PI_IS_SUBAGENT === "1";
45
45
 
46
- // Auto-generate a team name so subagents can coordinate via shared directory.
47
- // Subagents inherit PI_TEAM_NAME from the lead process automatically.
46
+ // Auto-generate a shared task-group name so subagents can coordinate via a
47
+ // file-backed store. PI_TEAM_NAME stays as the env var for backward compatibility.
48
48
  const teamName =
49
- process.env.PI_TEAM_NAME ?? (isSubagent ? null : `team-${randomUUID().slice(0, 8)}`);
49
+ process.env.PI_TEAM_NAME ?? (isSubagent ? null : `task-group-${randomUUID().slice(0, 8)}`);
50
50
  if (teamName && !process.env.PI_TEAM_NAME) {
51
51
  // Set on process.env so child subagents inherit it automatically
52
52
  process.env.PI_TEAM_NAME = teamName;
@@ -12,13 +12,14 @@ import {
12
12
  mkdirSync,
13
13
  readdirSync,
14
14
  readFileSync,
15
+ renameSync,
15
16
  rmdirSync,
16
17
  rmSync,
17
18
  statSync,
18
19
  unlinkSync,
19
20
  watch,
20
21
  } from "node:fs";
21
- import { join } from "node:path";
22
+ import { dirname, join } from "node:path";
22
23
  import type { AgentMessage } from "@mariozechner/pi-agent-core";
23
24
  import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
24
25
  import { atomicWriteFileSync } from "../../_shared/atomic-write.js";
@@ -26,8 +27,11 @@ import { getTallowPath } from "../../_shared/tallow-paths.js";
26
27
 
27
28
  // ── Constants ────────────────────────────────────────────────────────────────
28
29
 
29
- /** Directory root for team-based shared task lists. */
30
- export const TEAMS_DIR = getTallowPath("teams");
30
+ /** Directory root for shared task-group state used by tasks/subagents. */
31
+ export const TASK_GROUPS_DIR = getTallowPath("task-groups");
32
+
33
+ /** Legacy directory root from older builds before task groups were split from teams. */
34
+ export const LEGACY_TEAMS_DIR = getTallowPath("teams");
31
35
 
32
36
  /** Max age for team directories before cleanup (7 days in ms). */
33
37
  export const TEAM_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -120,6 +124,30 @@ export function nextTaskId(state: TasksState): string {
120
124
  return id;
121
125
  }
122
126
 
127
+ /**
128
+ * Move a legacy shared-task directory into the new task-groups root.
129
+ *
130
+ * Older builds stored shared task state under `~/.tallow/teams/`, which
131
+ * collided conceptually with the teams runtime. New builds use
132
+ * `~/.tallow/task-groups/`. Migration is best-effort and intentionally silent.
133
+ *
134
+ * @param currentDirPath - New task directory path under task-groups/
135
+ * @param legacyDirPath - Legacy task directory path under teams/
136
+ * @returns void
137
+ */
138
+ function migrateLegacyTaskGroupDir(currentDirPath: string, legacyDirPath: string): void {
139
+ if (existsSync(currentDirPath) || !existsSync(legacyDirPath)) return;
140
+ const currentTeamDir = dirname(currentDirPath);
141
+ const legacyTeamDir = dirname(legacyDirPath);
142
+ mkdirSync(dirname(currentTeamDir), { recursive: true });
143
+ try {
144
+ renameSync(legacyTeamDir, currentTeamDir);
145
+ } catch {
146
+ // Best-effort migration only. If this fails, the session will recreate the
147
+ // new directory and continue without blocking startup.
148
+ }
149
+ }
150
+
123
151
  // ── Message helpers ──────────────────────────────────────────────────────────
124
152
 
125
153
  /**
@@ -150,15 +178,17 @@ export function getTextContent(message: AssistantMessage): string {
150
178
  /**
151
179
  * Persistent, file-backed task store for cross-session sharing.
152
180
  *
153
- * Each team gets a directory at `~/.tallow/teams/{team-name}/tasks/` containing
154
- * one JSON file per task. `fs.watch` on the directory detects changes from
155
- * other sessions sharing the same team.
181
+ * Each shared task group gets a directory at
182
+ * `~/.tallow/task-groups/{group-name}/tasks/` containing one JSON file per
183
+ * task. `fs.watch` on the directory detects changes from other sessions
184
+ * sharing the same task group.
156
185
  *
157
186
  * Without a team name, this store is inactive and the extension falls back
158
187
  * to session-entry persistence.
159
188
  */
160
189
  export class TaskListStore {
161
190
  private readonly dirPath: string | null;
191
+ private readonly legacyDirPath: string | null;
162
192
  private watcher: FSWatcher | null = null;
163
193
  private onChange: (() => void) | null = null;
164
194
  /** Debounce timer to coalesce rapid file change events. */
@@ -172,10 +202,13 @@ export class TaskListStore {
172
202
  constructor(teamName: string | null) {
173
203
  if (teamName) {
174
204
  const safeName = teamName.replace(/[^a-zA-Z0-9._-]/g, "_");
175
- this.dirPath = join(TEAMS_DIR, safeName, "tasks");
205
+ this.dirPath = join(TASK_GROUPS_DIR, safeName, "tasks");
206
+ this.legacyDirPath = join(LEGACY_TEAMS_DIR, safeName, "tasks");
207
+ migrateLegacyTaskGroupDir(this.dirPath, this.legacyDirPath);
176
208
  mkdirSync(this.dirPath, { recursive: true });
177
209
  } else {
178
210
  this.dirPath = null;
211
+ this.legacyDirPath = null;
179
212
  }
180
213
  }
181
214
 
@@ -196,14 +229,23 @@ export class TaskListStore {
196
229
  */
197
230
  loadAll(): Task[] | null {
198
231
  if (!this.dirPath) return null;
199
- if (!existsSync(this.dirPath)) return [];
200
-
232
+ const hasCurrentDir = existsSync(this.dirPath);
233
+ const hasLegacyDir = this.legacyDirPath ? existsSync(this.legacyDirPath) : false;
234
+ if (!hasCurrentDir && !hasLegacyDir) return [];
235
+
236
+ const currentFiles = hasCurrentDir
237
+ ? readdirSync(this.dirPath).filter((fileName) => fileName.endsWith(".json"))
238
+ : [];
239
+ const readDirPath =
240
+ currentFiles.length > 0 || !hasLegacyDir || !this.legacyDirPath
241
+ ? this.dirPath
242
+ : this.legacyDirPath;
201
243
  const tasks: Task[] = [];
202
244
  try {
203
- const files = readdirSync(this.dirPath).filter((f) => f.endsWith(".json"));
245
+ const files = readdirSync(readDirPath).filter((f) => f.endsWith(".json"));
204
246
  for (const file of files) {
205
247
  try {
206
- const raw = readFileSync(join(this.dirPath, file), "utf-8");
248
+ const raw = readFileSync(join(readDirPath, file), "utf-8");
207
249
  const parsed = JSON.parse(raw) as Record<string, unknown>;
208
250
  // Migrate old schema: title → subject, dependencies → blockedBy
209
251
  if (parsed.title && !parsed.subject) {
@@ -386,37 +428,49 @@ export class TaskListStore {
386
428
  }
387
429
 
388
430
  /**
389
- * Remove team directories older than {@link TEAM_MAX_AGE_MS}.
431
+ * Remove stale task-group directories from one root.
390
432
  *
391
- * Skips the current team (if any) to avoid deleting an active session.
392
- * Runs once per session start errors are silently ignored.
433
+ * @param rootDir - Root directory containing per-group subdirectories
434
+ * @param currentSafeName - Sanitized active task-group name to preserve
435
+ * @returns void
436
+ */
437
+ function cleanupStaleTaskGroupRoot(rootDir: string, currentSafeName: string | null): void {
438
+ if (!existsSync(rootDir)) return;
439
+ const now = Date.now();
440
+ for (const entry of readdirSync(rootDir, { withFileTypes: true })) {
441
+ if (!entry.isDirectory()) continue;
442
+ if (entry.name === currentSafeName) continue;
443
+
444
+ const taskGroupPath = join(rootDir, entry.name);
445
+ try {
446
+ // Check tasks/ subdir mtime — that's where writes happen.
447
+ const tasksPath = join(taskGroupPath, "tasks");
448
+ const target = existsSync(tasksPath) ? tasksPath : taskGroupPath;
449
+ const { mtimeMs } = statSync(target);
450
+ if (now - mtimeMs > TEAM_MAX_AGE_MS) {
451
+ rmSync(taskGroupPath, { recursive: true, force: true });
452
+ }
453
+ } catch {
454
+ // Skip individual failures (permissions, race conditions).
455
+ }
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Remove task-group directories older than {@link TEAM_MAX_AGE_MS}.
461
+ *
462
+ * Skips the current task group (if any) to avoid deleting an active session.
463
+ * Runs once per session start. The legacy `~/.tallow/teams/` root is also
464
+ * cleaned so older builds do not leave permanent clutter behind.
393
465
  *
394
- * @param currentTeamName - The active team name to preserve, or null
466
+ * @param currentTeamName - The active shared task-group name to preserve, or null
395
467
  */
396
468
  export function cleanupStaleTeams(currentTeamName: string | null): void {
397
469
  try {
398
- if (!existsSync(TEAMS_DIR)) return;
399
- const now = Date.now();
400
470
  const currentSafeName = currentTeamName?.replace(/[^a-zA-Z0-9._-]/g, "_") ?? null;
401
-
402
- for (const entry of readdirSync(TEAMS_DIR, { withFileTypes: true })) {
403
- if (!entry.isDirectory()) continue;
404
- if (entry.name === currentSafeName) continue;
405
-
406
- const teamPath = join(TEAMS_DIR, entry.name);
407
- try {
408
- // Check tasks/ subdir mtime — that's where writes happen
409
- const tasksPath = join(teamPath, "tasks");
410
- const target = existsSync(tasksPath) ? tasksPath : teamPath;
411
- const { mtimeMs } = statSync(target);
412
- if (now - mtimeMs > TEAM_MAX_AGE_MS) {
413
- rmSync(teamPath, { recursive: true, force: true });
414
- }
415
- } catch {
416
- // Skip individual failures (permissions, race conditions)
417
- }
418
- }
471
+ cleanupStaleTaskGroupRoot(TASK_GROUPS_DIR, currentSafeName);
472
+ cleanupStaleTaskGroupRoot(LEGACY_TEAMS_DIR, currentSafeName);
419
473
  } catch {
420
- // TEAMS_DIR doesn't exist or isn't readable — nothing to clean
474
+ // Task-group roots don't exist or aren't readable — nothing to clean.
421
475
  }
422
476
  }
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ deleteArchivedTeamFromDisk,
7
+ getTeamArchivesDir,
8
+ loadAllArchivedTeamsFromDisk,
9
+ loadArchivedTeamFromDisk,
10
+ writeArchivedTeamToDisk,
11
+ } from "../archive-store.js";
12
+ import {
13
+ addTaskToBoard,
14
+ addTeamMessage,
15
+ archiveTeam,
16
+ createTeamStore,
17
+ getArchivedTeams,
18
+ getTeams,
19
+ } from "../store.js";
20
+
21
+ let originalTallowHome: string | undefined;
22
+ let tmpHome: string;
23
+
24
+ beforeEach(() => {
25
+ originalTallowHome = process.env.TALLOW_CODING_AGENT_DIR;
26
+ tmpHome = mkdtempSync(join(tmpdir(), "tallow-team-archives-"));
27
+ process.env.TALLOW_CODING_AGENT_DIR = tmpHome;
28
+ getArchivedTeams().clear();
29
+ getTeams().clear();
30
+ });
31
+
32
+ afterEach(() => {
33
+ getArchivedTeams().clear();
34
+ getTeams().clear();
35
+ if (originalTallowHome === undefined) {
36
+ delete process.env.TALLOW_CODING_AGENT_DIR;
37
+ } else {
38
+ process.env.TALLOW_CODING_AGENT_DIR = originalTallowHome;
39
+ }
40
+ rmSync(tmpHome, { force: true, recursive: true });
41
+ });
42
+
43
+ describe("team archive persistence", () => {
44
+ it("writes and reloads archived teams from disk", () => {
45
+ const team = createTeamStore("alpha");
46
+ addTaskToBoard(team, "Investigate", "Read files", []);
47
+ const message = addTeamMessage(team, "alice", "bob", "hello");
48
+ message.readBy.add("bob");
49
+
50
+ const archived = archiveTeam("alpha");
51
+ expect(archived).toBeDefined();
52
+ if (!archived) return;
53
+
54
+ writeArchivedTeamToDisk(archived);
55
+
56
+ const loaded = loadArchivedTeamFromDisk("alpha");
57
+ expect(loaded).toBeDefined();
58
+ expect(loaded?.name).toBe("alpha");
59
+ expect(loaded?.tasks).toHaveLength(1);
60
+ expect(loaded?.messages).toHaveLength(1);
61
+ expect(loaded?.messages[0].readBy.has("bob")).toBe(true);
62
+ });
63
+
64
+ it("lists archives newest-first", () => {
65
+ const first = createTeamStore("first");
66
+ addTaskToBoard(first, "One", "", []);
67
+ const archivedFirst = archiveTeam("first");
68
+ expect(archivedFirst).toBeDefined();
69
+ if (!archivedFirst) return;
70
+ archivedFirst.archivedAt = 1;
71
+ writeArchivedTeamToDisk(archivedFirst);
72
+
73
+ const second = createTeamStore("second");
74
+ addTaskToBoard(second, "Two", "", []);
75
+ const archivedSecond = archiveTeam("second");
76
+ expect(archivedSecond).toBeDefined();
77
+ if (!archivedSecond) return;
78
+ archivedSecond.archivedAt = 2;
79
+ writeArchivedTeamToDisk(archivedSecond);
80
+
81
+ const names = loadAllArchivedTeamsFromDisk().map((archive) => archive.name);
82
+ expect(names).toEqual(["second", "first"]);
83
+ });
84
+
85
+ it("deletes persisted archives", () => {
86
+ createTeamStore("gone");
87
+ const archived = archiveTeam("gone");
88
+ expect(archived).toBeDefined();
89
+ if (!archived) return;
90
+ writeArchivedTeamToDisk(archived);
91
+ expect(loadArchivedTeamFromDisk("gone")?.name).toBe("gone");
92
+
93
+ deleteArchivedTeamFromDisk("gone");
94
+
95
+ expect(loadArchivedTeamFromDisk("gone")).toBeUndefined();
96
+ expect(getTeamArchivesDir().startsWith(tmpHome)).toBe(true);
97
+ });
98
+ });
@@ -258,4 +258,30 @@ describe("teammate task board operations", () => {
258
258
  expect(team.tasks[0].status).toBe("completed");
259
259
  expect(team.tasks[0].result).toBe("Found 388 files");
260
260
  });
261
+
262
+ it("rejects completing a task owned by another teammate", async () => {
263
+ const team = freshTeam();
264
+ const { mate: alice } = mockTeammate("alice");
265
+ const { mate: bob } = mockTeammate("bob");
266
+ team.teammates.set("alice", alice);
267
+ team.teammates.set("bob", bob);
268
+
269
+ const { addTaskToBoard } = await import("../store");
270
+ const task = addTaskToBoard(team, "Count files", "Count .ts files", []);
271
+ task.status = "claimed";
272
+ task.assignee = "alice";
273
+
274
+ const bobTools = createTeammateTools(team, "bob");
275
+ const tasksTool = findTool(bobTools, "team_tasks");
276
+ const result = await tasksTool.execute("c4", {
277
+ action: "complete",
278
+ taskId: "1",
279
+ result: "I should not be allowed",
280
+ });
281
+
282
+ expect(result.isError).toBe(true);
283
+ expect(result.content[0].text).toContain("Only the assignee can complete it");
284
+ expect(team.tasks[0].status).toBe("claimed");
285
+ expect(team.tasks[0].result).toBeNull();
286
+ });
261
287
  });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Persistent archive storage for teams-tool.
3
+ *
4
+ * Archives are stored on disk so `team_resume` survives process restarts and
5
+ * session shutdown. Runtime state still uses the in-memory store; this module
6
+ * only handles serialization and persistence of archived snapshots.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { atomicWriteFileSync } from "../_shared/atomic-write.js";
12
+ import { getTallowPath } from "../_shared/tallow-paths.js";
13
+ import type { ArchivedTeam, TeamMessage } from "./store.js";
14
+
15
+ interface SerializedTeamMessage {
16
+ readonly content: string;
17
+ readonly from: string;
18
+ readonly readBy: readonly string[];
19
+ readonly timestamp: number;
20
+ readonly to: string;
21
+ }
22
+
23
+ interface SerializedArchivedTeam {
24
+ readonly archivedAt: number;
25
+ readonly messages: readonly SerializedTeamMessage[];
26
+ readonly name: string;
27
+ readonly taskCounter: number;
28
+ readonly tasks: ArchivedTeam["tasks"];
29
+ }
30
+
31
+ /**
32
+ * Resolve the directory that stores archived teams.
33
+ *
34
+ * @returns Absolute archive directory path under the active tallow home
35
+ */
36
+ export function getTeamArchivesDir(): string {
37
+ return getTallowPath("team-archives");
38
+ }
39
+
40
+ /**
41
+ * Ensure the archive directory exists before reading or writing.
42
+ *
43
+ * @returns Archive directory path
44
+ */
45
+ function ensureTeamArchivesDir(): string {
46
+ const dir = getTeamArchivesDir();
47
+ mkdirSync(dir, { recursive: true });
48
+ return dir;
49
+ }
50
+
51
+ /**
52
+ * Resolve the archive file path for one team name.
53
+ *
54
+ * @param teamName - Team name to encode into a stable file name
55
+ * @returns Absolute JSON file path
56
+ */
57
+ function getArchiveFilePath(teamName: string): string {
58
+ return join(ensureTeamArchivesDir(), `${encodeURIComponent(teamName)}.json`);
59
+ }
60
+
61
+ /**
62
+ * Convert a runtime TeamMessage into a JSON-safe form.
63
+ *
64
+ * @param message - Runtime message with Set-based read tracking
65
+ * @returns Serializable message record
66
+ */
67
+ function serializeTeamMessage(message: TeamMessage): SerializedTeamMessage {
68
+ return {
69
+ content: message.content,
70
+ from: message.from,
71
+ readBy: [...message.readBy].sort(),
72
+ timestamp: message.timestamp,
73
+ to: message.to,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Convert an archived team into a JSON-safe form.
79
+ *
80
+ * @param archived - Archived team snapshot from the runtime store
81
+ * @returns Serializable archive payload
82
+ */
83
+ function serializeArchivedTeam(archived: ArchivedTeam): SerializedArchivedTeam {
84
+ return {
85
+ archivedAt: archived.archivedAt,
86
+ messages: archived.messages.map(serializeTeamMessage),
87
+ name: archived.name,
88
+ taskCounter: archived.taskCounter,
89
+ tasks: archived.tasks,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Convert a serialized message back into the runtime representation.
95
+ *
96
+ * @param message - JSON-parsed message payload
97
+ * @returns Runtime message with Set-based read tracking
98
+ */
99
+ function deserializeTeamMessage(message: SerializedTeamMessage): TeamMessage {
100
+ return {
101
+ content: message.content,
102
+ from: message.from,
103
+ readBy: new Set(message.readBy),
104
+ timestamp: message.timestamp,
105
+ to: message.to,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Parse an archived-team JSON payload.
111
+ *
112
+ * @param raw - Raw JSON string from disk
113
+ * @returns Parsed archive, or undefined when malformed
114
+ */
115
+ function deserializeArchivedTeam(raw: string): ArchivedTeam | undefined {
116
+ try {
117
+ const parsed = JSON.parse(raw) as Partial<SerializedArchivedTeam>;
118
+ if (
119
+ typeof parsed.name !== "string" ||
120
+ typeof parsed.archivedAt !== "number" ||
121
+ typeof parsed.taskCounter !== "number" ||
122
+ !Array.isArray(parsed.tasks) ||
123
+ !Array.isArray(parsed.messages)
124
+ ) {
125
+ return undefined;
126
+ }
127
+ return {
128
+ archivedAt: parsed.archivedAt,
129
+ messages: parsed.messages.map((message) => deserializeTeamMessage(message)),
130
+ name: parsed.name,
131
+ taskCounter: parsed.taskCounter,
132
+ tasks: parsed.tasks,
133
+ };
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Persist one archived team snapshot to disk.
141
+ *
142
+ * @param archived - Archived team snapshot to write
143
+ * @returns Nothing
144
+ */
145
+ export function writeArchivedTeamToDisk(archived: ArchivedTeam): void {
146
+ const filePath = getArchiveFilePath(archived.name);
147
+ atomicWriteFileSync(filePath, JSON.stringify(serializeArchivedTeam(archived), null, 2));
148
+ }
149
+
150
+ /**
151
+ * Delete one archived team snapshot from disk.
152
+ *
153
+ * @param teamName - Team whose archive should be removed
154
+ * @returns Nothing
155
+ */
156
+ export function deleteArchivedTeamFromDisk(teamName: string): void {
157
+ const filePath = getArchiveFilePath(teamName);
158
+ if (!existsSync(filePath)) return;
159
+ unlinkSync(filePath);
160
+ }
161
+
162
+ /**
163
+ * Load one archived team snapshot from disk.
164
+ *
165
+ * @param teamName - Team whose archive should be read
166
+ * @returns Archived snapshot, or undefined when missing or malformed
167
+ */
168
+ export function loadArchivedTeamFromDisk(teamName: string): ArchivedTeam | undefined {
169
+ const filePath = getArchiveFilePath(teamName);
170
+ if (!existsSync(filePath)) return undefined;
171
+ try {
172
+ return deserializeArchivedTeam(readFileSync(filePath, "utf-8"));
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Load all archived team snapshots from disk.
180
+ *
181
+ * Malformed files are skipped rather than crashing archive discovery.
182
+ * Results are sorted newest-first for status listings.
183
+ *
184
+ * @returns Archived team snapshots persisted on disk
185
+ */
186
+ export function loadAllArchivedTeamsFromDisk(): ArchivedTeam[] {
187
+ const dir = ensureTeamArchivesDir();
188
+ const archives: ArchivedTeam[] = [];
189
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
190
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
191
+ try {
192
+ const archive = deserializeArchivedTeam(readFileSync(join(dir, entry.name), "utf-8"));
193
+ if (!archive) continue;
194
+ archives.push(archive);
195
+ } catch {
196
+ // Skip unreadable archive files instead of breaking discovery.
197
+ }
198
+ }
199
+ return archives.sort((left, right) => right.archivedAt - left.archivedAt);
200
+ }