@gajae-code/coding-agent 0.2.1 → 0.2.3

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 (153) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/dist/types/cli/setup-cli.d.ts +1 -0
  3. package/dist/types/commands/contribution-prep.d.ts +18 -0
  4. package/dist/types/commands/deep-interview.d.ts +41 -0
  5. package/dist/types/commands/session.d.ts +24 -0
  6. package/dist/types/commands/setup.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +2 -2
  8. package/dist/types/config/models-config-schema.d.ts +17 -9
  9. package/dist/types/config/settings-schema.d.ts +37 -24
  10. package/dist/types/discovery/helpers.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  12. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +33 -0
  13. package/dist/types/gjc-runtime/goal-mode-request.d.ts +1 -1
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +12 -11
  15. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +25 -0
  16. package/dist/types/gjc-runtime/state-runtime.d.ts +13 -0
  17. package/dist/types/gjc-runtime/team-runtime.d.ts +37 -5
  18. package/dist/types/gjc-runtime/tmux-common.d.ts +41 -0
  19. package/dist/types/gjc-runtime/tmux-sessions.d.ts +17 -0
  20. package/dist/types/goals/runtime.d.ts +3 -9
  21. package/dist/types/goals/state.d.ts +3 -6
  22. package/dist/types/goals/tools/goal-tool.d.ts +1 -69
  23. package/dist/types/hooks/skill-state.d.ts +5 -0
  24. package/dist/types/memories/index.d.ts +1 -1
  25. package/dist/types/memory-backend/local-backend.d.ts +3 -3
  26. package/dist/types/modes/components/hook-selector.d.ts +7 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +0 -2
  28. package/dist/types/modes/components/status-line/types.d.ts +0 -3
  29. package/dist/types/modes/components/status-line.d.ts +0 -3
  30. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +1 -12
  32. package/dist/types/modes/theme/defaults/index.d.ts +0 -2
  33. package/dist/types/modes/theme/theme.d.ts +1 -2
  34. package/dist/types/modes/types.d.ts +1 -7
  35. package/dist/types/modes/utils/context-usage.d.ts +6 -2
  36. package/dist/types/sdk.d.ts +6 -2
  37. package/dist/types/session/agent-session.d.ts +47 -1
  38. package/dist/types/session/contribution-prep.d.ts +47 -0
  39. package/dist/types/session/session-manager.d.ts +3 -0
  40. package/dist/types/setup/model-onboarding-guidance.d.ts +1 -0
  41. package/dist/types/setup/provider-onboarding.d.ts +29 -5
  42. package/dist/types/skill-state/active-state.d.ts +30 -1
  43. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +6 -1
  44. package/dist/types/skill-state/initial-phase.d.ts +12 -0
  45. package/dist/types/skill-state/workflow-hud.d.ts +9 -4
  46. package/dist/types/skill-state/workflow-state-contract.d.ts +34 -0
  47. package/dist/types/task/executor.d.ts +2 -0
  48. package/dist/types/task/types.d.ts +11 -0
  49. package/dist/types/tools/index.d.ts +20 -1
  50. package/dist/types/tools/skill.d.ts +47 -0
  51. package/dist/types/utils/changelog.d.ts +18 -2
  52. package/package.json +7 -7
  53. package/src/cli/args.ts +3 -2
  54. package/src/cli/setup-cli.ts +26 -12
  55. package/src/cli.ts +7 -1
  56. package/src/commands/contribution-prep.ts +41 -0
  57. package/src/commands/deep-interview.ts +30 -23
  58. package/src/commands/launch.ts +10 -1
  59. package/src/commands/ralplan.ts +10 -22
  60. package/src/commands/session.ts +150 -0
  61. package/src/commands/setup.ts +2 -0
  62. package/src/commands/state.ts +15 -4
  63. package/src/commands/team.ts +23 -3
  64. package/src/config/model-registry.ts +10 -2
  65. package/src/config/models-config-schema.ts +120 -102
  66. package/src/config/settings-schema.ts +42 -25
  67. package/src/config.ts +1 -1
  68. package/src/defaults/gjc/skills/deep-interview/SKILL.md +32 -13
  69. package/src/defaults/gjc/skills/ralplan/SKILL.md +22 -2
  70. package/src/defaults/gjc/skills/team/SKILL.md +39 -7
  71. package/src/defaults/gjc/skills/ultragoal/SKILL.md +33 -25
  72. package/src/discovery/helpers.ts +24 -1
  73. package/src/eval/py/prelude.py +1 -1
  74. package/src/extensibility/extensions/types.ts +6 -0
  75. package/src/gjc-runtime/deep-interview-runtime.ts +546 -0
  76. package/src/gjc-runtime/goal-mode-request.ts +2 -19
  77. package/src/gjc-runtime/launch-tmux.ts +83 -43
  78. package/src/gjc-runtime/ralplan-runtime.ts +460 -0
  79. package/src/gjc-runtime/state-runtime.ts +731 -0
  80. package/src/gjc-runtime/team-runtime.ts +708 -52
  81. package/src/gjc-runtime/tmux-common.ts +119 -0
  82. package/src/gjc-runtime/tmux-sessions.ts +165 -0
  83. package/src/gjc-runtime/ultragoal-guard.ts +6 -3
  84. package/src/gjc-runtime/ultragoal-runtime.ts +5 -4
  85. package/src/goals/runtime.ts +38 -144
  86. package/src/goals/state.ts +36 -7
  87. package/src/goals/tools/goal-tool.ts +15 -172
  88. package/src/hooks/skill-state.ts +39 -18
  89. package/src/internal-urls/docs-index.generated.ts +5 -4
  90. package/src/internal-urls/memory-protocol.ts +3 -2
  91. package/src/main.ts +2 -3
  92. package/src/memories/index.ts +2 -1
  93. package/src/memory-backend/local-backend.ts +14 -6
  94. package/src/modes/components/hook-selector.ts +156 -1
  95. package/src/modes/components/settings-selector.ts +5 -12
  96. package/src/modes/components/skill-hud/render.ts +4 -0
  97. package/src/modes/components/status-line/segments.ts +5 -16
  98. package/src/modes/components/status-line/types.ts +0 -3
  99. package/src/modes/components/status-line.ts +0 -6
  100. package/src/modes/controllers/command-controller.ts +27 -4
  101. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  102. package/src/modes/controllers/input-controller.ts +0 -15
  103. package/src/modes/controllers/selector-controller.ts +4 -11
  104. package/src/modes/interactive-mode.ts +18 -219
  105. package/src/modes/theme/defaults/dark-poimandres.json +0 -1
  106. package/src/modes/theme/defaults/light-poimandres.json +0 -1
  107. package/src/modes/theme/theme.ts +0 -6
  108. package/src/modes/types.ts +1 -7
  109. package/src/modes/utils/context-usage.ts +66 -17
  110. package/src/prompts/agents/architect.md +3 -0
  111. package/src/prompts/agents/executor.md +2 -0
  112. package/src/prompts/agents/frontmatter.md +1 -0
  113. package/src/prompts/goals/goal-continuation.md +1 -4
  114. package/src/prompts/goals/goal-mode-active.md +3 -5
  115. package/src/prompts/system/subagent-system-prompt.md +6 -0
  116. package/src/prompts/system/system-prompt.md +5 -7
  117. package/src/prompts/tools/goal.md +4 -4
  118. package/src/prompts/tools/skill.md +28 -0
  119. package/src/prompts/tools/task.md +3 -0
  120. package/src/sdk.ts +51 -11
  121. package/src/session/agent-session.ts +222 -21
  122. package/src/session/contribution-prep.ts +320 -0
  123. package/src/session/session-manager.ts +9 -1
  124. package/src/setup/model-onboarding-guidance.ts +6 -3
  125. package/src/setup/provider-onboarding.ts +177 -16
  126. package/src/skill-state/active-state.ts +188 -25
  127. package/src/skill-state/deep-interview-mutation-guard.ts +72 -21
  128. package/src/skill-state/initial-phase.ts +17 -0
  129. package/src/skill-state/workflow-hud.ts +23 -5
  130. package/src/skill-state/workflow-state-contract.ts +121 -0
  131. package/src/slash-commands/builtin-registry.ts +75 -25
  132. package/src/slash-commands/helpers/context-report.ts +123 -13
  133. package/src/task/agents.ts +1 -0
  134. package/src/task/commands.ts +1 -5
  135. package/src/task/executor.ts +9 -1
  136. package/src/task/index.ts +91 -4
  137. package/src/task/types.ts +6 -0
  138. package/src/tools/ask.ts +2 -0
  139. package/src/tools/gh.ts +212 -2
  140. package/src/tools/index.ts +25 -6
  141. package/src/tools/skill.ts +153 -0
  142. package/src/utils/changelog.ts +67 -44
  143. package/dist/types/commands/gjc-runtime-bridge.d.ts +0 -30
  144. package/dist/types/commands/question.d.ts +0 -7
  145. package/dist/types/modes/loop-limit.d.ts +0 -22
  146. package/src/commands/gjc-runtime-bridge.ts +0 -227
  147. package/src/commands/question.ts +0 -12
  148. package/src/modes/loop-limit.ts +0 -140
  149. package/src/prompts/commands/orchestrate.md +0 -49
  150. package/src/prompts/goals/goal-budget-limit.md +0 -16
  151. package/src/prompts/tools/create-goal.md +0 -3
  152. package/src/prompts/tools/get-goal.md +0 -3
  153. package/src/prompts/tools/update-goal.md +0 -3
@@ -1,11 +1,11 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import type { WorkflowHudSummary } from "../skill-state/active-state";
5
5
  import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
6
6
  import { applyGjcTmuxProfile } from "./launch-tmux";
7
7
 
8
- export type GjcTeamPhase = "starting" | "running" | "complete" | "failed" | "cancelled";
8
+ export type GjcTeamPhase = "starting" | "running" | "awaiting_integration" | "complete" | "failed" | "cancelled";
9
9
  export type GjcTeamTaskStatus = "pending" | "blocked" | "in_progress" | "completed" | "failed";
10
10
  export type GjcWorkerStatusState = "idle" | "working" | "blocked" | "done" | "failed" | "draining" | "unknown";
11
11
 
@@ -89,6 +89,7 @@ export interface GjcTeamConfig {
89
89
  tmux_session_name: string;
90
90
  tmux_target: string;
91
91
  workspace_mode: "direct" | "worktree";
92
+ dry_run: boolean;
92
93
  leader: GjcTeamLeader;
93
94
  leader_cwd: string;
94
95
  team_state_root: string;
@@ -121,6 +122,39 @@ export interface GjcTeamMonitorSnapshot {
121
122
  updated_at: string;
122
123
  }
123
124
 
125
+ export type GjcTeamNotificationDeliveryState =
126
+ | "pending"
127
+ | "sent"
128
+ | "queued"
129
+ | "deferred"
130
+ | "failed"
131
+ | "delivered"
132
+ | "acknowledged";
133
+
134
+ export type GjcTeamPaneAttemptResult = "sent" | "queued" | "deferred" | "failed";
135
+
136
+ export interface GjcTeamNotification {
137
+ id: string;
138
+ kind: "mailbox_message" | "worker_lifecycle" | "invalid_attempt";
139
+ team_name: string;
140
+ recipient: string;
141
+ source: { type: "message" | "task" | "worker" | "event"; id: string };
142
+ idempotency_key?: string;
143
+ delivery_state: GjcTeamNotificationDeliveryState;
144
+ pane_attempt_result?: GjcTeamPaneAttemptResult;
145
+ pane_attempt_reason?: string;
146
+ pane_attempt_at?: string;
147
+ created_at: string;
148
+ updated_at: string;
149
+ replay_count: number;
150
+ }
151
+
152
+ export interface GjcTeamNotificationSummary {
153
+ total: number;
154
+ replay_eligible: number;
155
+ by_state: Record<GjcTeamNotificationDeliveryState, number>;
156
+ }
157
+
124
158
  export interface GjcTeamSnapshot {
125
159
  team_name: string;
126
160
  display_name: string;
@@ -133,6 +167,7 @@ export interface GjcTeamSnapshot {
133
167
  task_counts: Record<GjcTeamTaskStatus, number>;
134
168
  workers: GjcTeamWorker[];
135
169
  integration_by_worker?: Record<string, GjcTeamWorkerIntegrationState>;
170
+ notification_summary: GjcTeamNotificationSummary;
136
171
  updated_at: string;
137
172
  }
138
173
 
@@ -163,6 +198,7 @@ export interface GjcTeamMailboxMessage {
163
198
  created_at: string;
164
199
  delivered_at?: string;
165
200
  notified_at?: string;
201
+ idempotency_key?: string;
166
202
  }
167
203
 
168
204
  interface FsError {
@@ -319,6 +355,11 @@ export const GJC_TEAM_API_OPERATIONS = [
319
355
  "mailbox-list",
320
356
  "mailbox-mark-delivered",
321
357
  "mailbox-mark-notified",
358
+ "notification-list",
359
+ "notification-read",
360
+ "notification-replay",
361
+ "notification-mark-pane-attempt",
362
+ "worker-startup-ack",
322
363
  "create-task",
323
364
  "read-task",
324
365
  "list-tasks",
@@ -367,6 +408,10 @@ function sanitizeName(value: string): string {
367
408
  function shortHash(value: string): string {
368
409
  return Bun.hash(value).toString(16).slice(0, 8).padStart(8, "0");
369
410
  }
411
+
412
+ function stableHash(value: string): string {
413
+ return createHash("sha256").update(value).digest("hex").slice(0, 24);
414
+ }
370
415
  function makeTeamName(task: string, env: NodeJS.ProcessEnv): string {
371
416
  const basis = [task, env.GJC_SESSION_ID, env.CODEX_SESSION_ID, env.TMUX_PANE, env.TMUX, now()]
372
417
  .filter(Boolean)
@@ -380,14 +425,65 @@ function teamDir(stateRoot: string, teamName: string): string {
380
425
  function shellQuote(value: string): string {
381
426
  return `'${value.replace(/'/g, "'\\''")}'`;
382
427
  }
428
+ function safePathSegment(kind: string, value: string): string {
429
+ assertSafeId(kind, value);
430
+ return value;
431
+ }
383
432
  function taskPath(dir: string, taskId: string): string {
384
- return path.join(dir, "tasks", `${taskId}.json`);
433
+ return path.join(dir, "tasks", `${safePathSegment("task_id", taskId)}.json`);
434
+ }
435
+ function taskEvidencePath(dir: string, taskId: string): string {
436
+ return path.join(dir, "evidence", "tasks", `${safePathSegment("task_id", taskId)}.json`);
385
437
  }
386
438
  function mailboxPath(dir: string, worker: string): string {
387
- return path.join(dir, "mailbox", `${worker}.json`);
439
+ return path.join(dir, "mailbox", `${safePathSegment("worker_id", worker)}.json`);
440
+ }
441
+ function mailboxDirPath(dir: string, worker: string): string {
442
+ return path.join(dir, "mailbox", safePathSegment("worker_id", worker));
443
+ }
444
+ function mailboxMessagePath(dir: string, worker: string, messageId: string): string {
445
+ return path.join(mailboxDirPath(dir, worker), `${safePathSegment("message_id", messageId)}.json`);
446
+ }
447
+ function notificationPath(dir: string, notificationId: string): string {
448
+ return path.join(dir, "notifications", `${safePathSegment("notification_id", notificationId)}.json`);
388
449
  }
389
450
  function workerDir(dir: string, worker: string): string {
390
- return path.join(dir, "workers", worker);
451
+ return path.join(dir, "workers", safePathSegment("worker_id", worker));
452
+ }
453
+ function isSafeId(value: string): boolean {
454
+ return (
455
+ /^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(value) &&
456
+ !value.includes("..") &&
457
+ !value.includes("/") &&
458
+ !value.includes("\\")
459
+ );
460
+ }
461
+ function assertSafeId(kind: string, value: string): void {
462
+ if (!isSafeId(value)) throw new Error(`invalid_${kind}:${value}`);
463
+ }
464
+ function isLeaderRecipient(value: string): boolean {
465
+ return value === "leader-fixed";
466
+ }
467
+ function assertKnownWorker(config: GjcTeamConfig, worker: string, allowLeader = false): void {
468
+ assertSafeId("worker_id", worker);
469
+ if (allowLeader && isLeaderRecipient(worker)) return;
470
+ if (!config.workers.some(candidate => candidate.id === worker)) throw new Error(`unknown_worker:${worker}`);
471
+ }
472
+ function assertKnownParticipant(config: GjcTeamConfig, worker: string): void {
473
+ assertKnownWorker(config, worker, true);
474
+ }
475
+ function messageNotificationId(teamName: string, recipient: string, messageId: string): string {
476
+ return `ntf-${stableHash(["mailbox_message", teamName, recipient, messageId].join(":"))}`;
477
+ }
478
+ function messageIdFor(input: {
479
+ teamName: string;
480
+ fromWorker: string;
481
+ toWorker: string;
482
+ body: string;
483
+ idempotencyKey?: string;
484
+ createdKey: string;
485
+ }): string {
486
+ return `msg-${stableHash([input.teamName, input.fromWorker, input.toWorker, input.idempotencyKey ?? input.body, input.createdKey].join(":"))}`;
391
487
  }
392
488
  function workerIntegrationDedupePath(dir: string, worker: string): string {
393
489
  return path.join(workerDir(dir, worker), "posttooluse-dedupe.json");
@@ -409,7 +505,23 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
409
505
  }
410
506
  async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
411
507
  await fs.mkdir(path.dirname(filePath), { recursive: true });
412
- await Bun.write(filePath, `${JSON.stringify(value, null, 2)}\n`);
508
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`;
509
+ await Bun.write(tmpPath, `${JSON.stringify(value, null, 2)}\n`);
510
+ await fs.rename(tmpPath, filePath);
511
+ }
512
+ async function writeJsonFileNoClobber(filePath: string, value: unknown): Promise<boolean> {
513
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
514
+ let handle: fs.FileHandle | undefined;
515
+ try {
516
+ handle = await fs.open(filePath, "wx");
517
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf-8");
518
+ return true;
519
+ } catch (error) {
520
+ if (isEexist(error)) return false;
521
+ throw error;
522
+ } finally {
523
+ await handle?.close();
524
+ }
413
525
  }
414
526
  async function appendJsonl(filePath: string, value: unknown): Promise<void> {
415
527
  await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -437,6 +549,7 @@ async function readConfig(dir: string): Promise<GjcTeamConfig> {
437
549
  tmux_session: tmuxSessionName,
438
550
  tmux_session_name: tmuxSessionName,
439
551
  tmux_target: config.tmux_target ?? config.tmux_session ?? tmuxSessionName,
552
+ dry_run: config.dry_run ?? config.tmux_session_name === "dry-run",
440
553
  leader_cwd: config.leader_cwd ?? config.leader.cwd,
441
554
  team_state_root: config.team_state_root ?? config.state_root,
442
555
  worker_cli_plan: config.worker_cli_plan ?? Array.from({ length: config.worker_count }, () => "gjc"),
@@ -462,16 +575,76 @@ function normalizeTask(raw: GjcTeamTask): GjcTeamTask {
462
575
  version: raw.version ?? 1,
463
576
  };
464
577
  }
578
+
579
+ const GJC_TEAM_INTEGRATION_ATTENTION_STATUSES = new Set<GjcTeamIntegrationStatus>([
580
+ "integration_failed",
581
+ "merge_conflict",
582
+ "cherry_pick_conflict",
583
+ "rebase_conflict",
584
+ ]);
585
+ const GJC_TEAM_INTEGRATION_SETTLED_STATUSES = new Set<GjcTeamIntegrationStatus>(["idle", "integrated"]);
586
+
587
+ async function hasPendingGjcTeamIntegration(
588
+ dir: string,
589
+ config: GjcTeamConfig,
590
+ monitor: GjcTeamMonitorSnapshot | null,
591
+ ): Promise<boolean> {
592
+ for (const worker of config.workers) {
593
+ const integration = monitor?.integration_by_worker?.[worker.id];
594
+ if (integration?.status && GJC_TEAM_INTEGRATION_ATTENTION_STATUSES.has(integration.status)) return true;
595
+
596
+ const request = await readJsonFile<GjcWorkerIntegrationDedupeState>(workerIntegrationDedupePath(dir, worker.id));
597
+ if (!request?.last_requested_at) continue;
598
+ if (!integration?.status || !integration.updated_at) return true;
599
+ if (GJC_TEAM_INTEGRATION_ATTENTION_STATUSES.has(integration.status)) return true;
600
+ if (
601
+ GJC_TEAM_INTEGRATION_SETTLED_STATUSES.has(integration.status) &&
602
+ integration.updated_at >= request.last_requested_at
603
+ ) {
604
+ continue;
605
+ }
606
+ return true;
607
+ }
608
+ return false;
609
+ }
610
+
611
+ async function resolveGjcTeamSnapshotPhase(
612
+ dir: string,
613
+ config: GjcTeamConfig,
614
+ storedPhase: GjcTeamPhase,
615
+ tasks: GjcTeamTask[],
616
+ monitor: GjcTeamMonitorSnapshot | null,
617
+ ): Promise<GjcTeamPhase> {
618
+ if (storedPhase !== "running") return storedPhase;
619
+ if (tasks.length === 0 || !tasks.every(task => task.status === "completed")) return storedPhase;
620
+ return (await hasPendingGjcTeamIntegration(dir, config, monitor)) ? "awaiting_integration" : storedPhase;
621
+ }
622
+
623
+ function isRecord(value: unknown): value is Record<string, unknown> {
624
+ return typeof value === "object" && value != null;
625
+ }
626
+ function isGjcTeamTaskRecord(value: unknown): value is GjcTeamTask {
627
+ return (
628
+ isRecord(value) &&
629
+ typeof value.id === "string" &&
630
+ typeof value.status === "string" &&
631
+ (isGjcTeamTaskStatus(value.status) || value.status === "complete") &&
632
+ (typeof value.subject === "string" || typeof value.title === "string") &&
633
+ (typeof value.description === "string" || typeof value.objective === "string")
634
+ );
635
+ }
636
+ function isGjcTeamTaskFile(entry: { isFile(): boolean; name: string }): boolean {
637
+ return entry.isFile() && entry.name.endsWith(".json") && !entry.name.endsWith(".evidence.json");
638
+ }
639
+
465
640
  async function readTasks(dir: string): Promise<GjcTeamTask[]> {
466
641
  try {
467
642
  const entries = await fs.readdir(path.join(dir, "tasks"), { withFileTypes: true });
468
643
  const tasks = await Promise.all(
469
- entries
470
- .filter(entry => entry.isFile() && entry.name.endsWith(".json"))
471
- .map(entry => readJsonFile<GjcTeamTask>(path.join(dir, "tasks", entry.name))),
644
+ entries.filter(isGjcTeamTaskFile).map(entry => readJsonFile<unknown>(path.join(dir, "tasks", entry.name))),
472
645
  );
473
646
  return tasks
474
- .filter((task): task is GjcTeamTask => task != null)
647
+ .filter(isGjcTeamTaskRecord)
475
648
  .map(normalizeTask)
476
649
  .sort((a, b) => a.id.localeCompare(b.id));
477
650
  } catch (error) {
@@ -678,6 +851,7 @@ function buildWorkerCommand(config: GjcTeamConfig, worker: GjcTeamWorker): strin
678
851
  `Team state root: ${config.state_root}.`,
679
852
  workspace,
680
853
  `Task: ${config.task}`,
854
+ `Before claiming work, send startup ACK: gjc team api worker-startup-ack --input '{"team_name":"${config.team_name}","worker_id":"${worker.id}","protocol_version":"1"}' --json.`,
681
855
  `Use gjc team api claim-task/transition-task-status with this worker id, record evidence, and do not mutate leader-owned goal state.`,
682
856
  ].join("\n");
683
857
  const env = [
@@ -1409,10 +1583,11 @@ async function integrateGjcWorkerCommits(
1409
1583
  }
1410
1584
 
1411
1585
  async function initializeStateDirs(dir: string, workers: GjcTeamWorker[]): Promise<void> {
1412
- for (const folder of ["tasks", "claims", "mailbox", "dispatch", "approvals", "workers"])
1586
+ for (const folder of ["tasks", "claims", "mailbox", "notifications", "dispatch", "approvals", "workers"])
1413
1587
  await fs.mkdir(path.join(dir, folder), { recursive: true });
1414
1588
  for (const worker of workers) {
1415
1589
  await fs.mkdir(workerDir(dir, worker.id), { recursive: true });
1590
+ await fs.mkdir(mailboxDirPath(dir, worker.id), { recursive: true });
1416
1591
  await writeJsonFile(mailboxPath(dir, worker.id), { messages: [] });
1417
1592
  await writeJsonFile(path.join(workerDir(dir, worker.id), "status.json"), { state: "idle", updated_at: now() });
1418
1593
  await writeJsonFile(path.join(workerDir(dir, worker.id), "heartbeat.json"), {
@@ -1422,6 +1597,7 @@ async function initializeStateDirs(dir: string, workers: GjcTeamWorker[]): Promi
1422
1597
  alive: true,
1423
1598
  });
1424
1599
  }
1600
+ await fs.mkdir(mailboxDirPath(dir, "leader-fixed"), { recursive: true });
1425
1601
  await writeJsonFile(mailboxPath(dir, "leader-fixed"), { messages: [] });
1426
1602
  }
1427
1603
 
@@ -1466,6 +1642,7 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
1466
1642
  tmux_session_name: tmuxContext.sessionName,
1467
1643
  tmux_target: tmuxContext.target,
1468
1644
  workspace_mode: worktreeMode.enabled ? "worktree" : "direct",
1645
+ dry_run: options.dryRun ?? false,
1469
1646
  leader: { session_id: env.GJC_SESSION_ID ?? env.CODEX_SESSION_ID ?? "", pane_id: tmuxContext.leaderPaneId, cwd },
1470
1647
  leader_cwd: cwd,
1471
1648
  team_state_root: stateRoot,
@@ -1489,6 +1666,7 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
1489
1666
  leader: config.leader,
1490
1667
  workers: config.workers,
1491
1668
  workspace_mode: config.workspace_mode,
1669
+ dry_run: config.dry_run,
1492
1670
  created_at: createdAt,
1493
1671
  updated_at: createdAt,
1494
1672
  });
@@ -1496,17 +1674,25 @@ export async function startGjcTeam(options: GjcTeamStartOptions): Promise<GjcTea
1496
1674
  for (const task of buildInitialTasks(options.task, config.workers)) await writeTask(dir, task);
1497
1675
  await appendEvent(dir, {
1498
1676
  type: "team_started",
1499
- message: "Started native gjc team runtime",
1500
- data: { worker_count: options.workerCount, agent_type: options.agentType, workspace_mode: config.workspace_mode },
1677
+ message: options.dryRun
1678
+ ? "Created native gjc team dry-run state without starting tmux workers"
1679
+ : "Started native gjc team runtime",
1680
+ data: {
1681
+ worker_count: options.workerCount,
1682
+ agent_type: options.agentType,
1683
+ workspace_mode: config.workspace_mode,
1684
+ dry_run: config.dry_run,
1685
+ },
1501
1686
  });
1502
1687
  await appendTelemetry(dir, {
1503
1688
  type: "team_runtime",
1504
- message: "Native gjc team runtime initialized",
1689
+ message: options.dryRun ? "Native gjc team dry-run state initialized" : "Native gjc team runtime initialized",
1505
1690
  data: {
1506
1691
  state_root: stateRoot,
1507
1692
  worker_command: config.worker_command,
1508
1693
  worker_cli_plan: workerCliPlan,
1509
1694
  workspace_mode: config.workspace_mode,
1695
+ dry_run: config.dry_run,
1510
1696
  },
1511
1697
  });
1512
1698
  let tmuxWorkers: GjcTeamWorker[];
@@ -1539,7 +1725,7 @@ export async function readGjcTeamSnapshot(
1539
1725
  ): Promise<GjcTeamSnapshot> {
1540
1726
  const dir = await findTeamDir(teamName, cwd, env);
1541
1727
  const config = await readConfig(dir);
1542
- const phase = await readPhase(dir);
1728
+ const storedPhase = await readPhase(dir);
1543
1729
  const tasks = await readTasks(dir);
1544
1730
  const taskCounts: Record<GjcTeamTaskStatus, number> = {
1545
1731
  pending: 0,
@@ -1550,6 +1736,8 @@ export async function readGjcTeamSnapshot(
1550
1736
  };
1551
1737
  for (const task of tasks) taskCounts[task.status] += 1;
1552
1738
  const monitor = await readJsonFile<GjcTeamMonitorSnapshot>(monitorSnapshotPath(dir));
1739
+ const notificationSummary = await reconcileTeamNotifications(dir, config);
1740
+ const phase = await resolveGjcTeamSnapshotPhase(dir, config, storedPhase, tasks, monitor);
1553
1741
  return {
1554
1742
  team_name: config.team_name,
1555
1743
  display_name: config.display_name,
@@ -1562,6 +1750,7 @@ export async function readGjcTeamSnapshot(
1562
1750
  task_counts: taskCounts,
1563
1751
  workers: config.workers,
1564
1752
  integration_by_worker: monitor?.integration_by_worker,
1753
+ notification_summary: notificationSummary,
1565
1754
  updated_at: config.updated_at,
1566
1755
  };
1567
1756
  }
@@ -1677,6 +1866,8 @@ export async function monitorGjcTeam(
1677
1866
  const previous = await readJsonFile<GjcTeamMonitorSnapshot>(monitorSnapshotPath(dir));
1678
1867
  const integrationByWorker = await integrateGjcWorkerCommits(config, dir, previous, cwd, env);
1679
1868
  await writeJsonFile(monitorSnapshotPath(dir), { integration_by_worker: integrationByWorker, updated_at: now() });
1869
+ await replayGjcTeamNotifications(teamName, cwd, env);
1870
+ await computeLifecycleNudges(config, dir, cwd, env);
1680
1871
  return readGjcTeamSnapshot(teamName, cwd, env);
1681
1872
  }
1682
1873
  export async function listGjcTeams(
@@ -1697,6 +1888,136 @@ export async function listGjcTeams(
1697
1888
  throw error;
1698
1889
  }
1699
1890
  }
1891
+
1892
+ function parsePaneAttemptResult(value: string): GjcTeamPaneAttemptResult {
1893
+ if (value === "sent" || value === "queued" || value === "deferred" || value === "failed") return value;
1894
+ throw new Error(`invalid_pane_attempt_result:${value}`);
1895
+ }
1896
+ async function writeGjcWorkerStartupAck(
1897
+ teamName: string,
1898
+ worker: string,
1899
+ cwd: string,
1900
+ env: NodeJS.ProcessEnv,
1901
+ input: Record<string, unknown>,
1902
+ ): Promise<Record<string, unknown>> {
1903
+ const dir = await findTeamDir(teamName, cwd, env);
1904
+ const config = await readConfig(dir);
1905
+ assertKnownWorker(config, worker);
1906
+ const ack = {
1907
+ worker,
1908
+ pid: typeof input.pid === "number" ? input.pid : undefined,
1909
+ session: typeof input.session === "string" ? input.session : undefined,
1910
+ protocol_version: String(input.protocol_version ?? "1"),
1911
+ ack_at: now(),
1912
+ };
1913
+ await writeJsonFile(path.join(workerDir(dir, worker), "startup-ack.json"), ack);
1914
+ await appendEvent(dir, { type: "worker_startup_ack", worker, message: `Worker ${worker} acknowledged startup` });
1915
+ return ack;
1916
+ }
1917
+ function parseDurationEnv(env: NodeJS.ProcessEnv, name: string, fallbackMs: number): number {
1918
+ const raw = env[name]?.trim();
1919
+ if (!raw) return fallbackMs;
1920
+ const parsed = Number(raw);
1921
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallbackMs;
1922
+ }
1923
+ async function writeLifecycleNudge(
1924
+ dir: string,
1925
+ worker: string,
1926
+ condition: string,
1927
+ severity: "warning" | "error",
1928
+ suggestedAction: string,
1929
+ env: NodeJS.ProcessEnv,
1930
+ ): Promise<void> {
1931
+ const fingerprint = `nudge-${stableHash([worker, condition].join(":"))}`;
1932
+ const nudgePath = path.join(workerDir(dir, worker), "nudges", `${fingerprint}.json`);
1933
+ const existing = await readJsonFile<Record<string, unknown>>(nudgePath);
1934
+ const nowMs = Date.now();
1935
+ const cooldownMs = parseDurationEnv(env, "GJC_TEAM_NUDGE_COOLDOWN_MS", 30_000);
1936
+ const cooldownUntil = typeof existing?.cooldown_until === "string" ? Date.parse(existing.cooldown_until) : 0;
1937
+ if (existing && Number.isFinite(cooldownUntil) && cooldownUntil > nowMs) return;
1938
+ const firstSeen = typeof existing?.first_seen_at === "string" ? existing.first_seen_at : now();
1939
+ const count = typeof existing?.count === "number" ? existing.count + 1 : 1;
1940
+ const record = {
1941
+ fingerprint,
1942
+ worker,
1943
+ condition,
1944
+ severity,
1945
+ first_seen_at: firstSeen,
1946
+ last_seen_at: now(),
1947
+ cooldown_until: new Date(nowMs + cooldownMs).toISOString(),
1948
+ count,
1949
+ suggested_action: suggestedAction,
1950
+ auto_action_taken: false,
1951
+ };
1952
+ await writeJsonFile(nudgePath, record);
1953
+ await appendEvent(dir, {
1954
+ type: "worker_lifecycle_nudge",
1955
+ worker,
1956
+ message: suggestedAction,
1957
+ data: { condition, severity, fingerprint, auto_action_taken: false },
1958
+ });
1959
+ await writeNotificationRecord(dir, {
1960
+ id: `ntf-${stableHash(["worker_lifecycle", worker, condition].join(":"))}`,
1961
+ kind: "worker_lifecycle",
1962
+ team_name: path.basename(dir),
1963
+ recipient: "leader-fixed",
1964
+ source: { type: "worker", id: worker },
1965
+ delivery_state: "pending",
1966
+ created_at: firstSeen,
1967
+ updated_at: now(),
1968
+ replay_count: 0,
1969
+ });
1970
+ }
1971
+ async function computeLifecycleNudges(
1972
+ config: GjcTeamConfig,
1973
+ dir: string,
1974
+ _cwd: string,
1975
+ env: NodeJS.ProcessEnv,
1976
+ ): Promise<void> {
1977
+ const startupGraceMs = parseDurationEnv(env, "GJC_TEAM_STARTUP_GRACE_MS", 30_000);
1978
+ const heartbeatStaleMs = parseDurationEnv(env, "GJC_TEAM_HEARTBEAT_STALE_MS", 120_000);
1979
+ const createdAt = Date.parse(config.created_at);
1980
+ const ageMs = Date.now() - (Number.isFinite(createdAt) ? createdAt : Date.now());
1981
+ for (const worker of config.workers) {
1982
+ const ack = await readJsonFile<Record<string, unknown>>(path.join(workerDir(dir, worker.id), "startup-ack.json"));
1983
+ if (!ack && ageMs >= startupGraceMs) {
1984
+ await writeLifecycleNudge(
1985
+ dir,
1986
+ worker.id,
1987
+ "missing_startup_ack",
1988
+ "warning",
1989
+ `Worker ${worker.id} has not sent startup ACK; leader may inspect or relaunch manually.`,
1990
+ env,
1991
+ );
1992
+ }
1993
+ const heartbeat = await readGjcWorkerHeartbeat(config.team_name, worker.id, config.leader.cwd, {
1994
+ ...env,
1995
+ GJC_TEAM_STATE_ROOT: config.state_root,
1996
+ });
1997
+ const heartbeatAt = Date.parse(heartbeat?.last_turn_at ?? worker.last_heartbeat);
1998
+ if (Number.isFinite(heartbeatAt) && Date.now() - heartbeatAt >= heartbeatStaleMs) {
1999
+ await writeLifecycleNudge(
2000
+ dir,
2001
+ worker.id,
2002
+ "stale_heartbeat",
2003
+ "warning",
2004
+ `Worker ${worker.id} heartbeat is stale; leader may inspect or relaunch manually.`,
2005
+ env,
2006
+ );
2007
+ }
2008
+ if (worker.status === "stopped") {
2009
+ await writeLifecycleNudge(
2010
+ dir,
2011
+ worker.id,
2012
+ "worker_stopped",
2013
+ "error",
2014
+ `Worker ${worker.id} is stopped before team completion; leader action is required.`,
2015
+ env,
2016
+ );
2017
+ }
2018
+ }
2019
+ }
2020
+
1700
2021
  export async function shutdownGjcTeam(
1701
2022
  teamName: string,
1702
2023
  cwd = process.cwd(),
@@ -1809,6 +2130,8 @@ export async function claimGjcTeamTask(
1809
2130
  taskId?: string,
1810
2131
  ): Promise<GjcTeamApiClaimResult> {
1811
2132
  const dir = await findTeamDir(teamName, cwd, env);
2133
+ const config = await readConfig(dir);
2134
+ assertKnownWorker(config, workerId);
1812
2135
  const tasks = await readTasks(dir);
1813
2136
  const task = taskId
1814
2137
  ? tasks.find(candidate => candidate.id === taskId)
@@ -1868,15 +2191,22 @@ export async function transitionGjcTeamTaskStatus(
1868
2191
  cwd = process.cwd(),
1869
2192
  env: NodeJS.ProcessEnv = process.env,
1870
2193
  claimToken?: string,
2194
+ workerId?: string,
2195
+ evidence?: string,
1871
2196
  ): Promise<GjcTeamTask> {
1872
2197
  const dir = await findTeamDir(teamName, cwd, env);
2198
+ const config = await readConfig(dir);
1873
2199
  const task = await readGjcTeamTask(teamName, taskId, cwd, env);
2200
+ if (workerId) assertKnownWorker(config, workerId);
1874
2201
  if (status === "pending") throw new Error(`invalid_task_transition:${taskId}:pending_requires_release`);
1875
2202
  if (task.status === "completed" || task.status === "failed") throw new Error(`task_terminal:${taskId}`);
1876
2203
  if (!task.claim) throw new Error(`claim_token_required:${taskId}`);
1877
2204
  if (!claimToken) throw new Error(`claim_token_required:${taskId}`);
1878
2205
  if (task.claim.token !== claimToken) throw new Error(`claim_token_mismatch:${taskId}`);
2206
+ if (workerId && task.claim.owner !== workerId) throw new Error(`claim_owner_mismatch:${taskId}`);
1879
2207
  const terminal = status === "completed" || status === "failed";
2208
+ if (status === "completed" && evidence !== undefined && evidence.trim().length === 0)
2209
+ throw new Error(`task_evidence_required:${taskId}`);
1880
2210
  const updated: GjcTeamTask = {
1881
2211
  ...task,
1882
2212
  status,
@@ -1886,6 +2216,13 @@ export async function transitionGjcTeamTaskStatus(
1886
2216
  ...(terminal ? { completed_at: now() } : {}),
1887
2217
  };
1888
2218
  await writeTask(dir, updated);
2219
+ if (terminal && evidence)
2220
+ await writeJsonFile(taskEvidencePath(dir, taskId), {
2221
+ task_id: taskId,
2222
+ worker: workerId ?? task.claim.owner,
2223
+ evidence,
2224
+ recorded_at: now(),
2225
+ });
1889
2226
  if (terminal) await fs.rm(path.join(dir, "claims", `${taskId}.json`), { force: true });
1890
2227
  await appendEvent(dir, {
1891
2228
  type: "task_transitioned",
@@ -1936,15 +2273,227 @@ export async function releaseGjcTeamTaskClaim(
1936
2273
  return updated;
1937
2274
  }
1938
2275
 
1939
- async function readMailbox(dir: string, worker: string): Promise<{ messages: GjcTeamMailboxMessage[] }> {
2276
+ function emptyNotificationSummary(): GjcTeamNotificationSummary {
2277
+ return {
2278
+ total: 0,
2279
+ replay_eligible: 0,
2280
+ by_state: {
2281
+ pending: 0,
2282
+ sent: 0,
2283
+ queued: 0,
2284
+ deferred: 0,
2285
+ failed: 0,
2286
+ delivered: 0,
2287
+ acknowledged: 0,
2288
+ },
2289
+ };
2290
+ }
2291
+ function isReplayEligibleNotification(state: GjcTeamNotificationDeliveryState): boolean {
2292
+ return state === "pending" || state === "queued" || state === "deferred" || state === "failed";
2293
+ }
2294
+ function summarizeNotifications(notifications: GjcTeamNotification[]): GjcTeamNotificationSummary {
2295
+ const summary = emptyNotificationSummary();
2296
+ for (const notification of notifications) {
2297
+ summary.total += 1;
2298
+ summary.by_state[notification.delivery_state] += 1;
2299
+ if (isReplayEligibleNotification(notification.delivery_state)) summary.replay_eligible += 1;
2300
+ }
2301
+ return summary;
2302
+ }
2303
+ async function listNotificationRecords(dir: string): Promise<GjcTeamNotification[]> {
2304
+ const notificationsDir = path.join(dir, "notifications");
2305
+ try {
2306
+ const entries = await fs.readdir(notificationsDir, { withFileTypes: true });
2307
+ const records = await Promise.all(
2308
+ entries
2309
+ .filter(entry => entry.isFile() && entry.name.endsWith(".json"))
2310
+ .map(entry => readJsonFile<GjcTeamNotification>(path.join(notificationsDir, entry.name))),
2311
+ );
2312
+ return records
2313
+ .filter((record): record is GjcTeamNotification => record != null)
2314
+ .sort((a, b) => a.id.localeCompare(b.id));
2315
+ } catch (error) {
2316
+ if (isEnoent(error)) return [];
2317
+ throw error;
2318
+ }
2319
+ }
2320
+ async function readNotificationRecord(dir: string, notificationId: string): Promise<GjcTeamNotification> {
2321
+ assertSafeId("notification_id", notificationId);
2322
+ const notification = await readJsonFile<GjcTeamNotification>(notificationPath(dir, notificationId));
2323
+ if (!notification) throw new Error(`notification_not_found:${notificationId}`);
2324
+ return notification;
2325
+ }
2326
+ function mergeNotificationState(
2327
+ current: GjcTeamNotificationDeliveryState,
2328
+ next: GjcTeamNotificationDeliveryState,
2329
+ ): GjcTeamNotificationDeliveryState {
2330
+ const rank: Record<GjcTeamNotificationDeliveryState, number> = {
2331
+ pending: 0,
2332
+ queued: 1,
2333
+ deferred: 1,
2334
+ failed: 1,
2335
+ sent: 2,
2336
+ delivered: 3,
2337
+ acknowledged: 4,
2338
+ };
2339
+ return rank[next] >= rank[current] ? next : current;
2340
+ }
2341
+ async function writeNotificationRecord(dir: string, notification: GjcTeamNotification): Promise<GjcTeamNotification> {
2342
+ const existing = await readJsonFile<GjcTeamNotification>(notificationPath(dir, notification.id));
2343
+ const merged: GjcTeamNotification = existing
2344
+ ? {
2345
+ ...existing,
2346
+ ...notification,
2347
+ delivery_state: mergeNotificationState(existing.delivery_state, notification.delivery_state),
2348
+ created_at: existing.created_at,
2349
+ replay_count: Math.max(existing.replay_count ?? 0, notification.replay_count ?? 0),
2350
+ updated_at: now(),
2351
+ }
2352
+ : notification;
2353
+ await writeJsonFile(notificationPath(dir, merged.id), merged);
2354
+ return merged;
2355
+ }
2356
+ async function createMessageNotification(
2357
+ dir: string,
2358
+ teamName: string,
2359
+ message: GjcTeamMailboxMessage,
2360
+ state: GjcTeamNotificationDeliveryState = "pending",
2361
+ ): Promise<GjcTeamNotification> {
2362
+ const id = messageNotificationId(teamName, message.to_worker, message.message_id);
2363
+ return writeNotificationRecord(dir, {
2364
+ id,
2365
+ kind: "mailbox_message",
2366
+ team_name: teamName,
2367
+ recipient: message.to_worker,
2368
+ source: { type: "message", id: message.message_id },
2369
+ idempotency_key: message.idempotency_key,
2370
+ delivery_state: state,
2371
+ created_at: message.created_at,
2372
+ updated_at: now(),
2373
+ replay_count: 0,
2374
+ });
2375
+ }
2376
+ async function readLegacyMailbox(dir: string, worker: string): Promise<{ messages: GjcTeamMailboxMessage[] }> {
1940
2377
  return (await readJsonFile<{ messages: GjcTeamMailboxMessage[] }>(mailboxPath(dir, worker))) ?? { messages: [] };
1941
2378
  }
1942
- async function writeMailbox(
2379
+ async function readMailbox(dir: string, worker: string): Promise<{ messages: GjcTeamMailboxMessage[] }> {
2380
+ assertSafeId("worker_id", worker);
2381
+ const byId = new Map<string, GjcTeamMailboxMessage>();
2382
+ for (const message of (await readLegacyMailbox(dir, worker)).messages ?? []) byId.set(message.message_id, message);
2383
+ try {
2384
+ const entries = await fs.readdir(mailboxDirPath(dir, worker), { withFileTypes: true });
2385
+ const records = await Promise.all(
2386
+ entries
2387
+ .filter(entry => entry.isFile() && entry.name.endsWith(".json"))
2388
+ .map(entry => readJsonFile<GjcTeamMailboxMessage>(path.join(mailboxDirPath(dir, worker), entry.name))),
2389
+ );
2390
+ for (const message of records) if (message) byId.set(message.message_id, message);
2391
+ } catch (error) {
2392
+ if (!isEnoent(error)) throw error;
2393
+ }
2394
+ return { messages: [...byId.values()].sort((a, b) => a.created_at.localeCompare(b.created_at)) };
2395
+ }
2396
+ async function writeLegacyMailboxView(dir: string, worker: string): Promise<void> {
2397
+ const current = await readMailbox(dir, worker);
2398
+ await writeJsonFile(mailboxPath(dir, worker), current);
2399
+ }
2400
+ async function writeMailboxMessage(
1943
2401
  dir: string,
1944
2402
  worker: string,
1945
- mailbox: { messages: GjcTeamMailboxMessage[] },
1946
- ): Promise<void> {
1947
- await writeJsonFile(mailboxPath(dir, worker), mailbox);
2403
+ message: GjcTeamMailboxMessage,
2404
+ ): Promise<GjcTeamMailboxMessage> {
2405
+ assertSafeId("message_id", message.message_id);
2406
+ const filePath = mailboxMessagePath(dir, worker, message.message_id);
2407
+ const existing = await readJsonFile<GjcTeamMailboxMessage>(filePath);
2408
+ if (existing) {
2409
+ if (
2410
+ existing.from_worker !== message.from_worker ||
2411
+ existing.to_worker !== message.to_worker ||
2412
+ existing.body !== message.body
2413
+ ) {
2414
+ throw new Error(`message_id_conflict:${message.message_id}`);
2415
+ }
2416
+ const merged = {
2417
+ ...existing,
2418
+ ...message,
2419
+ notified_at: existing.notified_at ?? message.notified_at,
2420
+ delivered_at: existing.delivered_at ?? message.delivered_at,
2421
+ };
2422
+ await writeJsonFile(filePath, merged);
2423
+ await writeLegacyMailboxView(dir, worker);
2424
+ return merged;
2425
+ }
2426
+ const created = await writeJsonFileNoClobber(filePath, message);
2427
+ if (!created) return writeMailboxMessage(dir, worker, message);
2428
+ await writeLegacyMailboxView(dir, worker);
2429
+ return message;
2430
+ }
2431
+ async function reconcileTeamNotifications(dir: string, config: GjcTeamConfig): Promise<GjcTeamNotificationSummary> {
2432
+ for (const recipient of ["leader-fixed", ...config.workers.map(worker => worker.id)]) {
2433
+ const mailbox = await readMailbox(dir, recipient);
2434
+ for (const message of mailbox.messages) {
2435
+ const state = message.delivered_at ? "acknowledged" : message.notified_at ? "delivered" : "pending";
2436
+ await createMessageNotification(dir, config.team_name, message, state);
2437
+ }
2438
+ }
2439
+ return summarizeNotifications(await listNotificationRecords(dir));
2440
+ }
2441
+ async function attemptPaneNotification(
2442
+ dir: string,
2443
+ config: GjcTeamConfig,
2444
+ notification: GjcTeamNotification,
2445
+ env: NodeJS.ProcessEnv,
2446
+ ): Promise<GjcTeamNotification> {
2447
+ const paneId =
2448
+ notification.recipient === "leader-fixed"
2449
+ ? config.leader.pane_id
2450
+ : config.workers.find(worker => worker.id === notification.recipient)?.pane_id;
2451
+ let result: GjcTeamPaneAttemptResult = "deferred";
2452
+ let reason = "pane_missing";
2453
+ if (paneId) {
2454
+ if (config.tmux_session === "dry-run" || env.GJC_TEAM_FAKE_PANE_ATTEMPT === "sent") {
2455
+ result = "sent";
2456
+ reason = "dry_run_or_fake_tmux";
2457
+ } else {
2458
+ result = "queued";
2459
+ reason = "tmux_delivery_recorded_without_injection";
2460
+ }
2461
+ }
2462
+ return writeNotificationRecord(dir, {
2463
+ ...notification,
2464
+ delivery_state: result,
2465
+ pane_attempt_result: result,
2466
+ pane_attempt_reason: reason,
2467
+ pane_attempt_at: now(),
2468
+ updated_at: now(),
2469
+ });
2470
+ }
2471
+ export async function replayGjcTeamNotifications(
2472
+ teamName: string,
2473
+ cwd = process.cwd(),
2474
+ env: NodeJS.ProcessEnv = process.env,
2475
+ ): Promise<{ notifications: GjcTeamNotification[]; summary: GjcTeamNotificationSummary }> {
2476
+ const dir = await findTeamDir(teamName, cwd, env);
2477
+ const config = await readConfig(dir);
2478
+ await reconcileTeamNotifications(dir, config);
2479
+ const next: GjcTeamNotification[] = [];
2480
+ for (const notification of await listNotificationRecords(dir)) {
2481
+ if (!isReplayEligibleNotification(notification.delivery_state)) {
2482
+ next.push(notification);
2483
+ continue;
2484
+ }
2485
+ const attempted = await attemptPaneNotification(
2486
+ dir,
2487
+ config,
2488
+ {
2489
+ ...notification,
2490
+ replay_count: (notification.replay_count ?? 0) + 1,
2491
+ },
2492
+ env,
2493
+ );
2494
+ next.push(attempted);
2495
+ }
2496
+ return { notifications: next, summary: summarizeNotifications(next) };
1948
2497
  }
1949
2498
  export async function sendGjcTeamMessage(
1950
2499
  teamName: string,
@@ -1953,20 +2502,31 @@ export async function sendGjcTeamMessage(
1953
2502
  body: string,
1954
2503
  cwd = process.cwd(),
1955
2504
  env: NodeJS.ProcessEnv = process.env,
2505
+ idempotencyKey?: string,
1956
2506
  ): Promise<GjcTeamMailboxMessage> {
1957
2507
  const dir = await findTeamDir(teamName, cwd, env);
1958
- const message = {
1959
- message_id: `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`,
2508
+ const config = await readConfig(dir);
2509
+ assertKnownParticipant(config, fromWorker);
2510
+ assertKnownParticipant(config, toWorker);
2511
+ const createdKey = idempotencyKey ?? randomUUID();
2512
+ const message: GjcTeamMailboxMessage = {
2513
+ message_id: messageIdFor({ teamName: config.team_name, fromWorker, toWorker, body, idempotencyKey, createdKey }),
1960
2514
  from_worker: fromWorker,
1961
2515
  to_worker: toWorker,
1962
2516
  body,
1963
2517
  created_at: now(),
2518
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
1964
2519
  };
1965
- const mailbox = await readMailbox(dir, toWorker);
1966
- mailbox.messages.push(message);
1967
- await writeMailbox(dir, toWorker, mailbox);
1968
- await appendEvent(dir, { type: "message_sent", worker: fromWorker, message: body });
1969
- return message;
2520
+ const written = await writeMailboxMessage(dir, toWorker, message);
2521
+ const notification = await createMessageNotification(dir, config.team_name, written);
2522
+ await attemptPaneNotification(dir, config, notification, env);
2523
+ await appendEvent(dir, {
2524
+ type: "message_sent",
2525
+ worker: fromWorker,
2526
+ message: body,
2527
+ data: { to_worker: toWorker, message_id: written.message_id },
2528
+ });
2529
+ return written;
1970
2530
  }
1971
2531
  export async function broadcastGjcTeamMessage(
1972
2532
  teamName: string,
@@ -1974,10 +2534,21 @@ export async function broadcastGjcTeamMessage(
1974
2534
  body: string,
1975
2535
  cwd = process.cwd(),
1976
2536
  env: NodeJS.ProcessEnv = process.env,
2537
+ idempotencyKey?: string,
1977
2538
  ): Promise<GjcTeamMailboxMessage[]> {
1978
2539
  const config = await readConfig(await findTeamDir(teamName, cwd, env));
1979
2540
  return Promise.all(
1980
- config.workers.map(worker => sendGjcTeamMessage(teamName, fromWorker, worker.id, body, cwd, env)),
2541
+ config.workers.map(worker =>
2542
+ sendGjcTeamMessage(
2543
+ teamName,
2544
+ fromWorker,
2545
+ worker.id,
2546
+ body,
2547
+ cwd,
2548
+ env,
2549
+ idempotencyKey ? `${idempotencyKey}:${worker.id}` : undefined,
2550
+ ),
2551
+ ),
1981
2552
  );
1982
2553
  }
1983
2554
  export async function listGjcTeamMailbox(
@@ -1986,7 +2557,10 @@ export async function listGjcTeamMailbox(
1986
2557
  cwd = process.cwd(),
1987
2558
  env: NodeJS.ProcessEnv = process.env,
1988
2559
  ): Promise<GjcTeamMailboxMessage[]> {
1989
- return (await readMailbox(await findTeamDir(teamName, cwd, env), worker)).messages;
2560
+ const dir = await findTeamDir(teamName, cwd, env);
2561
+ const config = await readConfig(dir);
2562
+ assertKnownParticipant(config, worker);
2563
+ return (await readMailbox(dir, worker)).messages;
1990
2564
  }
1991
2565
  export async function markGjcTeamMailboxMessage(
1992
2566
  teamName: string,
@@ -1996,13 +2570,29 @@ export async function markGjcTeamMailboxMessage(
1996
2570
  cwd = process.cwd(),
1997
2571
  env: NodeJS.ProcessEnv = process.env,
1998
2572
  ): Promise<GjcTeamMailboxMessage> {
2573
+ assertSafeId("message_id", messageId);
1999
2574
  const dir = await findTeamDir(teamName, cwd, env);
2575
+ const config = await readConfig(dir);
2576
+ assertKnownParticipant(config, worker);
2000
2577
  const mailbox = await readMailbox(dir, worker);
2001
- const index = mailbox.messages.findIndex(message => message.message_id === messageId);
2002
- if (index < 0) throw new Error(`message_not_found:${messageId}`);
2003
- mailbox.messages[index] = { ...mailbox.messages[index], [field]: now() };
2004
- await writeMailbox(dir, worker, mailbox);
2005
- return mailbox.messages[index];
2578
+ const message = mailbox.messages.find(candidate => candidate.message_id === messageId);
2579
+ if (!message) throw new Error(`message_not_found:${messageId}`);
2580
+ const updated = { ...message, [field]: message[field] ?? now() };
2581
+ const written = await writeMailboxMessage(dir, worker, updated);
2582
+ const notificationId = messageNotificationId(config.team_name, worker, messageId);
2583
+ const existing =
2584
+ (await readJsonFile<GjcTeamNotification>(notificationPath(dir, notificationId))) ??
2585
+ (await createMessageNotification(dir, config.team_name, written));
2586
+ const nextState: GjcTeamNotificationDeliveryState = field === "delivered_at" ? "acknowledged" : "delivered";
2587
+ const before = existing.delivery_state;
2588
+ await writeNotificationRecord(dir, { ...existing, delivery_state: nextState, updated_at: now() });
2589
+ if (mergeNotificationState(before, nextState) !== before)
2590
+ await appendEvent(dir, {
2591
+ type: `message_${field === "delivered_at" ? "acknowledged" : "notified"}`,
2592
+ worker,
2593
+ message: messageId,
2594
+ });
2595
+ return written;
2006
2596
  }
2007
2597
  export async function readGjcWorkerStatus(
2008
2598
  teamName: string,
@@ -2010,10 +2600,14 @@ export async function readGjcWorkerStatus(
2010
2600
  cwd = process.cwd(),
2011
2601
  env: NodeJS.ProcessEnv = process.env,
2012
2602
  ): Promise<WorkerStatusFile> {
2603
+ const dir = await findTeamDir(teamName, cwd, env);
2604
+ const config = await readConfig(dir);
2605
+ assertKnownWorker(config, worker);
2013
2606
  return (
2014
- (await readJsonFile<WorkerStatusFile>(
2015
- path.join(workerDir(await findTeamDir(teamName, cwd, env), worker), "status.json"),
2016
- )) ?? { state: "unknown", updated_at: now() }
2607
+ (await readJsonFile<WorkerStatusFile>(path.join(workerDir(dir, worker), "status.json"))) ?? {
2608
+ state: "unknown",
2609
+ updated_at: now(),
2610
+ }
2017
2611
  );
2018
2612
  }
2019
2613
  export async function readGjcWorkerHeartbeat(
@@ -2022,9 +2616,10 @@ export async function readGjcWorkerHeartbeat(
2022
2616
  cwd = process.cwd(),
2023
2617
  env: NodeJS.ProcessEnv = process.env,
2024
2618
  ): Promise<WorkerHeartbeatFile | null> {
2025
- return readJsonFile<WorkerHeartbeatFile>(
2026
- path.join(workerDir(await findTeamDir(teamName, cwd, env), worker), "heartbeat.json"),
2027
- );
2619
+ const dir = await findTeamDir(teamName, cwd, env);
2620
+ const config = await readConfig(dir);
2621
+ assertKnownWorker(config, worker);
2622
+ return readJsonFile<WorkerHeartbeatFile>(path.join(workerDir(dir, worker), "heartbeat.json"));
2028
2623
  }
2029
2624
  export async function updateGjcWorkerHeartbeat(
2030
2625
  teamName: string,
@@ -2033,8 +2628,11 @@ export async function updateGjcWorkerHeartbeat(
2033
2628
  cwd = process.cwd(),
2034
2629
  env: NodeJS.ProcessEnv = process.env,
2035
2630
  ): Promise<WorkerHeartbeatFile> {
2631
+ const dir = await findTeamDir(teamName, cwd, env);
2632
+ const config = await readConfig(dir);
2633
+ assertKnownWorker(config, worker);
2036
2634
  const value = { ...heartbeat, last_turn_at: heartbeat.last_turn_at || now() };
2037
- await writeJsonFile(path.join(workerDir(await findTeamDir(teamName, cwd, env), worker), "heartbeat.json"), value);
2635
+ await writeJsonFile(path.join(workerDir(dir, worker), "heartbeat.json"), value);
2038
2636
  return value;
2039
2637
  }
2040
2638
  export async function writeGjcWorkerInbox(
@@ -2044,7 +2642,10 @@ export async function writeGjcWorkerInbox(
2044
2642
  cwd = process.cwd(),
2045
2643
  env: NodeJS.ProcessEnv = process.env,
2046
2644
  ): Promise<{ path: string }> {
2047
- const filePath = path.join(workerDir(await findTeamDir(teamName, cwd, env), worker), "inbox.md");
2645
+ const dir = await findTeamDir(teamName, cwd, env);
2646
+ const config = await readConfig(dir);
2647
+ assertKnownWorker(config, worker);
2648
+ const filePath = path.join(workerDir(dir, worker), "inbox.md");
2048
2649
  await Bun.write(filePath, content);
2049
2650
  return { path: filePath };
2050
2651
  }
@@ -2054,7 +2655,10 @@ export async function writeGjcWorkerIdentity(
2054
2655
  cwd = process.cwd(),
2055
2656
  env: NodeJS.ProcessEnv = process.env,
2056
2657
  ): Promise<GjcTeamWorker> {
2057
- await writeJsonFile(path.join(workerDir(await findTeamDir(teamName, cwd, env), worker.id), "identity.json"), worker);
2658
+ const dir = await findTeamDir(teamName, cwd, env);
2659
+ const config = await readConfig(dir);
2660
+ assertKnownWorker(config, worker.id);
2661
+ await writeJsonFile(path.join(workerDir(dir, worker.id), "identity.json"), worker);
2058
2662
  return worker;
2059
2663
  }
2060
2664
  export async function readGjcTeamEvents(
@@ -2116,6 +2720,7 @@ export async function writeGjcTaskApproval(
2116
2720
  cwd = process.cwd(),
2117
2721
  env: NodeJS.ProcessEnv = process.env,
2118
2722
  ): Promise<Record<string, unknown>> {
2723
+ assertSafeId("task_id", taskId);
2119
2724
  await writeJsonFile(path.join(await findTeamDir(teamName, cwd, env), "approvals", `${taskId}.json`), approval);
2120
2725
  return approval;
2121
2726
  }
@@ -2125,6 +2730,7 @@ export async function readGjcTaskApproval(
2125
2730
  cwd = process.cwd(),
2126
2731
  env: NodeJS.ProcessEnv = process.env,
2127
2732
  ): Promise<Record<string, unknown> | null> {
2733
+ assertSafeId("task_id", taskId);
2128
2734
  return readJsonFile<Record<string, unknown>>(
2129
2735
  path.join(await findTeamDir(teamName, cwd, env), "approvals", `${taskId}.json`),
2130
2736
  );
@@ -2136,11 +2742,12 @@ export async function writeGjcShutdownRequest(
2136
2742
  cwd = process.cwd(),
2137
2743
  env: NodeJS.ProcessEnv = process.env,
2138
2744
  ): Promise<Record<string, unknown>> {
2745
+ const dir = await findTeamDir(teamName, cwd, env);
2746
+ const config = await readConfig(dir);
2747
+ assertKnownWorker(config, worker);
2748
+ assertKnownParticipant(config, requestedBy);
2139
2749
  const value = { worker, requested_by: requestedBy, requested_at: now() };
2140
- await writeJsonFile(
2141
- path.join(workerDir(await findTeamDir(teamName, cwd, env), worker), "shutdown-request.json"),
2142
- value,
2143
- );
2750
+ await writeJsonFile(path.join(workerDir(dir, worker), "shutdown-request.json"), value);
2144
2751
  return value;
2145
2752
  }
2146
2753
  export async function readGjcShutdownAck(
@@ -2149,9 +2756,10 @@ export async function readGjcShutdownAck(
2149
2756
  cwd = process.cwd(),
2150
2757
  env: NodeJS.ProcessEnv = process.env,
2151
2758
  ): Promise<Record<string, unknown> | null> {
2152
- return readJsonFile<Record<string, unknown>>(
2153
- path.join(workerDir(await findTeamDir(teamName, cwd, env), worker), "shutdown-ack.json"),
2154
- );
2759
+ const dir = await findTeamDir(teamName, cwd, env);
2760
+ const config = await readConfig(dir);
2761
+ assertKnownWorker(config, worker);
2762
+ return readJsonFile<Record<string, unknown>>(path.join(workerDir(dir, worker), "shutdown-ack.json"));
2155
2763
  }
2156
2764
 
2157
2765
  export async function executeGjcTeamApiOperation(
@@ -2162,7 +2770,9 @@ export async function executeGjcTeamApiOperation(
2162
2770
  ): Promise<unknown> {
2163
2771
  const teamName = String(input.team_name ?? input.teamName ?? "").trim();
2164
2772
  if (!teamName) throw new Error("missing_team_name");
2165
- const worker = String(input.worker ?? input.worker_id ?? input.workerId ?? "worker-1");
2773
+ const workerInput = input.worker ?? input.worker_id ?? input.workerId;
2774
+ const worker = String(workerInput ?? "worker-1");
2775
+ const explicitWorker = workerInput == null ? undefined : String(workerInput);
2166
2776
  switch (operation) {
2167
2777
  case "list-tasks":
2168
2778
  return { tasks: await listGjcTeamTasks(teamName, cwd, env) };
@@ -2210,6 +2820,12 @@ export async function executeGjcTeamApiOperation(
2210
2820
  cwd,
2211
2821
  env,
2212
2822
  typeof input.claim_token === "string" ? input.claim_token : undefined,
2823
+ explicitWorker,
2824
+ typeof input.evidence === "string"
2825
+ ? input.evidence
2826
+ : typeof input.result === "string"
2827
+ ? input.result
2828
+ : undefined,
2213
2829
  ),
2214
2830
  };
2215
2831
  case "release-task-claim":
@@ -2233,11 +2849,19 @@ export async function executeGjcTeamApiOperation(
2233
2849
  String(input.body),
2234
2850
  cwd,
2235
2851
  env,
2852
+ typeof input.idempotency_key === "string" ? input.idempotency_key : undefined,
2236
2853
  ),
2237
2854
  };
2238
2855
  case "broadcast":
2239
2856
  return {
2240
- messages: await broadcastGjcTeamMessage(teamName, String(input.from_worker), String(input.body), cwd, env),
2857
+ messages: await broadcastGjcTeamMessage(
2858
+ teamName,
2859
+ String(input.from_worker),
2860
+ String(input.body),
2861
+ cwd,
2862
+ env,
2863
+ typeof input.idempotency_key === "string" ? input.idempotency_key : undefined,
2864
+ ),
2241
2865
  };
2242
2866
  case "mailbox-list":
2243
2867
  return { messages: await listGjcTeamMailbox(teamName, worker, cwd, env) };
@@ -2263,6 +2887,38 @@ export async function executeGjcTeamApiOperation(
2263
2887
  env,
2264
2888
  ),
2265
2889
  };
2890
+ case "notification-list": {
2891
+ const dir = await findTeamDir(teamName, cwd, env);
2892
+ const config = await readConfig(dir);
2893
+ await reconcileTeamNotifications(dir, config);
2894
+ const notifications = await listNotificationRecords(dir);
2895
+ return { notifications, summary: summarizeNotifications(notifications) };
2896
+ }
2897
+ case "notification-read":
2898
+ return {
2899
+ notification: await readNotificationRecord(
2900
+ await findTeamDir(teamName, cwd, env),
2901
+ String(input.notification_id),
2902
+ ),
2903
+ };
2904
+ case "notification-replay":
2905
+ return replayGjcTeamNotifications(teamName, cwd, env);
2906
+ case "notification-mark-pane-attempt": {
2907
+ const dir = await findTeamDir(teamName, cwd, env);
2908
+ const notification = await readNotificationRecord(dir, String(input.notification_id));
2909
+ return {
2910
+ notification: await writeNotificationRecord(dir, {
2911
+ ...notification,
2912
+ delivery_state: parsePaneAttemptResult(String(input.result ?? "failed")),
2913
+ pane_attempt_result: parsePaneAttemptResult(String(input.result ?? "failed")),
2914
+ pane_attempt_reason: String(input.reason ?? "manual_api"),
2915
+ pane_attempt_at: now(),
2916
+ updated_at: now(),
2917
+ }),
2918
+ };
2919
+ }
2920
+ case "worker-startup-ack":
2921
+ return writeGjcWorkerStartupAck(teamName, worker, cwd, env, input);
2266
2922
  case "read-config":
2267
2923
  return await readConfig(await findTeamDir(teamName, cwd, env));
2268
2924
  case "read-manifest":