@agilemonster/mcp-server 1.0.16 → 1.0.22

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/server.js CHANGED
@@ -3,10 +3,17 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
4
4
  import path from 'node:path';
5
5
  import fs from 'node:fs';
6
- import os from 'node:os';
7
- import { spawn, execSync } from 'node:child_process';
6
+ import crypto from 'node:crypto';
7
+ import { spawn, execSync, execFileSync } from 'node:child_process';
8
+ import { isUtf8 } from 'node:buffer';
9
+ import { fileURLToPath } from 'node:url';
8
10
  import { AgileMonsterClient, MCP_SERVER_VERSION } from './client.js';
9
11
  import { AGENT_GUIDELINES_MARKDOWN } from './guidelines.js';
12
+ import { knownSecrets, redactResult } from './redact.js';
13
+ import { describeProfile, methodLabel, normalizeMethod, resolveDeployProfile, } from './deploy-profile.js';
14
+ import { addPendingVerification, getCardState, loadMemory, recordAttempt, removePendingVerification, setCardState, setExplicitProfile, } from './deploy-memory.js';
15
+ import { checkCiOnce, describeFailureForHuman, ensureLocalGitExcludes, parseDeployDirective, runDeployment, } from './deploy-runner.js';
16
+ import { addWellKnownToolDirs, agileMonsterHome, configureLogger, ensureUserPath, errorMessage, exec, log, readJson, which, writeJsonAtomic, } from './runtime.js';
10
17
  export function createServer(client = new AgileMonsterClient()) {
11
18
  const server = new Server({
12
19
  name: 'agilemonster-mcp-server',
@@ -110,7 +117,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
110
117
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
111
118
  return { resources };
112
119
  });
113
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
120
+ const readResource = async (request) => {
114
121
  const { uri } = request.params;
115
122
  if (uri === 'agilemonster://guidelines/agents') {
116
123
  return {
@@ -127,7 +134,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
127
134
  const projectConfig = client.loadProjectConfig();
128
135
  const projectName = projectConfig?.board_name || path.basename(process.cwd());
129
136
  const boardId = projectConfig?.board_id;
130
- const context = await client.getContext(projectName, boardId);
137
+ const context = await client.getContext(projectName, boardId, undefined, AgileMonsterClient.detectInstalledAgents());
131
138
  return {
132
139
  contents: [
133
140
  {
@@ -135,7 +142,8 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
135
142
  mimeType: 'application/json',
136
143
  text: JSON.stringify({
137
144
  config_source: projectConfig ? '.agilemonster.json' : 'auto_matched',
138
- project_config: projectConfig,
145
+ project_config: projectConfig ? { ...projectConfig, api_key: projectConfig.api_key ? '***' : undefined } : null,
146
+ deploy_profile: currentDeployProfileSummary(client),
139
147
  ...context,
140
148
  }, null, 2),
141
149
  },
@@ -143,7 +151,9 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
143
151
  };
144
152
  }
145
153
  throw new Error(`Resource not found: ${uri}`);
146
- });
154
+ };
155
+ // Every resource passes through one filter, so a credential can never reach an agent's context
156
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => redactResult(await readResource(request), knownSecrets(client.getApiKey())));
147
157
  // -------------------------------------------------------------------------
148
158
  // 3. Tools for Autonomous Agents
149
159
  // -------------------------------------------------------------------------
@@ -452,11 +462,61 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
452
462
  required: ['card_id'],
453
463
  },
454
464
  },
465
+ {
466
+ name: 'agilemonster_get_deploy_profile',
467
+ description: 'How THIS project is deployed: the route (GitHub Actions, local Docker Compose, deploy script, cloud CLI, manual), the exact command, confidence, the evidence behind it, alternatives found, and recent deployment history on this machine. Call this before deploying or before writing a deployment note, instead of guessing.',
468
+ inputSchema: {
469
+ type: 'object',
470
+ properties: {},
471
+ },
472
+ },
473
+ {
474
+ name: 'agilemonster_record_deployment',
475
+ description: 'Record how a deployment of this project actually went, so every future task (including cards started from the Agile Monster web app while no IDE is open) deploys the same proven way. Call it after you deploy successfully, when a deployment fails, or when the user tells you how the project is deployed (set pin=true).',
476
+ inputSchema: {
477
+ type: 'object',
478
+ properties: {
479
+ method: {
480
+ type: 'string',
481
+ description: 'One of: github_actions, docker_compose, deploy_script, cloud_cli, git_push, manual, none.',
482
+ },
483
+ command: {
484
+ type: 'string',
485
+ description: 'The exact command that deploys, if any (e.g. "docker compose -f infra/compose.yaml up -d --build", "./deploy/deploy.sh").',
486
+ },
487
+ live_url: {
488
+ type: 'string',
489
+ description: 'URL where the deployed result can be checked (production URL, or http://localhost:PORT for a local stack).',
490
+ },
491
+ success: {
492
+ type: 'boolean',
493
+ description: 'Whether the deployment succeeded (default true).',
494
+ },
495
+ error: {
496
+ type: 'string',
497
+ description: 'What went wrong, when success is false.',
498
+ },
499
+ pin: {
500
+ type: 'boolean',
501
+ description: 'true = the user confirmed this is THE way this project deploys; it then overrides auto-detection until changed.',
502
+ },
503
+ notes: {
504
+ type: 'string',
505
+ description: 'Anything a future agent must know (required env, order of steps, which branch deploys).',
506
+ },
507
+ card_id: {
508
+ type: 'string',
509
+ description: 'The card this deployment belongs to, if any.',
510
+ },
511
+ },
512
+ required: ['method'],
513
+ },
514
+ },
455
515
  ];
456
516
  server.setRequestHandler(ListToolsRequestSchema, async () => {
457
517
  return { tools };
458
518
  });
459
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
519
+ const callTool = async (request) => {
460
520
  const { name, arguments: args = {} } = request.params;
461
521
  const projectConfig = client.loadProjectConfig();
462
522
  try {
@@ -466,14 +526,15 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
466
526
  projectConfig?.board_name ||
467
527
  path.basename(process.cwd());
468
528
  const boardId = args.board_id || projectConfig?.board_id;
469
- const context = await client.getContext(projectName, boardId);
529
+ const context = await client.getContext(projectName, boardId, undefined, AgileMonsterClient.detectInstalledAgents());
470
530
  return {
471
531
  content: [
472
532
  {
473
533
  type: 'text',
474
534
  text: JSON.stringify({
475
535
  config_source: projectConfig ? '.agilemonster.json' : 'auto_matched',
476
- project_config: projectConfig,
536
+ project_config: projectConfig ? { ...projectConfig, api_key: projectConfig.api_key ? '***' : undefined } : null,
537
+ deploy_profile: currentDeployProfileSummary(client),
477
538
  ...context,
478
539
  }, null, 2),
479
540
  },
@@ -486,6 +547,8 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
486
547
  const provider = args.provider || projectConfig?.provider || 'antigravity';
487
548
  const reviewList = args.review_list || projectConfig?.review_list;
488
549
  const savedPath = client.saveProjectConfig({
550
+ // Keep what bootstrap stored (API key, board name, done list ...): this call only re-points the binding
551
+ ...(projectConfig || {}),
489
552
  workspace_id: workspaceId,
490
553
  board_id: boardId,
491
554
  provider,
@@ -564,7 +627,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
564
627
  content: [
565
628
  {
566
629
  type: 'text',
567
- text: `Task claimed successfully on board "${card.board_name || expectedBoardId}". Status is now "in_progress".\n${JSON.stringify(card, null, 2)}`,
630
+ text: `Task claimed successfully on board "${card.board_name || expectedBoardId}". Status is now "in_progress".\n\n${deployGuidanceText(client)}\n\n${JSON.stringify(card, null, 2)}`,
568
631
  },
569
632
  ],
570
633
  };
@@ -688,6 +751,102 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
688
751
  ],
689
752
  };
690
753
  }
754
+ case 'agilemonster_get_deploy_profile': {
755
+ const root = projectRootFor(client);
756
+ const memory = loadMemory(root);
757
+ const profile = resolveDeployProfile(root, { memory });
758
+ const history = memory.history.slice(-5).map((h) => ({
759
+ at: h.at,
760
+ method: h.method,
761
+ ok: h.ok,
762
+ pending: h.pending || undefined,
763
+ commit: h.commit || undefined,
764
+ error: h.error || undefined,
765
+ }));
766
+ return {
767
+ content: [
768
+ {
769
+ type: 'text',
770
+ text: `${describeProfile(profile)}\n\n` +
771
+ JSON.stringify({
772
+ project_path: root,
773
+ method: profile.method,
774
+ command: profile.command,
775
+ confidence: profile.confidence,
776
+ source: profile.source,
777
+ live_url: profile.liveUrl,
778
+ live_url_source: profile.liveUrlSource,
779
+ build_command: profile.buildCommand,
780
+ alternatives: profile.alternatives.map((a) => ({ method: a.method, command: a.command, score: a.score, evidence: a.evidence })),
781
+ recent_deployments: history,
782
+ notes_from_project_docs: profile.deploymentNotes || undefined,
783
+ pinned_notes: memory.explicit?.notes || undefined,
784
+ }, null, 2) +
785
+ `\n\nAfter you deploy, call agilemonster_record_deployment so the next task (including cards run from the web app) reuses what worked.`,
786
+ },
787
+ ],
788
+ };
789
+ }
790
+ case 'agilemonster_record_deployment': {
791
+ const root = projectRootFor(client);
792
+ const method = normalizeMethod(args.method);
793
+ if (!method) {
794
+ return {
795
+ isError: true,
796
+ content: [
797
+ {
798
+ type: 'text',
799
+ text: `Unknown deployment method "${String(args.method)}". Use one of: github_actions, docker_compose, deploy_script, cloud_cli, git_push, manual, none.`,
800
+ },
801
+ ],
802
+ };
803
+ }
804
+ const command = typeof args.command === 'string' && args.command.trim() ? args.command.trim() : null;
805
+ const liveUrl = typeof args.live_url === 'string' && /^https?:\/\//i.test(args.live_url.trim()) ? args.live_url.trim() : null;
806
+ const success = args.success === undefined ? true : Boolean(args.success);
807
+ const pin = Boolean(args.pin);
808
+ const notes = typeof args.notes === 'string' ? args.notes.trim().slice(0, 600) : null;
809
+ const errorText = typeof args.error === 'string' ? args.error.trim().slice(0, 600) : null;
810
+ const cardId = typeof args.card_id === 'string' ? args.card_id : null;
811
+ const provider = projectConfig?.provider || 'agent';
812
+ if (pin) {
813
+ setExplicitProfile(root, { method, command, live_url: liveUrl, notes, set_by: `${provider} (IDE session)` });
814
+ }
815
+ recordAttempt(root, {
816
+ card_id: cardId,
817
+ method,
818
+ command,
819
+ ok: success,
820
+ kind: success ? null : 'reported_failure',
821
+ error: errorText,
822
+ live_url: liveUrl,
823
+ reported_by: provider,
824
+ });
825
+ const synced = await client.reportDeployment({
826
+ card_id: cardId,
827
+ board_id: projectConfig?.board_id || null,
828
+ project_path: root,
829
+ method,
830
+ command,
831
+ live_url: liveUrl,
832
+ ok: success,
833
+ error: errorText,
834
+ error_kind: success ? null : 'reported_failure',
835
+ source: pin ? 'user' : 'verified',
836
+ });
837
+ const profile = resolveDeployProfile(root, { memory: loadMemory(root) });
838
+ return {
839
+ content: [
840
+ {
841
+ type: 'text',
842
+ text: `Recorded ${success ? 'successful' : 'failed'} deployment via ${methodLabel(method)}${command ? ` (\`${command}\`)` : ''}` +
843
+ `${pin ? ' and pinned it as this project\'s deployment route' : ''}.` +
844
+ `${synced ? ' Synced to the Agile Monster board binding.' : ' Saved on this machine (the server did not accept the report; it will still be used locally).'}` +
845
+ `\n\nThe project now resolves to:\n${describeProfile(profile)}`,
846
+ },
847
+ ],
848
+ };
849
+ }
691
850
  default:
692
851
  throw new Error(`Unknown tool: ${name}`);
693
852
  }
@@ -703,10 +862,55 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
703
862
  ],
704
863
  };
705
864
  }
706
- });
865
+ };
866
+ // Every tool result (including errors) passes through the same filter as resources
867
+ server.setRequestHandler(CallToolRequestSchema, async (request) => redactResult(await callTool(request), knownSecrets(client.getApiKey())));
707
868
  return server;
708
869
  }
870
+ /** The bound project folder for this MCP session (where .agilemonster.json lives), else the working directory. */
871
+ function projectRootFor(client) {
872
+ const config = client.loadProjectConfig();
873
+ if (config?.project_path && fs.existsSync(config.project_path))
874
+ return config.project_path;
875
+ let dir = process.cwd();
876
+ while (dir !== path.parse(dir).root) {
877
+ if (fs.existsSync(path.join(dir, '.agilemonster.json')))
878
+ return dir;
879
+ dir = path.dirname(dir);
880
+ }
881
+ return process.cwd();
882
+ }
883
+ function currentDeployProfileSummary(client) {
884
+ try {
885
+ const root = projectRootFor(client);
886
+ const profile = resolveDeployProfile(root, { memory: loadMemory(root) });
887
+ return {
888
+ method: profile.method,
889
+ command: profile.command,
890
+ confidence: profile.confidence,
891
+ source: profile.source,
892
+ live_url: profile.liveUrl,
893
+ evidence: profile.evidence.slice(0, 3),
894
+ };
895
+ }
896
+ catch {
897
+ return null;
898
+ }
899
+ }
900
+ function deployGuidanceText(client) {
901
+ try {
902
+ const root = projectRootFor(client);
903
+ const profile = resolveDeployProfile(root, { memory: loadMemory(root) });
904
+ return (`How this project deploys (from project evidence and past deployments):\n${describeProfile(profile)}\n` +
905
+ `After deploying, call agilemonster_record_deployment so future tasks reuse what worked.`);
906
+ }
907
+ catch {
908
+ return 'Call agilemonster_get_deploy_profile to see how this project deploys.';
909
+ }
910
+ }
709
911
  function sendDesktopNotification(title, message) {
912
+ if (process.env.AGILEMONSTER_NO_NOTIFY === '1')
913
+ return;
710
914
  if (process.platform === 'darwin') {
711
915
  try {
712
916
  const safeTitle = title.replace(/["\\]/g, '');
@@ -761,8 +965,31 @@ function syncIdeTaskRules(boardName, queuedCards) {
761
965
  }
762
966
  catch { }
763
967
  }
968
+ /** Work in flight (card execution or deployment). Restarts for updates wait until this drops to zero. */
969
+ let activeWork = 0;
970
+ function exitWhenIdle(reason) {
971
+ const startedWaiting = Date.now();
972
+ const check = () => {
973
+ // Never kill a half-applied edit or a running deployment; give up waiting after 30 minutes.
974
+ if (activeWork === 0 || Date.now() - startedWaiting > 30 * 60 * 1000) {
975
+ log.info('daemon.exit', { reason, waited_ms: Date.now() - startedWaiting });
976
+ process.exit(0);
977
+ }
978
+ };
979
+ check();
980
+ setInterval(check, 3000);
981
+ }
764
982
  function triggerBackgroundSelfUpdate(targetVersion) {
765
983
  try {
984
+ // Guard against a restart loop: if we already tried to reach this version recently and are still
985
+ // older (e.g. the daemon runs from a source checkout, or npm has not published it yet), do not retry.
986
+ const attemptFile = path.join(agileMonsterHome(), 'update-attempt.json');
987
+ const target = targetVersion || 'latest';
988
+ const previous = readJson(attemptFile);
989
+ if (previous?.target === target && Date.now() - (previous.at || 0) < 6 * 60 * 60 * 1000) {
990
+ return;
991
+ }
992
+ writeJsonAtomic(attemptFile, { target, at: Date.now(), from: MCP_SERVER_VERSION });
766
993
  // 1. Ensure local .cursor/mcp.json and .mcp.json use @latest so next IDE reload uses latest
767
994
  const cwd = process.cwd();
768
995
  for (const rel of ['.cursor/mcp.json', '.mcp.json', '.vscode/mcp.json']) {
@@ -778,7 +1005,7 @@ function triggerBackgroundSelfUpdate(targetVersion) {
778
1005
  catch { }
779
1006
  }
780
1007
  }
781
- // 2. Install @agilemonster/mcp-server@latest into global/npx cache and then exit(0) so launchd respawns the daemon cleanly
1008
+ // 2. Install @agilemonster/mcp-server@latest into global/npx cache, then exit once idle so launchd respawns the daemon
782
1009
  const child = spawn('sh', ['-c', 'npm install -g @agilemonster/mcp-server@latest >/dev/null 2>&1 || npx -y @agilemonster/mcp-server@latest --version >/dev/null 2>&1'], {
783
1010
  stdio: 'ignore',
784
1011
  detached: true,
@@ -790,72 +1017,138 @@ function triggerBackgroundSelfUpdate(targetVersion) {
790
1017
  agentName: 'Agile Monster Daemon',
791
1018
  model: 'system',
792
1019
  status: 'updated',
793
- summary: `Automatically updated @agilemonster/mcp-server from v${MCP_SERVER_VERSION} to v${targetVersion || 'latest'} and restarted daemon via launchd.`,
1020
+ summary: `Updated @agilemonster/mcp-server from v${MCP_SERVER_VERSION} to v${target} and restarting the daemon.`,
794
1021
  });
795
- setTimeout(() => {
796
- process.exit(0);
797
- }, 1500).unref();
1022
+ setTimeout(() => exitWhenIdle('self-update'), 1500).unref();
798
1023
  });
799
1024
  child.unref();
800
1025
  }
801
- catch { }
1026
+ catch (err) {
1027
+ log.warn('daemon.self_update_failed', { error: errorMessage(err) });
1028
+ }
802
1029
  }
803
- function readProjectInstructionsContext(projectPath) {
1030
+ /**
1031
+ * Context handed to the code model. Ordered so the most decisive facts come first (older backends keep
1032
+ * only the first 4,000 characters): how the project ships, then the project's own memory/instructions.
1033
+ */
1034
+ function readProjectInstructionsContext(projectPath, profile) {
804
1035
  const sections = [];
805
- for (const docName of ['CLAUDE.md', 'AGENTS.md', 'README.md']) {
1036
+ const maxTotalChars = 12000;
1037
+ if (profile) {
1038
+ const notes = profile.deploymentNotes ? `\nFrom the project docs:\n${profile.deploymentNotes.slice(0, 700)}` : '';
1039
+ sections.push(`=== How this project builds and deploys ===\n${describeProfile(profile)}${notes}`.slice(0, 1500));
1040
+ }
1041
+ const docCandidates = [
1042
+ ['MEMORY.md', 2200],
1043
+ ['CLAUDE.md', 2200],
1044
+ ['AGENTS.md', 1200],
1045
+ ['.cursorrules', 1000],
1046
+ ['README.md', 2000],
1047
+ ['.github/copilot-instructions.md', 1000],
1048
+ ];
1049
+ for (const [docName, cap] of docCandidates) {
806
1050
  const docPath = path.join(projectPath, docName);
807
1051
  if (fs.existsSync(docPath)) {
808
1052
  try {
809
1053
  const raw = fs.readFileSync(docPath, 'utf8').trim();
810
1054
  if (raw.length > 0) {
811
- sections.push(`=== ${docName} ===\n${raw.slice(0, 1800)}`);
1055
+ sections.push(`=== ${docName} ===\n${raw.slice(0, cap)}`);
1056
+ }
1057
+ }
1058
+ catch { }
1059
+ }
1060
+ }
1061
+ const cursorRulesDir = path.join(projectPath, '.cursor', 'rules');
1062
+ if (fs.existsSync(cursorRulesDir)) {
1063
+ try {
1064
+ const ruleFiles = fs
1065
+ .readdirSync(cursorRulesDir)
1066
+ .filter((f) => (f.endsWith('.md') || f.endsWith('.mdc')) && f !== 'agilemonster-queue.mdc');
1067
+ for (const rf of ruleFiles.slice(0, 5)) {
1068
+ try {
1069
+ const ruleContent = fs.readFileSync(path.join(cursorRulesDir, rf), 'utf8').trim();
1070
+ if (ruleContent) {
1071
+ sections.push(`=== .cursor/rules/${rf} ===\n${ruleContent.slice(0, 1200)}`);
1072
+ }
1073
+ }
1074
+ catch { }
1075
+ }
1076
+ }
1077
+ catch { }
1078
+ }
1079
+ for (const sub of ['apps/web/README.md', 'apps/api/README.md', 'frontend/README.md', 'backend/README.md']) {
1080
+ const subDoc = path.join(projectPath, sub);
1081
+ if (fs.existsSync(subDoc)) {
1082
+ try {
1083
+ const raw = fs.readFileSync(subDoc, 'utf8').trim();
1084
+ if (raw.length > 0) {
1085
+ sections.push(`=== ${sub} ===\n${raw.slice(0, 1200)}`);
812
1086
  }
813
1087
  }
814
1088
  catch { }
815
1089
  }
816
- if (sections.join('\n\n').length >= 3000)
817
- break;
1090
+ }
1091
+ const pkgPath = path.join(projectPath, 'package.json');
1092
+ if (fs.existsSync(pkgPath)) {
1093
+ try {
1094
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1095
+ const scripts = pkg?.scripts ? JSON.stringify(pkg.scripts, null, 2) : '';
1096
+ const workspaces = pkg?.workspaces ? JSON.stringify(pkg.workspaces) : '';
1097
+ if (scripts || workspaces) {
1098
+ sections.push(`=== Project Scripts & Workspaces ===\n${scripts ? `scripts: ${scripts}\n` : ''}${workspaces ? `workspaces: ${workspaces}` : ''}`.slice(0, 1200));
1099
+ }
1100
+ }
1101
+ catch { }
818
1102
  }
819
1103
  try {
820
- const recentCommits = execSync('git log -n 4 --oneline', {
1104
+ const recentCommits = execSync('git log -n 8 --oneline', {
821
1105
  cwd: projectPath,
822
1106
  encoding: 'utf8',
823
1107
  stdio: ['ignore', 'pipe', 'ignore'],
1108
+ timeout: 5000,
824
1109
  }).trim();
825
1110
  if (recentCommits) {
826
1111
  sections.push(`=== Recent Git Commits ===\n${recentCommits}`);
827
1112
  }
828
1113
  }
829
1114
  catch { }
830
- return sections.join('\n\n').slice(0, 3800);
1115
+ return sections.join('\n\n').slice(0, maxTotalChars);
831
1116
  }
832
1117
  const activeAutonomousCards = new Set();
833
- function collectProjectSourceFiles(projectPath, keywords) {
834
- const skipDirs = new Set([
835
- 'node_modules',
836
- '.git',
837
- 'dist',
838
- 'build',
839
- '.next',
840
- '.nuxt',
841
- '.dart_tool',
842
- 'vendor',
843
- 'coverage',
844
- 'ios',
845
- 'android',
846
- 'macos',
847
- 'linux',
848
- 'windows',
849
- 'web',
850
- 'public',
851
- 'assets',
852
- 'archive',
853
- 'backups',
854
- ]);
855
- const validExts = new Set(['.tsx', '.ts', '.jsx', '.js', '.dart', '.php', '.py', '.css', '.scss', '.html', '.vue']);
856
- const results = [];
1118
+ const SOURCE_EXTS = new Set(['.tsx', '.ts', '.jsx', '.js', '.dart', '.php', '.py', '.css', '.scss', '.html', '.vue']);
1119
+ const SKIP_DIRS = new Set([
1120
+ 'node_modules',
1121
+ '.git',
1122
+ 'dist',
1123
+ 'build',
1124
+ '.next',
1125
+ '.nuxt',
1126
+ '.dart_tool',
1127
+ 'vendor',
1128
+ 'coverage',
1129
+ 'archive',
1130
+ 'backups',
1131
+ ]);
1132
+ /** Every candidate source file in the project: Git's own view when available (honours .gitignore), else a bounded walk. */
1133
+ function listProjectFiles(projectPath) {
1134
+ try {
1135
+ if (fs.existsSync(path.join(projectPath, '.git'))) {
1136
+ const out = execFileSync('git', ['ls-files', '-co', '--exclude-standard'], {
1137
+ cwd: projectPath,
1138
+ encoding: 'utf8',
1139
+ stdio: ['ignore', 'pipe', 'ignore'],
1140
+ timeout: 10000,
1141
+ maxBuffer: 32 * 1024 * 1024,
1142
+ });
1143
+ const files = out.split('\n').map((l) => l.trim()).filter(Boolean);
1144
+ if (files.length > 0)
1145
+ return files;
1146
+ }
1147
+ }
1148
+ catch { }
1149
+ const found = [];
857
1150
  const walk = (dir, depth) => {
858
- if (depth > 6 || results.length > 600)
1151
+ if (depth > 8 || found.length > 20000)
859
1152
  return;
860
1153
  let entries = [];
861
1154
  try {
@@ -865,67 +1158,157 @@ function collectProjectSourceFiles(projectPath, keywords) {
865
1158
  return;
866
1159
  }
867
1160
  for (const entry of entries) {
868
- const name = entry.name;
869
- if (name.startsWith('.'))
1161
+ if (entry.name.startsWith('.'))
870
1162
  continue;
871
- if (/backup|copy|\s\d+/i.test(name))
872
- continue;
873
- const full = path.join(dir, name);
1163
+ const full = path.join(dir, entry.name);
874
1164
  if (entry.isDirectory()) {
875
- if (skipDirs.has(name.toLowerCase()))
876
- continue;
877
- walk(full, depth + 1);
1165
+ if (!SKIP_DIRS.has(entry.name.toLowerCase()))
1166
+ walk(full, depth + 1);
878
1167
  }
879
1168
  else if (entry.isFile()) {
880
- const ext = path.extname(name).toLowerCase();
881
- if (!validExts.has(ext))
882
- continue;
883
- if (/\.(test|spec|min|d)\./i.test(name))
1169
+ found.push(path.relative(projectPath, full).replace(/\\/g, '/'));
1170
+ }
1171
+ }
1172
+ };
1173
+ walk(projectPath, 0);
1174
+ return found;
1175
+ }
1176
+ /**
1177
+ * Which top-level folders and files the team has actually been working in lately, measured in commits
1178
+ * (not file touches, so one bulk reformat of a legacy folder does not make it look active).
1179
+ */
1180
+ function recentGitActivity(projectPath) {
1181
+ const files = new Set();
1182
+ const dirCommits = new Map();
1183
+ try {
1184
+ const out = execFileSync('git', ['log', '-n', '150', '--name-only', '--format=__AM_COMMIT__'], {
1185
+ cwd: projectPath,
1186
+ encoding: 'utf8',
1187
+ stdio: ['ignore', 'pipe', 'ignore'],
1188
+ timeout: 10000,
1189
+ maxBuffer: 16 * 1024 * 1024,
1190
+ });
1191
+ for (const commit of out.split('__AM_COMMIT__')) {
1192
+ const tops = new Set();
1193
+ for (const line of commit.split('\n')) {
1194
+ const rel = line.trim();
1195
+ if (!rel)
884
1196
  continue;
885
- const rel = path.relative(projectPath, full).replace(/\\/g, '/');
886
- const lowerRel = rel.toLowerCase();
887
- let score = 0;
1197
+ files.add(rel);
1198
+ if (rel.includes('/'))
1199
+ tops.add(rel.split('/')[0]);
1200
+ }
1201
+ for (const top of tops)
1202
+ dirCommits.set(top, (dirCommits.get(top) || 0) + 1);
1203
+ }
1204
+ }
1205
+ catch { }
1206
+ let maxCommits = 0;
1207
+ for (const n of dirCommits.values())
1208
+ maxCommits = Math.max(maxCommits, n);
1209
+ return { files, dirCommits, maxCommits };
1210
+ }
1211
+ export function collectProjectSourceFiles(projectPath, keywords) {
1212
+ const isFlutterProject = fs.existsSync(path.join(projectPath, 'pubspec.yaml'));
1213
+ const activity = recentGitActivity(projectPath);
1214
+ let docsText = '';
1215
+ for (const doc of ['CLAUDE.md', 'AGENTS.md', 'MEMORY.md', 'README.md']) {
1216
+ try {
1217
+ docsText += fs.readFileSync(path.join(projectPath, doc), 'utf8').slice(0, 20000).toLowerCase() + '\n';
1218
+ }
1219
+ catch { }
1220
+ }
1221
+ const candidates = [];
1222
+ for (const rel of listProjectFiles(projectPath)) {
1223
+ const segments = rel.split('/');
1224
+ const name = segments[segments.length - 1];
1225
+ const ext = path.extname(name).toLowerCase();
1226
+ if (!SOURCE_EXTS.has(ext))
1227
+ continue;
1228
+ if (/\.(test|spec|min|d)\./i.test(name))
1229
+ continue;
1230
+ const dirsOnly = segments.slice(0, -1).map((s) => s.toLowerCase());
1231
+ if (segments.some((s) => s.startsWith('.') || /backup|copy|\s\d+/i.test(s)))
1232
+ continue;
1233
+ if (dirsOnly.some((s) => SKIP_DIRS.has(s) || /blueprint|figma[-_]export|mockup|prototype/i.test(s)))
1234
+ continue;
1235
+ // In a Flutter app, the native platform runner folders are generated scaffolding, not the app's source
1236
+ if (isFlutterProject && ['ios', 'android', 'macos', 'linux', 'windows', 'web'].includes(dirsOnly[0] || ''))
1237
+ continue;
1238
+ const lowerRel = rel.toLowerCase();
1239
+ let score = 0;
1240
+ if (/blueprint|figma-export|figma_export|mockup|prototype|sample|temp/i.test(lowerRel))
1241
+ score -= 50;
1242
+ // Active application directories, including suffixed monorepo folders (backend-php/, frontend-flutter/, …)
1243
+ if (/^(apps|packages|src|lib|app|web|server|api|client|frontend[\w.-]*|backend[\w.-]*)\//i.test(lowerRel))
1244
+ score += 35;
1245
+ const top = dirsOnly[0] || '';
1246
+ if (top) {
1247
+ // Folders the project docs name explicitly are the live ones (`backend-php/` must not count as `backend/`)
1248
+ if (new RegExp(`(^|[^\\w-])${top.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`).test(docsText))
1249
+ score += 15;
1250
+ // Rank top-level folders by how much of the recent history touched them: next to an active
1251
+ // `backend-php/`, a `backend/` that one commit in 150 touched is legacy.
1252
+ if (activity.maxCommits >= 10) {
1253
+ const commits = activity.dirCommits.get(segments[0]) || 0;
1254
+ const share = commits / activity.maxCommits;
1255
+ if (share < 0.06)
1256
+ score -= 30;
1257
+ else
1258
+ score += Math.round(12 * share);
1259
+ }
1260
+ }
1261
+ if (activity.files.has(rel))
1262
+ score += 8;
1263
+ for (const kw of keywords) {
1264
+ if (kw.length >= 3 && lowerRel.includes(kw))
1265
+ score += 25;
1266
+ }
1267
+ if (ext === '.tsx' || ext === '.jsx' || ext === '.dart' || ext === '.vue') {
1268
+ if (/component|screen|widget|modal|page|view/.test(lowerRel))
1269
+ score += 12;
1270
+ }
1271
+ if (ext === '.php' || ext === '.py' || ext === '.ts') {
1272
+ if (/controller|service|route|model/.test(lowerRel))
1273
+ score += 12;
1274
+ }
1275
+ candidates.push({ rel, score });
1276
+ }
1277
+ // Content keyword hits are the strongest signal but cost a file read: spend them on the best-looking files
1278
+ candidates.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
1279
+ for (const c of candidates.slice(0, 1200)) {
1280
+ const ext = path.extname(c.rel).toLowerCase();
1281
+ if (!['.tsx', '.ts', '.dart', '.php', '.jsx', '.vue', '.py', '.js'].includes(ext))
1282
+ continue;
1283
+ try {
1284
+ const full = path.join(projectPath, c.rel);
1285
+ if (fs.statSync(full).size < 120000) {
1286
+ const contentLower = fs.readFileSync(full, 'utf8').toLowerCase();
888
1287
  for (const kw of keywords) {
889
- if (kw.length >= 3 && lowerRel.includes(kw)) {
890
- score += 25;
891
- }
892
- }
893
- // Check file content for keyword hits on promising source files
894
- if (ext === '.tsx' || ext === '.ts' || ext === '.dart' || ext === '.php' || ext === '.jsx') {
895
- try {
896
- const stat = fs.statSync(full);
897
- if (stat.size < 120000) {
898
- const contentLower = fs.readFileSync(full, 'utf8').toLowerCase();
899
- for (const kw of keywords) {
900
- if (kw.length >= 4 && contentLower.includes(kw)) {
901
- score += 10;
902
- }
903
- }
904
- }
905
- }
906
- catch { }
1288
+ if (kw.length >= 4 && contentLower.includes(kw))
1289
+ c.score += 10;
907
1290
  }
908
- // Bonus score for UI components / screens AND backend controllers / models
909
- if (ext === '.tsx' || ext === '.jsx' || ext === '.dart' || ext === '.vue') {
910
- if (lowerRel.includes('component') || lowerRel.includes('screen') || lowerRel.includes('widget') || lowerRel.includes('modal') || lowerRel.includes('model')) {
911
- score += 8;
912
- }
913
- }
914
- if (ext === '.php' || ext === '.py' || ext === '.ts') {
915
- if (lowerRel.includes('controller') || lowerRel.includes('service') || lowerRel.includes('route')) {
916
- score += 10;
917
- }
918
- }
919
- results.push({ rel, score });
920
1291
  }
921
1292
  }
922
- };
923
- walk(projectPath, 0);
924
- results.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
925
- return results.slice(0, 250).map((r) => r.rel);
1293
+ catch { }
1294
+ }
1295
+ candidates.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
1296
+ return candidates.slice(0, 300).map((r) => r.rel);
1297
+ }
1298
+ /** Lock files are tiny, but they are written per card forever: drop the ones nobody can still be waiting on. */
1299
+ function pruneStaleLocks(maxAgeMs = 45 * 24 * 60 * 60 * 1000) {
1300
+ try {
1301
+ const dir = path.join(agileMonsterHome(), 'locks');
1302
+ for (const name of fs.readdirSync(dir)) {
1303
+ const file = path.join(dir, name);
1304
+ if (Date.now() - fs.statSync(file).mtimeMs > maxAgeMs)
1305
+ fs.rmSync(file, { force: true });
1306
+ }
1307
+ }
1308
+ catch { }
926
1309
  }
927
1310
  function getAgileMonsterLocksDir() {
928
- const dir = path.join(os.homedir(), '.agilemonster', 'locks');
1311
+ const dir = path.join(agileMonsterHome(), 'locks');
929
1312
  try {
930
1313
  if (!fs.existsSync(dir)) {
931
1314
  fs.mkdirSync(dir, { recursive: true });
@@ -936,9 +1319,8 @@ function getAgileMonsterLocksDir() {
936
1319
  }
937
1320
  /**
938
1321
  * Append a live, human-readable IDE activity entry to `<projectPath>/.agilemonster/ACTIVITY.md`
939
- * (and exclude `.agilemonster/` in `.git/info/exclude` so it never pollutes `git status`).
940
- * Developers can open `.agilemonster/ACTIVITY.md` in their IDE at any time to see full agent
941
- * troubleshooting logs, files inspected, build verification output, and diffs.
1322
+ * (kept out of Git via `.git/info/exclude`). Developers can open it in their IDE at any time to see
1323
+ * agent troubleshooting logs, files inspected, build verification output, and diffs.
942
1324
  */
943
1325
  function appendIdeActivityLog(projectPath, entry) {
944
1326
  try {
@@ -946,13 +1328,7 @@ function appendIdeActivityLog(projectPath, entry) {
946
1328
  if (!fs.existsSync(amDir)) {
947
1329
  fs.mkdirSync(amDir, { recursive: true });
948
1330
  }
949
- const gitExclude = path.join(projectPath, '.git', 'info', 'exclude');
950
- if (fs.existsSync(gitExclude)) {
951
- const exContent = fs.readFileSync(gitExclude, 'utf8');
952
- if (!exContent.includes('.agilemonster/')) {
953
- fs.appendFileSync(gitExclude, '\n.agilemonster/\n.agilemonster.json\n', 'utf8');
954
- }
955
- }
1331
+ ensureLocalGitExcludes(projectPath);
956
1332
  const logPath = path.join(amDir, 'ACTIVITY.md');
957
1333
  const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
958
1334
  const header = !fs.existsSync(logPath)
@@ -975,6 +1351,382 @@ function appendIdeActivityLog(projectPath, entry) {
975
1351
  }
976
1352
  catch { }
977
1353
  }
1354
+ function boundProfileOf(card) {
1355
+ return {
1356
+ deploy_method: card.deploy_method,
1357
+ deploy_command: card.deploy_command,
1358
+ live_url: card.live_url,
1359
+ build_command: card.build_command,
1360
+ deploy_source: card.deploy_source,
1361
+ };
1362
+ }
1363
+ /** Feed the outcome back to the server so the board's binding converges on the route that really works. */
1364
+ async function reportOutcome(client, card, projectPath, res) {
1365
+ if (res.method === 'none' || res.errorKind === 'needs_confirmation')
1366
+ return;
1367
+ await client.reportDeployment({
1368
+ card_id: card.id,
1369
+ board_id: card.board_id,
1370
+ project_path: projectPath,
1371
+ method: res.method,
1372
+ command: res.command,
1373
+ live_url: res.profile.liveUrlSource === 'guessed' ? null : res.profile.liveUrl,
1374
+ ok: res.ok,
1375
+ // "Pushed but not verified" must not be recorded as a proven route
1376
+ pending: res.pending || res.unverified,
1377
+ error: res.ok ? null : res.errorDetail.slice(0, 480),
1378
+ error_kind: res.errorKind,
1379
+ commit: res.commitHash || null,
1380
+ source: res.profile.source === 'explicit' || res.profile.source === 'user' ? 'user' : 'verified',
1381
+ evidence: res.profile.evidence.slice(0, 3),
1382
+ });
1383
+ }
1384
+ const sha1 = (text) => crypto.createHash('sha1').update(text).digest('hex');
1385
+ function journalFile(cardId) {
1386
+ return path.join(agileMonsterHome(), 'journal', `${cardId.replace(/[^a-zA-Z0-9-]/g, '')}.json`);
1387
+ }
1388
+ /** Record the pre-edit contents of every file touched so far (called after each write). */
1389
+ function journalEdits(cardId, projectPath, originals) {
1390
+ try {
1391
+ const files = {};
1392
+ for (const [abs, original] of originals.entries()) {
1393
+ let current = original;
1394
+ try {
1395
+ current = fs.readFileSync(abs, 'utf8');
1396
+ }
1397
+ catch { }
1398
+ files[abs] = { original, edited_sha: sha1(current) };
1399
+ }
1400
+ writeJsonAtomic(journalFile(cardId), { card_id: cardId, project_path: projectPath, pid: process.pid, at: new Date().toISOString(), files });
1401
+ }
1402
+ catch (err) {
1403
+ log.warn('journal.write_failed', { card: cardId, error: errorMessage(err) });
1404
+ }
1405
+ }
1406
+ function clearEditJournal(cardId) {
1407
+ try {
1408
+ fs.rmSync(journalFile(cardId), { force: true });
1409
+ }
1410
+ catch { }
1411
+ }
1412
+ /**
1413
+ * Roll back edits left behind by a worker that died mid-card. A file is only restored if it still holds
1414
+ * exactly what that worker wrote — anything the developer touched since is left alone.
1415
+ */
1416
+ export function recoverEditJournals() {
1417
+ let restored = 0;
1418
+ try {
1419
+ const dir = path.join(agileMonsterHome(), 'journal');
1420
+ if (!fs.existsSync(dir))
1421
+ return 0;
1422
+ for (const name of fs.readdirSync(dir)) {
1423
+ if (!name.endsWith('.json'))
1424
+ continue;
1425
+ const file = path.join(dir, name);
1426
+ const journal = readJson(file);
1427
+ if (!journal?.files) {
1428
+ fs.rmSync(file, { force: true });
1429
+ continue;
1430
+ }
1431
+ if (journal.pid === process.pid || (journal.pid && isPidAlive(journal.pid)))
1432
+ continue; // still being worked on
1433
+ for (const [abs, entry] of Object.entries(journal.files)) {
1434
+ try {
1435
+ if (sha1(fs.readFileSync(abs, 'utf8')) === entry.edited_sha) {
1436
+ fs.writeFileSync(abs, entry.original, 'utf8');
1437
+ restored++;
1438
+ }
1439
+ }
1440
+ catch { }
1441
+ }
1442
+ log.warn('journal.recovered', { card: journal.card_id, project: journal.project_path, files: Object.keys(journal.files).length });
1443
+ fs.rmSync(file, { force: true });
1444
+ }
1445
+ }
1446
+ catch (err) {
1447
+ log.warn('journal.recover_failed', { error: errorMessage(err) });
1448
+ }
1449
+ return restored;
1450
+ }
1451
+ // ---------------------------------------------------------------------------
1452
+ // Who may steer deployments on this machine
1453
+ // ---------------------------------------------------------------------------
1454
+ const ownerIds = new WeakMap();
1455
+ /** The account whose API key this machine runs under. Only that person may choose or confirm what gets executed here. */
1456
+ function machineOwnerId(client) {
1457
+ let cached = ownerIds.get(client);
1458
+ if (!cached) {
1459
+ cached = client
1460
+ .me()
1461
+ .then((me) => (me?.id ? String(me.id) : null))
1462
+ .catch(() => null);
1463
+ ownerIds.set(client, cached);
1464
+ // Do not cache a failed lookup forever
1465
+ void cached.then((id) => {
1466
+ if (!id)
1467
+ ownerIds.delete(client);
1468
+ });
1469
+ }
1470
+ return cached;
1471
+ }
1472
+ async function withRetry(what, fn, attempts = 3) {
1473
+ let lastError;
1474
+ for (let i = 0; i < attempts; i++) {
1475
+ try {
1476
+ return await fn();
1477
+ }
1478
+ catch (err) {
1479
+ lastError = err;
1480
+ log.warn('api.retry', { what, attempt: i + 1, error: errorMessage(err) });
1481
+ await new Promise((r) => setTimeout(r, 1500 * (i + 1)));
1482
+ }
1483
+ }
1484
+ throw lastError;
1485
+ }
1486
+ /**
1487
+ * A card whose TITLE is an operations request ("Restart the container", "Redeploy production").
1488
+ * Anchored to the start of the title so "Add a restart button to the server page" is not one.
1489
+ */
1490
+ export function isRedeployOnlyCard(card) {
1491
+ return /^\s*(please\s+)?(restart|re-?deploy|rebuild|recreate|re-?start)\b[\s\S]{0,60}\b(containers?|docker|server|stack|prod(uction)?|app|site|services?)\b/i.test(card.title || '');
1492
+ }
1493
+ async function runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile) {
1494
+ await client.addProgress(card.id, provider, `This card asks for a deployment, not a code change. Redeploying \`${projectPath}\` now...`);
1495
+ const res = await runDeployment({
1496
+ projectPath,
1497
+ title: card.title || 'redeploy',
1498
+ cardId: card.id,
1499
+ redeploy: true,
1500
+ bound: boundProfileOf(card),
1501
+ });
1502
+ await reportOutcome(client, card, projectPath, res);
1503
+ const blankState = (extra) => ({
1504
+ stage: 'deploy_failed',
1505
+ files: [],
1506
+ summary: `Redeployed for "${card.title}".`,
1507
+ verification_steps: '1. Check that the application is up and serving the latest version.',
1508
+ diff: '',
1509
+ commit: res.commitHash || null,
1510
+ at: new Date().toISOString(),
1511
+ attempts: 1,
1512
+ ...extra,
1513
+ });
1514
+ if (!res.ok) {
1515
+ setCardState(projectPath, card.id, blankState({
1516
+ last_error: res.errorDetail,
1517
+ pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
1518
+ }));
1519
+ removeTaskLock(lockFile);
1520
+ await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation' ? describeFailureForHuman(res) : `The redeploy did not go through.\n\n${describeFailureForHuman(res)}`);
1521
+ return false;
1522
+ }
1523
+ if (res.pending && res.pendingVerification)
1524
+ addPendingVerification(projectPath, { ...res.pendingVerification, provider });
1525
+ setCardState(projectPath, card.id, null);
1526
+ appendIdeActivityLog(projectPath, {
1527
+ cardId: card.id,
1528
+ cardTitle: card.title,
1529
+ agentName: workerLabel,
1530
+ model: card.agent_model || 'auto',
1531
+ status: 'review',
1532
+ summary: res.deployedNote,
1533
+ });
1534
+ await client.submitReview(card.id, provider, res.deployedNote, '1. Check that the application is up and serving the latest version.', undefined, undefined, {
1535
+ logText: `Redeploy for "${card.title}" in ${projectPath}\n${res.deployedNote}`,
1536
+ });
1537
+ writeTaskLock(lockFile, card, 'completed');
1538
+ return true;
1539
+ }
1540
+ function latestHumanComment(comments) {
1541
+ for (let i = comments.length - 1; i >= 0; i--) {
1542
+ const c = comments[i];
1543
+ const text = (c.text || '').trim();
1544
+ if (!text)
1545
+ continue;
1546
+ if ((c.author_kind || 'member').toLowerCase() === 'agent')
1547
+ continue;
1548
+ if (/^(🤖|🛠️|✅|🚀|🔄|🛡️|🚨|⚠️|❓|⏳|📦)/u.test(text))
1549
+ continue;
1550
+ return c;
1551
+ }
1552
+ return null;
1553
+ }
1554
+ /** Server timestamps are UTC, with or without a zone suffix. Unknown formats count as "after" (fail open). */
1555
+ function commentIsAfter(comment, isoTime) {
1556
+ const raw = (comment.created_at || '').trim();
1557
+ if (!raw)
1558
+ return true;
1559
+ const normalized = /[zZ]|[+-]\d\d:?\d\d$/.test(raw) ? raw : `${raw.replace(' ', 'T')}Z`;
1560
+ const at = Date.parse(normalized);
1561
+ const ref = Date.parse(isoTime);
1562
+ if (Number.isNaN(at) || Number.isNaN(ref))
1563
+ return true;
1564
+ return at >= ref - 2 * 60 * 1000; // tolerate clock skew between this machine and the server
1565
+ }
1566
+ function writeTaskLock(lockFile, card, status) {
1567
+ try {
1568
+ fs.writeFileSync(lockFile, JSON.stringify({
1569
+ card_id: card.id,
1570
+ card_updated_at: card.updated_at || '',
1571
+ status,
1572
+ [status === 'running' ? 'started_at' : 'completed_at']: new Date().toISOString(),
1573
+ }), 'utf8');
1574
+ }
1575
+ catch { }
1576
+ }
1577
+ function removeTaskLock(lockFile) {
1578
+ try {
1579
+ if (fs.existsSync(lockFile))
1580
+ fs.unlinkSync(lockFile);
1581
+ }
1582
+ catch { }
1583
+ }
1584
+ /**
1585
+ * The code change for this card already exists — only its deployment failed. When the human replies
1586
+ * ("retry", or "deploy: <method or command>"), redo ONLY the deployment. Previously the whole card was
1587
+ * re-run from scratch, the model found nothing left to change, and the deploy was never retried.
1588
+ *
1589
+ * Returns 'continue' when the reply is new code feedback and the normal flow should run.
1590
+ */
1591
+ async function resumeFailedDeployment(client, card, projectPath, provider, workerLabel, state, lockFile) {
1592
+ let human = null;
1593
+ try {
1594
+ human = latestHumanComment(await client.listComments(card.id));
1595
+ }
1596
+ catch (err) {
1597
+ log.warn('card.comments_unavailable', { card: card.id, error: errorMessage(err) });
1598
+ }
1599
+ // Only a reply written AFTER the failure/question counts. An older comment is never re-applied as an instruction.
1600
+ const reply = human && commentIsAfter(human, state.at) ? human : null;
1601
+ const parsed = reply ? parseDeployDirective(reply.text) : null;
1602
+ // A fresh reply that is not about deploying is new feedback on the code: run the full flow.
1603
+ if (reply && !parsed)
1604
+ return 'continue';
1605
+ const pendingRoute = state.pending_confirmation || null;
1606
+ const choosesRoute = Boolean(parsed && (parsed.method || parsed.liveUrl || pendingRoute));
1607
+ if (choosesRoute) {
1608
+ // Choosing or confirming a route decides what is executed on THIS computer, so it has to come from the
1609
+ // person whose account this machine runs under — not from anyone who can comment on the board.
1610
+ const ownerId = await machineOwnerId(client);
1611
+ const authorId = reply?.user_id ? String(reply.user_id) : '';
1612
+ if (!ownerId || !authorId || authorId !== ownerId) {
1613
+ removeTaskLock(lockFile);
1614
+ setCardState(projectPath, card.id, { ...state, at: new Date().toISOString() });
1615
+ await client.askQuestion(card.id, provider, 'I can only take deployment instructions (which route or command to run) from the person whose computer runs this agent. Please ask them to reply here with `deploy: <method or command>`, or to confirm with **yes**.');
1616
+ return 'failed';
1617
+ }
1618
+ }
1619
+ if (pendingRoute && !parsed) {
1620
+ // Re-queued without an answer (card dragged, reassigned, comments unreadable): asking is still pending.
1621
+ // Nothing is ever confirmed on the human's behalf.
1622
+ removeTaskLock(lockFile);
1623
+ setCardState(projectPath, card.id, { ...state, at: new Date().toISOString() });
1624
+ await client.askQuestion(card.id, provider, `I still need an answer before I deploy this project for the first time. Reply \`deploy: ${pendingRoute.command || pendingRoute.method}\` to confirm, \`deploy: manual\` if you deploy it yourself, or tell me the right route.`);
1625
+ return 'failed';
1626
+ }
1627
+ // No fresh reply at all: a re-queue of a deploy-failed card means "try the same deployment again".
1628
+ let directive = parsed || { retry: true, method: null, command: null, liveUrl: null };
1629
+ // "yes" / "retry" from the owner in answer to "may I deploy with X?" confirms X
1630
+ if (parsed && !directive.method && pendingRoute) {
1631
+ const confirmed = normalizeMethod(pendingRoute.method);
1632
+ if (confirmed)
1633
+ directive = { ...directive, method: confirmed, command: pendingRoute.command };
1634
+ }
1635
+ if (directive.method) {
1636
+ setExplicitProfile(projectPath, {
1637
+ method: directive.method,
1638
+ command: directive.command,
1639
+ live_url: directive.liveUrl,
1640
+ notes: `confirmed on card "${card.title}"`,
1641
+ set_by: 'the machine owner',
1642
+ });
1643
+ }
1644
+ await client.addProgress(card.id, provider, directive.method
1645
+ ? `Got it — deploying with **${methodLabel(directive.method)}**${directive.command ? ` (\`${directive.command}\`)` : ''}. I've saved this as the deployment route for this project, so future cards use it automatically.`
1646
+ : 'Retrying the deployment of the change I already made (no code is being regenerated).');
1647
+ const res = await runDeployment({
1648
+ projectPath,
1649
+ title: card.title || 'update',
1650
+ cardId: card.id,
1651
+ files: state.files,
1652
+ redeploy: true,
1653
+ bound: boundProfileOf(card),
1654
+ override: directive.method ? { method: directive.method, command: directive.command } : null,
1655
+ });
1656
+ await reportOutcome(client, card, projectPath, res);
1657
+ if (!res.ok) {
1658
+ setCardState(projectPath, card.id, {
1659
+ ...state,
1660
+ attempts: state.attempts + 1,
1661
+ last_error: res.errorDetail,
1662
+ at: new Date().toISOString(),
1663
+ pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
1664
+ });
1665
+ removeTaskLock(lockFile);
1666
+ appendIdeActivityLog(projectPath, {
1667
+ cardId: card.id,
1668
+ cardTitle: card.title,
1669
+ agentName: workerLabel,
1670
+ model: card.agent_model || 'auto',
1671
+ status: 'deploy_failed',
1672
+ summary: `Deployment retry failed: ${res.errorDetail.slice(0, 500)}`,
1673
+ files: state.files,
1674
+ });
1675
+ await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation'
1676
+ ? describeFailureForHuman(res)
1677
+ : `The deployment still did not go through, so this card stays in progress.\n\n${describeFailureForHuman(res)}`);
1678
+ return 'failed';
1679
+ }
1680
+ if (res.pending && res.pendingVerification) {
1681
+ addPendingVerification(projectPath, { ...res.pendingVerification, provider });
1682
+ }
1683
+ setCardState(projectPath, card.id, null);
1684
+ const summary = `${state.summary}\n\n${res.deployedNote}`.trim();
1685
+ appendIdeActivityLog(projectPath, {
1686
+ cardId: card.id,
1687
+ cardTitle: card.title,
1688
+ agentName: workerLabel,
1689
+ model: card.agent_model || 'auto',
1690
+ status: 'review',
1691
+ summary,
1692
+ files: state.files,
1693
+ });
1694
+ await client.submitReview(card.id, provider, summary, state.verification_steps, undefined, undefined, {
1695
+ diffText: state.diff,
1696
+ logText: `Deployment retried for "${card.title}" in ${projectPath}\n${res.deployedNote}`,
1697
+ filesModified: state.files.join(', '),
1698
+ });
1699
+ writeTaskLock(lockFile, card, 'completed');
1700
+ sendDesktopNotification('Agile Monster -> Ready for Review', `Deployed "${card.title}" and moved it to Testing / Review.`);
1701
+ return 'done';
1702
+ }
1703
+ /** Find the nearest package.json (walking up from each changed front-end file) that has a build script and installed deps. */
1704
+ function frontendBuildDirs(projectPath, modifiedFiles) {
1705
+ const root = path.resolve(projectPath);
1706
+ const dirs = [];
1707
+ for (const rel of modifiedFiles) {
1708
+ if (!/\.(tsx?|jsx?|vue|css|scss)$/i.test(rel))
1709
+ continue;
1710
+ let dir = path.dirname(path.resolve(root, rel));
1711
+ while (dir.startsWith(root)) {
1712
+ const pkgJson = path.join(dir, 'package.json');
1713
+ if (fs.existsSync(pkgJson)) {
1714
+ try {
1715
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf8'));
1716
+ if (pkg?.scripts?.build && fs.existsSync(path.join(dir, 'node_modules')) && !dirs.includes(dir)) {
1717
+ dirs.push(dir);
1718
+ }
1719
+ }
1720
+ catch { }
1721
+ break;
1722
+ }
1723
+ if (dir === root)
1724
+ break;
1725
+ dir = path.dirname(dir);
1726
+ }
1727
+ }
1728
+ return dirs;
1729
+ }
978
1730
  export async function executeQueuedCardAutonomously(client, card, projectPath) {
979
1731
  if (!projectPath || !fs.existsSync(projectPath))
980
1732
  return false;
@@ -1001,23 +1753,42 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1001
1753
  return false;
1002
1754
  }
1003
1755
  }
1004
- fs.writeFileSync(lockFile, JSON.stringify({
1005
- card_id: card.id,
1006
- card_updated_at: cardUpdatedAt,
1007
- status: 'running',
1008
- started_at: new Date().toISOString(),
1009
- }), 'utf8');
1756
+ writeTaskLock(lockFile, card, 'running');
1010
1757
  }
1011
1758
  catch {
1012
1759
  return false;
1013
1760
  }
1014
1761
  activeAutonomousCards.add(card.id);
1762
+ activeWork++;
1015
1763
  const provider = card.agent_provider || 'cursor';
1016
1764
  const workerLabel = card.agent_display_name || provider;
1765
+ // Original contents of every file this run touches, so an interrupted run never leaves half-applied edits behind
1766
+ const originalContents = new Map();
1767
+ let editsHandedOff = false;
1768
+ log.info('card.start', { card: card.id, title: card.title, project: projectPath, provider });
1017
1769
  try {
1018
1770
  // 1. Claim the card (automatically moves it from ToDo -> In Progress on the board!)
1019
1771
  await client.claimTask(card.id, provider, `auto-pulse-${Date.now()}`, card.board_id);
1020
1772
  sendDesktopNotification(`Agile Monster -> ${workerLabel}`, `Claimed & moved to In Progress: "${card.title}"`);
1773
+ // 1b. Deploy-only resume: the change exists, only shipping it is outstanding
1774
+ const priorState = getCardState(projectPath, card.id);
1775
+ let carryOverFiles = [];
1776
+ if (priorState?.stage === 'deploy_failed') {
1777
+ const outcome = await resumeFailedDeployment(client, card, projectPath, provider, workerLabel, priorState, lockFile);
1778
+ if (outcome !== 'continue')
1779
+ return outcome === 'done';
1780
+ // The human sent new code feedback instead: keep the earlier files in the commit set and run normally
1781
+ carryOverFiles = priorState.files;
1782
+ }
1783
+ else if (priorState) {
1784
+ // Rework of a change that is still waiting for approval: the earlier files must ship together with the new ones
1785
+ carryOverFiles = priorState.files;
1786
+ }
1787
+ // "Restart the container" / "Redeploy production" cards ask for a deployment, not for a code change
1788
+ if (isRedeployOnlyCard(card)) {
1789
+ return await runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile);
1790
+ }
1791
+ const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card), memory: loadMemory(projectPath) });
1021
1792
  await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. Scanning repository files...`);
1022
1793
  // 2. Build keyword list from card title + description and collect project source files
1023
1794
  const rawText = `${card.title || ''} ${card.description || ''}`.toLowerCase();
@@ -1027,18 +1798,22 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1027
1798
  .filter((w) => w.length >= 3 && !stopWords.has(w))));
1028
1799
  const fileIndex = collectProjectSourceFiles(projectPath, keywords);
1029
1800
  if (fileIndex.length === 0) {
1030
- await client.addProgress(card.id, provider, `Card is now **In Progress**, but no matching source files were found in \`${projectPath}\`.`);
1801
+ removeTaskLock(lockFile);
1802
+ await client.askQuestion(card.id, provider, `I could not find any source files to work on in \`${projectPath}\`. Is this the right folder for the "${card.board_name || 'this'}" board? If not, connect the correct project folder in the board's AI Agents settings and reply here.`);
1031
1803
  return false;
1032
1804
  }
1033
1805
  // 3. Ask Agile Monster Autonomous Step to pick the 1-6 most relevant files
1034
- const projectContext = readProjectInstructionsContext(projectPath);
1806
+ const projectContext = readProjectInstructionsContext(projectPath, profile);
1035
1807
  const selectionRes = await client.autonomousStep({
1036
1808
  card_id: card.id,
1037
1809
  action: 'select_files',
1038
1810
  file_index: fileIndex,
1039
1811
  project_context: projectContext,
1040
1812
  });
1041
- const selectedFiles = (selectionRes.target_files || selectionRes.selected_files || []).slice(0, 6);
1813
+ // The model may only choose from the files it was offered: a path it made up (../../.ssh/…, .env) is dropped
1814
+ const selectedFiles = (selectionRes.target_files || selectionRes.selected_files || [])
1815
+ .filter((f) => typeof f === 'string' && fileIndex.includes(f))
1816
+ .slice(0, 6);
1042
1817
  if (selectedFiles.length === 0) {
1043
1818
  selectedFiles.push(...fileIndex.slice(0, 4));
1044
1819
  }
@@ -1061,12 +1836,17 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1061
1836
  const full = path.join(projectPath, rel);
1062
1837
  if (fs.existsSync(full)) {
1063
1838
  try {
1064
- filesPayload[rel] = fs.readFileSync(full, 'utf8');
1839
+ const raw = fs.readFileSync(full);
1840
+ // A file that is not valid UTF-8 would be silently corrupted by a text round-trip: leave it alone
1841
+ if (isUtf8(raw))
1842
+ filesPayload[rel] = raw.toString('utf8');
1065
1843
  }
1066
1844
  catch { }
1067
1845
  }
1068
1846
  }
1069
1847
  if (Object.keys(filesPayload).length === 0) {
1848
+ removeTaskLock(lockFile);
1849
+ await client.askQuestion(card.id, provider, 'I could not read the files I selected for this task. Could you point me at the page, screen or file this should change?');
1070
1850
  return false;
1071
1851
  }
1072
1852
  await client.addProgress(card.id, provider, `Selected target files for implementation: ${Object.keys(filesPayload)
@@ -1081,11 +1861,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1081
1861
  });
1082
1862
  // Gate A: Strict truth — if the model is not confident / cannot find the target, ask the human instead of guessing
1083
1863
  if (editsRes.needs_clarification && (editsRes.clarification_question || '').trim() !== '') {
1084
- try {
1085
- if (fs.existsSync(lockFile))
1086
- fs.unlinkSync(lockFile);
1087
- }
1088
- catch { }
1864
+ removeTaskLock(lockFile);
1089
1865
  appendIdeActivityLog(projectPath, {
1090
1866
  cardId: card.id,
1091
1867
  cardTitle: card.title,
@@ -1098,116 +1874,148 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1098
1874
  await client.askQuestion(card.id, provider, (editsRes.clarification_question || '').trim());
1099
1875
  return false;
1100
1876
  }
1101
- const applyEditsPass = (editsList) => {
1877
+ // Only the files that were actually sent to the model may be edited (never .git/, never a path it invented)
1878
+ const editableFiles = new Set(Object.keys(filesPayload));
1879
+ const projectRoot = fs.realpathSync(projectPath);
1880
+ /**
1881
+ * Apply one set of edits. Returns the files changed and a reason for every edit that could not be applied.
1882
+ * A set of edits is one coordinated change: the caller ships it only if ALL of it applied.
1883
+ */
1884
+ const applyEditsPass = async (editsList) => {
1102
1885
  const modFiles = [];
1886
+ const failed = [];
1103
1887
  for (const edit of editsList) {
1104
- const relPath = edit.file || edit.path;
1105
- if (!relPath || !edit.search || typeof edit.replace !== 'string')
1888
+ const relPath = (edit.file || edit.path || '').replace(/\\/g, '/').replace(/^\.\//, '');
1889
+ if (!relPath || !edit.search || typeof edit.replace !== 'string') {
1890
+ failed.push(`${relPath || '(no file)'}: the edit was incomplete`);
1106
1891
  continue;
1107
- const fullPath = path.resolve(projectPath, relPath);
1108
- if (!fullPath.startsWith(path.resolve(projectPath)))
1892
+ }
1893
+ if (!editableFiles.has(relPath)) {
1894
+ failed.push(`${relPath}: not one of the files provided`);
1109
1895
  continue;
1110
- if (!fs.existsSync(fullPath))
1896
+ }
1897
+ const fullPath = path.resolve(projectPath, relPath);
1898
+ let realPath = '';
1899
+ try {
1900
+ realPath = fs.realpathSync(fullPath);
1901
+ }
1902
+ catch { }
1903
+ if (!realPath || !(realPath === projectRoot || realPath.startsWith(projectRoot + path.sep))) {
1904
+ failed.push(`${relPath}: outside the project`);
1111
1905
  continue;
1906
+ }
1112
1907
  const currentContent = fs.readFileSync(fullPath, 'utf8');
1113
- if (!originalContents.has(fullPath)) {
1908
+ if (!currentContent.includes(edit.search)) {
1909
+ failed.push(`${relPath}: the \`search\` text was not found verbatim in the file`);
1910
+ continue;
1911
+ }
1912
+ const updatedContent = currentContent.replace(edit.search, () => edit.replace);
1913
+ if (updatedContent === currentContent)
1914
+ continue; // nothing to do is not a failure
1915
+ const countChar = (s, ch) => s.split(ch).length - 1;
1916
+ const searchBraceDelta = countChar(edit.search, '{') - countChar(edit.search, '}');
1917
+ const replaceBraceDelta = countChar(edit.replace, '{') - countChar(edit.replace, '}');
1918
+ const searchParenDelta = countChar(edit.search, '(') - countChar(edit.search, ')');
1919
+ const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
1920
+ if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
1921
+ failed.push(`${relPath}: the replacement would leave braces or parentheses unbalanced`);
1922
+ continue;
1923
+ }
1924
+ if (!originalContents.has(fullPath))
1114
1925
  originalContents.set(fullPath, currentContent);
1926
+ fs.writeFileSync(fullPath, updatedContent, 'utf8');
1927
+ journalEdits(card.id, projectPath, originalContents);
1928
+ // Per-file syntax checks run without blocking the event loop (the worker heartbeat must keep ticking)
1929
+ if (relPath.endsWith('.dart')) {
1930
+ const lint = await exec('dart', ['analyze', fullPath], { cwd: projectPath, timeoutMs: 40000 });
1931
+ if (!lint.ok && /\berror\s+[•-]/i.test(lint.stdout + lint.stderr)) {
1932
+ fs.writeFileSync(fullPath, currentContent, 'utf8');
1933
+ failed.push(`${relPath}: dart analyze reported an error after the edit: ${lint.tail.slice(0, 300)}`);
1934
+ continue;
1935
+ }
1115
1936
  }
1116
- if (currentContent.includes(edit.search)) {
1117
- const updatedContent = currentContent.replace(edit.search, edit.replace);
1118
- if (updatedContent !== currentContent) {
1119
- const countChar = (s, ch) => s.split(ch).length - 1;
1120
- const searchBraceDelta = countChar(edit.search, '{') - countChar(edit.search, '}');
1121
- const replaceBraceDelta = countChar(edit.replace, '{') - countChar(edit.replace, '}');
1122
- const searchParenDelta = countChar(edit.search, '(') - countChar(edit.search, ')');
1123
- const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
1124
- if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
1125
- continue;
1126
- }
1127
- fs.writeFileSync(fullPath, updatedContent, 'utf8');
1128
- if (relPath.endsWith('.dart')) {
1129
- try {
1130
- execSync(`dart analyze "${fullPath}"`, {
1131
- cwd: projectPath,
1132
- stdio: ['ignore', 'pipe', 'pipe'],
1133
- timeout: 25000,
1134
- });
1135
- }
1136
- catch (analyzeErr) {
1137
- const out = String(analyzeErr?.stdout || '') + String(analyzeErr?.stderr || '');
1138
- if (/\berror\s+[•-]/i.test(out)) {
1139
- fs.writeFileSync(fullPath, currentContent, 'utf8');
1140
- continue;
1141
- }
1142
- }
1143
- }
1144
- else if (relPath.endsWith('.php')) {
1145
- try {
1146
- execSync(`php -l "${fullPath}"`, {
1147
- cwd: projectPath,
1148
- stdio: ['ignore', 'pipe', 'pipe'],
1149
- timeout: 10000,
1150
- });
1151
- }
1152
- catch {
1153
- fs.writeFileSync(fullPath, currentContent, 'utf8');
1154
- continue;
1155
- }
1156
- }
1157
- if (!modFiles.includes(relPath)) {
1158
- modFiles.push(relPath);
1159
- }
1937
+ else if (relPath.endsWith('.php')) {
1938
+ const lint = await exec('php', ['-l', fullPath], { cwd: projectPath, timeoutMs: 15000 });
1939
+ // exit code null = `php` is not installed here: that must not discard valid work
1940
+ if (!lint.ok && lint.code !== null && !lint.timedOut) {
1941
+ fs.writeFileSync(fullPath, currentContent, 'utf8');
1942
+ failed.push(`${relPath}: PHP syntax error after the edit: ${lint.tail.slice(0, 300)}`);
1943
+ continue;
1160
1944
  }
1161
1945
  }
1946
+ if (!modFiles.includes(relPath))
1947
+ modFiles.push(relPath);
1162
1948
  }
1163
- return modFiles;
1949
+ return { modFiles, failed };
1164
1950
  };
1165
- const originalContents = new Map();
1166
- const modifiedFiles = applyEditsPass(editsRes.edits || []);
1167
- // Pre-submit frontend build verification gate for root AND monorepo subdirectories (frontend/, web/, client/)
1168
- const verifyFrontendBuilds = () => {
1169
- const hasFrontendEdits = modifiedFiles.some((f) => /\.(tsx?|jsx?|vue|css)$/i.test(f));
1170
- if (!hasFrontendEdits)
1171
- return { ok: true };
1172
- const candidateDirs = [
1173
- projectPath,
1174
- path.join(projectPath, 'frontend'),
1175
- path.join(projectPath, 'web'),
1176
- path.join(projectPath, 'client'),
1177
- ];
1178
- for (const dir of candidateDirs) {
1179
- const pkgJsonPath = path.join(dir, 'package.json');
1180
- if (!fs.existsSync(pkgJsonPath) || !fs.existsSync(path.join(dir, 'node_modules')))
1181
- continue;
1951
+ const revertAll = () => {
1952
+ for (const [fPath, orig] of originalContents.entries()) {
1182
1953
  try {
1183
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
1184
- if (pkg?.scripts?.build) {
1185
- const buildCmd = fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))
1186
- ? 'pnpm run build'
1187
- : 'npm run build';
1188
- execSync(buildCmd, {
1189
- cwd: dir,
1190
- stdio: ['ignore', 'pipe', 'pipe'],
1191
- timeout: 60000,
1192
- });
1193
- }
1954
+ fs.writeFileSync(fPath, orig, 'utf8');
1955
+ }
1956
+ catch { }
1957
+ }
1958
+ originalContents.clear();
1959
+ clearEditJournal(card.id);
1960
+ };
1961
+ // Never build on top of the developer's own uncommitted work in the same file: a whole-file commit would
1962
+ // ship their half-finished changes under this card's name.
1963
+ const targetFiles = Array.from(new Set((editsRes.edits || []).map((e) => (e.file || e.path || '').replace(/\\/g, '/').replace(/^\.\//, '')).filter((f) => editableFiles.has(f))));
1964
+ if (targetFiles.length > 0) {
1965
+ const status = await exec('git', ['--literal-pathspecs', 'status', '--porcelain', '--', ...targetFiles], { cwd: projectPath, timeoutMs: 20000 });
1966
+ const dirty = status.ok
1967
+ ? status.stdout
1968
+ .split('\n')
1969
+ .filter((l) => l.trim() && !l.startsWith('??'))
1970
+ .map((l) => l.slice(3).trim())
1971
+ .filter((f) => !carryOverFiles.includes(f))
1972
+ : [];
1973
+ if (dirty.length > 0) {
1974
+ removeTaskLock(lockFile);
1975
+ await client.askQuestion(card.id, provider, `I need to change ${dirty.map((f) => `\`${f}\``).join(', ')}, but ${dirty.length === 1 ? 'that file has' : 'those files have'} uncommitted changes in \`${projectPath}\` that are not mine. Committing my edit would also commit that unfinished work, so I stopped.\n\nCommit or stash those changes, then reply here and I will continue.`);
1976
+ return false;
1977
+ }
1978
+ }
1979
+ let pass = await applyEditsPass(editsRes.edits || []);
1980
+ if (pass.failed.length > 0) {
1981
+ // Half of a coordinated change is worse than none: undo it and give the model one chance to correct itself
1982
+ revertAll();
1983
+ editsRes = await client.autonomousStep({
1984
+ card_id: card.id,
1985
+ action: 'generate_edits',
1986
+ files: {
1987
+ ...filesPayload,
1988
+ '__EDITS_THAT_DID_NOT_APPLY__.txt': `Your previous edits were NOT applied because some of them could not be applied, and a partial change must never ship:\n${pass.failed.join('\n')}\nReturn the complete set of edits again. Every \`search\` must be copied verbatim from the current file contents, and only files listed in \`files\` may be edited.`,
1989
+ },
1990
+ project_context: projectContext,
1991
+ });
1992
+ pass = await applyEditsPass(editsRes.edits || []);
1993
+ if (pass.failed.length > 0) {
1994
+ revertAll();
1995
+ pass = { modFiles: [], failed: pass.failed };
1996
+ }
1997
+ }
1998
+ const modifiedFiles = pass.modFiles;
1999
+ const applyFailures = pass.failed;
2000
+ // Pre-submit build gate: build the package that owns each changed front-end file (works for any monorepo layout)
2001
+ const verifyFrontendBuilds = async () => {
2002
+ for (const dir of frontendBuildDirs(projectPath, modifiedFiles)) {
2003
+ const buildCmd = fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) ? 'pnpm' : 'npm';
2004
+ const res = await exec(buildCmd, ['run', 'build'], { cwd: dir, timeoutMs: 6 * 60 * 1000 });
2005
+ if (res.timedOut) {
2006
+ // A slow build is not a compile error: do not throw the work away over it
2007
+ return { ok: true, note: `The build in \`${path.relative(projectPath, dir) || '.'}\` did not finish within 6 minutes, so it was not verified locally.` };
1194
2008
  }
1195
- catch (buildErr) {
1196
- const errOut = (String(buildErr?.stdout || '') + '\n' + String(buildErr?.stderr || '')).trim();
1197
- return { ok: false, errorOutput: errOut.slice(-1500) };
2009
+ if (!res.ok) {
2010
+ return { ok: false, errorOutput: `${res.stdout}\n${res.stderr}`.trim().slice(-1500) };
1198
2011
  }
1199
2012
  }
1200
2013
  return { ok: true };
1201
2014
  };
1202
- let buildCheck = verifyFrontendBuilds();
2015
+ let buildCheck = await verifyFrontendBuilds();
1203
2016
  if (!buildCheck.ok) {
1204
2017
  // Revert first pass, attach compiler error context, and do 1 self-correction pass
1205
- for (const [fPath, orig] of originalContents.entries()) {
1206
- try {
1207
- fs.writeFileSync(fPath, orig, 'utf8');
1208
- }
1209
- catch { }
1210
- }
2018
+ revertAll();
1211
2019
  modifiedFiles.length = 0;
1212
2020
  const retryFiles = {
1213
2021
  ...filesPayload,
@@ -1219,329 +2027,176 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1219
2027
  files: retryFiles,
1220
2028
  project_context: projectContext,
1221
2029
  });
1222
- const retryMods = applyEditsPass(editsRes.edits || []);
1223
- modifiedFiles.push(...retryMods);
1224
- buildCheck = verifyFrontendBuilds();
1225
- if (!buildCheck.ok) {
1226
- for (const [fPath, orig] of originalContents.entries()) {
1227
- try {
1228
- fs.writeFileSync(fPath, orig, 'utf8');
1229
- }
1230
- catch { }
2030
+ const retryPass = await applyEditsPass(editsRes.edits || []);
2031
+ if (retryPass.failed.length > 0) {
2032
+ revertAll();
2033
+ applyFailures.push(...retryPass.failed);
2034
+ }
2035
+ else {
2036
+ modifiedFiles.push(...retryPass.modFiles);
2037
+ buildCheck = await verifyFrontendBuilds();
2038
+ if (!buildCheck.ok) {
2039
+ revertAll();
2040
+ modifiedFiles.length = 0;
1231
2041
  }
1232
- modifiedFiles.length = 0;
1233
2042
  }
1234
2043
  }
1235
- if (modifiedFiles.length > 0) {
1236
- let rawDiff = '';
1237
- try {
1238
- const quotedFiles = modifiedFiles.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ');
1239
- rawDiff = execSync(`git diff -- ${quotedFiles}`, {
1240
- cwd: projectPath,
1241
- encoding: 'utf8',
1242
- stdio: ['ignore', 'pipe', 'ignore'],
1243
- }).trim();
1244
- }
1245
- catch { }
1246
- // Gate C: Commit, push, and run the project's defined deployment pipeline, then VERIFY it actually succeeded.
1247
- // If deploy_stage is 'done' or 'manual', only commit locally-verified changes and defer deployment to approval.
1248
- const deployStage = (card.deploy_stage || 'review').toLowerCase();
1249
- const deployInfo = deployStage === 'review'
1250
- ? await runProjectDeploymentPipeline(projectPath, card.title || 'update', card.id.slice(0, 8), false, card)
1251
- : { ok: true, commitHash: '', branch: '', deployedNote: '', errorDetail: '' };
2044
+ if (modifiedFiles.length === 0) {
2045
+ removeTaskLock(lockFile);
2046
+ revertAll();
2047
+ const buildNote = buildCheck && !buildCheck.ok && buildCheck.errorOutput
2048
+ ? `\n\nThe attempted change did not compile, so it was reverted. Last build error:\n${buildCheck.errorOutput.split('\n').filter((l) => /error/i.test(l)).slice(0, 4).join('\n').slice(0, 600)}`
2049
+ : applyFailures.length > 0
2050
+ ? `\n\nThe change I generated could not be applied cleanly, so nothing was modified:\n${applyFailures.slice(0, 4).map((f) => `- ${f}`).join('\n').slice(0, 700)}`
2051
+ : '';
2052
+ await client.askQuestion(card.id, provider, `I inspected ${Object.keys(filesPayload)
2053
+ .map((f) => `\`${f}\``)
2054
+ .join(', ')} but could not make a verified change automatically.${buildNote}\n\nCould you describe exactly where in the app this should appear (page or screen name) and what you expect to see?`);
2055
+ return false;
2056
+ }
2057
+ const commitFiles = Array.from(new Set([...carryOverFiles, ...modifiedFiles]));
2058
+ let rawDiff = '';
2059
+ try {
2060
+ rawDiff = (await exec('git', ['--literal-pathspecs', 'diff', '--', ...modifiedFiles], { cwd: projectPath, timeoutMs: 20000 })).stdout.trim();
2061
+ }
2062
+ catch { }
2063
+ const baseSummary = editsRes.summary ||
2064
+ `Updated ${modifiedFiles.join(', ')} to complete "${card.title}".`;
2065
+ const plainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() + (buildCheck.note ? `\n\n${buildCheck.note}` : '');
2066
+ const verificationSteps = Array.isArray(editsRes.verification_steps)
2067
+ ? editsRes.verification_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')
2068
+ : (typeof editsRes.verification_steps === 'string' && editsRes.verification_steps.trim() !== ''
2069
+ ? editsRes.verification_steps
2070
+ : `1. Check the live update for "${card.title}".\n2. Click **Approve & Deploy to Done** when verified.`);
2071
+ const cardState = (stage, extra = {}) => ({
2072
+ stage,
2073
+ files: commitFiles,
2074
+ summary: plainSummary,
2075
+ verification_steps: verificationSteps,
2076
+ diff: rawDiff.slice(0, 20000),
2077
+ commit: null,
2078
+ at: new Date().toISOString(),
2079
+ attempts: 1,
2080
+ ...extra,
2081
+ });
2082
+ // Gate C: commit ONLY the files changed for this card, run the project's real deployment route, and VERIFY it.
2083
+ // With deploy_stage 'done' or 'manual' the change waits (uncommitted) for approval and deploys then.
2084
+ const deployStage = (card.deploy_stage || 'review').toLowerCase();
2085
+ let deployedNote = '';
2086
+ // From here on the edits are owned by the card's saved state (deploy / approval), not by this run's revert safety net
2087
+ editsHandedOff = true;
2088
+ clearEditJournal(card.id);
2089
+ if (deployStage === 'review') {
2090
+ // Persist first: if this process dies mid-deploy, the re-queued card resumes with the deployment only
2091
+ setCardState(projectPath, card.id, cardState('deploy_failed', { last_error: 'The deployment was interrupted before it finished.' }));
2092
+ await client.addProgress(card.id, provider, `Change is ready. Deploying via **${methodLabel(profile.method)}** — ${profile.evidence[0] || 'best match found in the project'}.`);
2093
+ const deployInfo = await runDeployment({
2094
+ projectPath,
2095
+ title: card.title || 'update',
2096
+ cardId: card.id,
2097
+ files: commitFiles,
2098
+ bound: boundProfileOf(card),
2099
+ });
2100
+ await reportOutcome(client, card, projectPath, deployInfo);
1252
2101
  if (!deployInfo.ok) {
1253
- // Never claim success: keep card In Progress (waiting_for_input) with a plain-English diagnostic
1254
- try {
1255
- if (fs.existsSync(lockFile))
1256
- fs.unlinkSync(lockFile);
1257
- }
1258
- catch { }
1259
- const reason = (deployInfo.errorDetail || 'Unknown deployment error').slice(0, 700);
2102
+ // Never claim success: keep the card with the human, and remember the change so a reply retries ONLY the deployment
2103
+ setCardState(projectPath, card.id, cardState('deploy_failed', {
2104
+ commit: deployInfo.commitHash || null,
2105
+ last_error: deployInfo.errorDetail,
2106
+ pending_confirmation: deployInfo.errorKind === 'needs_confirmation' ? { method: deployInfo.method, command: deployInfo.command } : null,
2107
+ }));
2108
+ removeTaskLock(lockFile);
1260
2109
  appendIdeActivityLog(projectPath, {
1261
2110
  cardId: card.id,
1262
2111
  cardTitle: card.title,
1263
2112
  agentName: workerLabel,
1264
2113
  model: card.agent_model || 'auto',
1265
2114
  status: 'deploy_failed',
1266
- summary: `Code change was made but deployment could not be verified: ${reason}`,
2115
+ summary: `Code change was made but deployment could not be verified: ${deployInfo.errorDetail.slice(0, 700)}`,
1267
2116
  files: modifiedFiles,
1268
2117
  diff: rawDiff,
1269
2118
  });
1270
- await client.askQuestion(card.id, provider, `I made the code change (${modifiedFiles.join(', ')}), but the deployment could not be verified, so I kept this card in progress instead of moving it to Testing.\n\nWhat failed: ${reason}\n\nReply "retry" once the issue is fixed, or tell me how this project should be deployed.`);
2119
+ const changed = modifiedFiles.map((f) => `\`${f}\``).join(', ');
2120
+ await client.askQuestion(card.id, provider, deployInfo.errorKind === 'needs_confirmation'
2121
+ ? `I made the code change (${changed}). One question before I deploy this project for the first time:\n\n${describeFailureForHuman(deployInfo)}`
2122
+ : `I made the code change (${changed}), but the deployment could not be verified, so I kept this card in progress instead of moving it to Testing.\n\n${describeFailureForHuman(deployInfo)}`);
1271
2123
  return false;
1272
2124
  }
1273
- const baseSummary = editsRes.summary ||
1274
- `Updated ${modifiedFiles.join(', ')} to complete "${card.title}".`;
1275
- const cleanPlainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() +
1276
- (deployInfo.deployedNote ? `\n\n${deployInfo.deployedNote}` : '');
1277
- const verificationSteps = editsRes.verification_steps ||
1278
- `1. Check the live update for "${card.title}".\n2. Click **Approve & Deploy to Done** when verified.`;
1279
- const logLines = [
1280
- `Claimed card "${card.title}" (${card.id}) in ${projectPath}`,
1281
- `Agent: ${workerLabel} | Model: ${card.agent_model || 'auto'}`,
1282
- `Selected files: ${Object.keys(filesPayload).join(', ')}`,
1283
- `Modified & verified files: ${modifiedFiles.join(', ')}`,
1284
- ...(deployInfo.deployedNote ? [`Deployment: ${deployInfo.deployedNote}`] : []),
1285
- `Summary: ${cleanPlainSummary}`,
1286
- ];
1287
- appendIdeActivityLog(projectPath, {
1288
- cardId: card.id,
1289
- cardTitle: card.title,
1290
- agentName: workerLabel,
1291
- model: card.agent_model || 'auto',
1292
- status: 'review',
1293
- summary: cleanPlainSummary,
1294
- files: modifiedFiles,
1295
- diff: rawDiff,
1296
- });
1297
- await client.submitReview(card.id, provider, cleanPlainSummary, verificationSteps, undefined, undefined, {
1298
- diffText: rawDiff,
1299
- logText: logLines.join('\n'),
1300
- filesModified: modifiedFiles.join(', '),
1301
- });
1302
- try {
1303
- fs.writeFileSync(lockFile, JSON.stringify({
1304
- card_id: card.id,
1305
- card_updated_at: cardUpdatedAt,
1306
- status: 'completed',
1307
- completed_at: new Date().toISOString(),
1308
- }), 'utf8');
2125
+ if (deployInfo.pending && deployInfo.pendingVerification) {
2126
+ addPendingVerification(projectPath, { ...deployInfo.pendingVerification, provider });
1309
2127
  }
1310
- catch { }
1311
- sendDesktopNotification(`Agile Monster -> Ready for Review`, `Completed "${card.title}" (${modifiedFiles.length} file(s) updated) and moved to Testing / Review!`);
1312
- return true;
2128
+ setCardState(projectPath, card.id, null);
2129
+ deployedNote = deployInfo.deployedNote;
1313
2130
  }
1314
2131
  else {
1315
- try {
1316
- if (fs.existsSync(lockFile))
1317
- fs.unlinkSync(lockFile);
1318
- }
1319
- catch { }
1320
- const buildNote = buildCheck && !buildCheck.ok && buildCheck.errorOutput
1321
- ? `\n\nThe attempted change did not compile, so it was reverted. Last build error:\n${buildCheck.errorOutput.split('\n').filter((l) => /error/i.test(l)).slice(0, 4).join('\n').slice(0, 600)}`
1322
- : '';
1323
- await client.askQuestion(card.id, provider, `I inspected ${Object.keys(filesPayload)
1324
- .map((f) => `\`${f}\``)
1325
- .join(', ')} but could not make a verified change automatically.${buildNote}\n\nCould you describe exactly where in the app this should appear (page or screen name) and what you expect to see?`);
1326
- return false;
2132
+ setCardState(projectPath, card.id, cardState('awaiting_approval'));
2133
+ deployedNote =
2134
+ deployStage === 'manual'
2135
+ ? '📦 **Changes saved locally** (not committed). This project is set to manual deployment.'
2136
+ : `📦 **Changes saved locally** (not committed yet). They will be committed and deployed via ${methodLabel(profile.method)} when you approve this card.`;
1327
2137
  }
2138
+ const cleanPlainSummary = `${plainSummary}\n\n${deployedNote}`.trim();
2139
+ const logLines = [
2140
+ `Claimed card "${card.title}" (${card.id}) in ${projectPath}`,
2141
+ `Agent: ${workerLabel} | Model: ${card.agent_model || 'auto'}`,
2142
+ `Selected files: ${Object.keys(filesPayload).join(', ')}`,
2143
+ `Modified & verified files: ${modifiedFiles.join(', ')}`,
2144
+ `Deployment route: ${profile.method} (${profile.confidence} confidence, ${profile.source})`,
2145
+ `Deployment: ${deployedNote}`,
2146
+ `Summary: ${cleanPlainSummary}`,
2147
+ ];
2148
+ appendIdeActivityLog(projectPath, {
2149
+ cardId: card.id,
2150
+ cardTitle: card.title,
2151
+ agentName: workerLabel,
2152
+ model: card.agent_model || 'auto',
2153
+ status: 'review',
2154
+ summary: cleanPlainSummary,
2155
+ files: modifiedFiles,
2156
+ diff: rawDiff,
2157
+ });
2158
+ await client.submitReview(card.id, provider, cleanPlainSummary, verificationSteps, undefined, undefined, {
2159
+ diffText: rawDiff,
2160
+ logText: logLines.join('\n'),
2161
+ filesModified: modifiedFiles.join(', '),
2162
+ });
2163
+ writeTaskLock(lockFile, card, 'completed');
2164
+ sendDesktopNotification(`Agile Monster -> Ready for Review`, `Completed "${card.title}" (${modifiedFiles.length} file(s) updated) and moved to Testing / Review!`);
2165
+ log.info('card.review', { card: card.id, files: modifiedFiles.length, deploy: profile.method });
2166
+ return true;
1328
2167
  }
1329
2168
  catch (err) {
1330
- try {
1331
- if (fs.existsSync(lockFile))
1332
- fs.unlinkSync(lockFile);
2169
+ removeTaskLock(lockFile);
2170
+ // An interrupted run must not leave half-applied, uncommitted edits in the developer's working tree
2171
+ const rolledBack = !editsHandedOff && originalContents.size > 0;
2172
+ if (rolledBack) {
2173
+ for (const [fPath, orig] of originalContents.entries()) {
2174
+ try {
2175
+ fs.writeFileSync(fPath, orig, 'utf8');
2176
+ }
2177
+ catch { }
2178
+ }
1333
2179
  }
1334
- catch { }
2180
+ if (!editsHandedOff)
2181
+ clearEditJournal(card.id);
2182
+ log.error('card.failed', { card: card.id, project: projectPath, error: errorMessage(err), reverted: rolledBack });
1335
2183
  try {
1336
- await client.addProgress(card.id, provider, `Autonomous runner note: ${err?.message || String(err)}`);
2184
+ await client.addProgress(card.id, provider, `Autonomous runner note: ${err?.message || String(err)}${rolledBack ? ' (partial edits were rolled back)' : ''}`);
1337
2185
  }
1338
2186
  catch { }
1339
2187
  return false;
1340
2188
  }
1341
2189
  finally {
1342
2190
  activeAutonomousCards.delete(card.id);
2191
+ activeWork--;
1343
2192
  }
1344
2193
  }
1345
2194
  /**
1346
- * Detect and execute the project's defined deployment pipeline, then VERIFY the result:
1347
- * 1. Commits modified tracked files and pushes to `origin/<branch>`.
1348
- * 2. Runs the configured/auto-detected deploy method:
1349
- * - `deploy_script` (e.g. `./deploy/deploy.sh --skip-preflight`): requires exit code 0.
1350
- * - `github_actions`: polls `gh run list --commit <sha>` until the workflow concludes; a failed run = not deployed.
1351
- * - `docker_compose`: runs the deploy command and checks `docker compose ps` for running containers.
1352
- * 3. If a `live_url` is known, performs an HTTP smoke check (must return < 400).
1353
- * Returns ok=false with a plain-English `errorDetail` whenever deployment cannot be proven.
1354
- */
1355
- async function runProjectDeploymentPipeline(projectPath, rawTitle, shortId, forceEmptyCommit = false, profile) {
1356
- const cleanTitle = (rawTitle || 'update').replace(/["`$\\\r\n]+/g, ' ').trim();
1357
- let branch = 'main';
1358
- let commitHash = '';
1359
- let fullSha = '';
1360
- const actions = [];
1361
- const run = (cmd, timeout = 45000) => execSync(cmd, { cwd: projectPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout }).trim();
1362
- const errText = (e) => (String(e?.stderr || '') + '\n' + String(e?.stdout || '') + '\n' + String(e?.message || ''))
1363
- .split('\n')
1364
- .map((l) => l.trim())
1365
- .filter((l) => l.length > 0)
1366
- .slice(-6)
1367
- .join(' | ')
1368
- .slice(0, 700);
1369
- const detected = AgileMonsterClient.inspectProjectProfile(projectPath, path.basename(projectPath));
1370
- const deployMethod = (profile?.deploy_method || detected.deploy_method || 'git_push').toLowerCase();
1371
- const deployCommand = profile?.deploy_command || detected.deploy_command || '';
1372
- const liveUrl = profile?.live_url || detected.live_url || '';
1373
- // Step 1: commit & push
1374
- try {
1375
- branch = run('git rev-parse --abbrev-ref HEAD') || 'main';
1376
- const statusOut = run('git status --porcelain -uno');
1377
- if (statusOut.length > 0) {
1378
- run('git add -u');
1379
- run(`git commit -m "feat: ${cleanTitle} [AM-${shortId}]"`);
1380
- }
1381
- else if (forceEmptyCommit) {
1382
- run(`git commit --allow-empty -m "chore: trigger redeploy [AM-${shortId}]"`);
1383
- }
1384
- fullSha = run('git rev-parse HEAD');
1385
- commitHash = fullSha.slice(0, 7);
1386
- if (fs.existsSync(path.join(projectPath, '.git')) && run('git remote').length > 0) {
1387
- try {
1388
- run(`git push origin ${branch}`, 60000);
1389
- actions.push(`pushed \`${commitHash}\` to \`origin/${branch}\``);
1390
- }
1391
- catch (pushErr) {
1392
- return {
1393
- ok: false,
1394
- commitHash,
1395
- branch,
1396
- deployedNote: '',
1397
- errorDetail: `git push to origin/${branch} failed (check credentials or branch protection): ${errText(pushErr)}`,
1398
- };
1399
- }
1400
- }
1401
- }
1402
- catch (gitErr) {
1403
- return { ok: false, commitHash, branch, deployedNote: '', errorDetail: `Git commit failed: ${errText(gitErr)}` };
1404
- }
1405
- // Step 2: run & verify the deploy method
1406
- try {
1407
- if (deployMethod === 'deploy_script') {
1408
- const cmd = deployCommand && !deployCommand.includes('(')
1409
- ? deployCommand
1410
- : './deploy/deploy.sh';
1411
- const scriptPath = cmd.split(' ')[0];
1412
- const args = cmd.split(' ').slice(1).join(' ');
1413
- try {
1414
- run(`bash "${path.resolve(projectPath, scriptPath)}" ${args}`, 300000);
1415
- }
1416
- catch (dErr) {
1417
- return {
1418
- ok: false,
1419
- commitHash,
1420
- branch,
1421
- deployedNote: '',
1422
- errorDetail: `Deploy script \`${cmd}\` exited with an error: ${errText(dErr)}`,
1423
- };
1424
- }
1425
- actions.push(`deployed via \`${cmd}\` (exit 0)`);
1426
- }
1427
- else if (deployMethod === 'github_actions') {
1428
- let hasGh = false;
1429
- try {
1430
- run('gh --version', 5000);
1431
- hasGh = true;
1432
- }
1433
- catch { }
1434
- if (!hasGh) {
1435
- actions.push('triggered GitHub Actions (could not verify: `gh` CLI not installed)');
1436
- }
1437
- else {
1438
- const deadline = Date.now() + 8 * 60 * 1000;
1439
- let verdict = 'none';
1440
- let failedName = '';
1441
- let runUrl = '';
1442
- // Give GitHub a moment to register the workflow run
1443
- await new Promise((r) => setTimeout(r, 8000));
1444
- while (Date.now() < deadline) {
1445
- let runs = [];
1446
- try {
1447
- runs = JSON.parse(run(`gh run list --commit ${fullSha} --json status,conclusion,name,url --limit 10`, 20000) || '[]');
1448
- }
1449
- catch { }
1450
- if (runs.length === 0) {
1451
- verdict = 'none';
1452
- }
1453
- else if (runs.some((r) => r.status !== 'completed')) {
1454
- verdict = 'pending';
1455
- }
1456
- else {
1457
- const failed = runs.find((r) => r.conclusion !== 'success' && r.conclusion !== 'skipped');
1458
- verdict = failed ? 'failure' : 'success';
1459
- failedName = failed?.name || '';
1460
- runUrl = (failed || runs[0])?.url || '';
1461
- break;
1462
- }
1463
- if (verdict === 'none' && Date.now() > deadline - 7 * 60 * 1000)
1464
- break; // no workflow for this commit after ~1 min
1465
- await new Promise((r) => setTimeout(r, 15000));
1466
- }
1467
- if (verdict === 'failure') {
1468
- return {
1469
- ok: false,
1470
- commitHash,
1471
- branch,
1472
- deployedNote: '',
1473
- errorDetail: `GitHub Actions workflow "${failedName}" failed for commit ${commitHash}. ${runUrl}`.trim(),
1474
- };
1475
- }
1476
- if (verdict === 'pending') {
1477
- return {
1478
- ok: false,
1479
- commitHash,
1480
- branch,
1481
- deployedNote: '',
1482
- errorDetail: `GitHub Actions for commit ${commitHash} was still running after 8 minutes. ${runUrl}`.trim(),
1483
- };
1484
- }
1485
- actions.push(verdict === 'success'
1486
- ? `GitHub Actions passed for \`${commitHash}\``
1487
- : 'pushed (no GitHub Actions workflow ran for this commit)');
1488
- }
1489
- }
1490
- else if (deployMethod === 'docker_compose') {
1491
- const cmd = deployCommand || 'docker compose up -d --build';
1492
- try {
1493
- run(cmd, 600000);
1494
- const ps = run('docker compose ps --format json', 20000);
1495
- if (/"State"\s*:\s*"(exited|dead|restarting)"/i.test(ps)) {
1496
- return { ok: false, commitHash, branch, deployedNote: '', errorDetail: 'A Docker container is not running after deploy (exited/restarting).' };
1497
- }
1498
- }
1499
- catch (dErr) {
1500
- return { ok: false, commitHash, branch, deployedNote: '', errorDetail: `Docker deploy failed: ${errText(dErr)}` };
1501
- }
1502
- actions.push('Docker containers rebuilt and running');
1503
- }
1504
- }
1505
- catch (anyErr) {
1506
- return { ok: false, commitHash, branch, deployedNote: '', errorDetail: errText(anyErr) };
1507
- }
1508
- // Step 3: live URL smoke check
1509
- if (liveUrl && /^https?:\/\//i.test(liveUrl)) {
1510
- let status = 0;
1511
- for (let attempt = 0; attempt < 3 && (status === 0 || status >= 400); attempt++) {
1512
- try {
1513
- const ctrl = new AbortController();
1514
- const t = setTimeout(() => ctrl.abort(), 15000);
1515
- const res = await fetch(liveUrl, { method: 'GET', redirect: 'follow', signal: ctrl.signal });
1516
- clearTimeout(t);
1517
- status = res.status;
1518
- }
1519
- catch {
1520
- status = 0;
1521
- }
1522
- if (status === 0 || status >= 400)
1523
- await new Promise((r) => setTimeout(r, 5000));
1524
- }
1525
- if (status === 0 || status >= 400) {
1526
- return {
1527
- ok: false,
1528
- commitHash,
1529
- branch,
1530
- deployedNote: '',
1531
- errorDetail: `Deployment finished, but ${liveUrl} returned ${status === 0 ? 'no response' : `HTTP ${status}`}.`,
1532
- };
1533
- }
1534
- actions.push(`verified live at ${liveUrl} (HTTP ${status})`);
1535
- }
1536
- const deployedNote = actions.length > 0
1537
- ? `🚀 **Deployed**: ${actions.join(', ')}.`
1538
- : `🚀 **Deployed**: commit \`${commitHash}\` on \`${branch}\`.`;
1539
- return { ok: true, commitHash, branch, deployedNote, errorDetail: '' };
1540
- }
1541
- /**
1542
- * Deploy a card the human approved into Done (or flagged as `deploying`).
1543
- * Idempotent: keyed on card ID only (never on `updated_at`, which changes whenever we post a comment),
1544
- * so a card is deployed at most once unless the human explicitly re-triggers it into `deploying`.
2195
+ * Finish a card the human approved into Done (or flagged as `deploying`).
2196
+ * Idempotent: keyed on card ID only (never on `updated_at`, which changes whenever we post a comment).
2197
+ *
2198
+ * Approving a card that was already deployed and verified at review time only CONFIRMS it — it no longer
2199
+ * triggers a second deployment (or an empty "redeploy" commit) for every approval.
1545
2200
  */
1546
2201
  export async function deployApprovedCardIfNeeded(client, card, projectPath) {
1547
2202
  if (!projectPath || !fs.existsSync(projectPath))
@@ -1557,71 +2212,233 @@ export async function deployApprovedCardIfNeeded(client, card, projectPath) {
1557
2212
  existing = { status: 'deployed' }; // unreadable lock: fail safe (never loop)
1558
2213
  }
1559
2214
  }
1560
- const explicitRedeploy = card.agent_status === 'deploying';
2215
+ const awaitingDeploy = card.agent_status === 'deploying';
2216
+ const priorAttempts = Number(existing?.attempts || 0);
1561
2217
  if (existing) {
1562
- // Already handled and the human has not explicitly asked for a re-deploy
1563
- if (!explicitRedeploy)
2218
+ // Already handled and nobody is waiting on a deployment
2219
+ if (!awaitingDeploy)
1564
2220
  return false;
1565
- // Re-deploy request already being processed / processed for this exact request
1566
- if (existing.redeploy_of === card.updated_at || existing.status === 'running') {
1567
- const age = Date.now() - (Date.parse(existing.started_at || '') || 0);
1568
- if (existing.status !== 'running' || age < 15 * 60 * 1000)
1569
- return false;
2221
+ const age = Date.now() - (Date.parse(existing.started_at || '') || 0);
2222
+ if (existing.status === 'waiting_ci') {
2223
+ // re-check the pipeline on this pulse
2224
+ }
2225
+ else if (existing.status === 'running') {
2226
+ if (age < 30 * 60 * 1000)
2227
+ return false; // another run is (or was very recently) on it
2228
+ }
2229
+ else if (existing.status === 'failed') {
2230
+ // The card is STILL waiting. Either the human approved again (card changed -> retry now), or we never
2231
+ // managed to tell them (the API call failed): retry with a growing pause instead of going silent forever.
2232
+ if (existing.redeploy_of === card.updated_at) {
2233
+ if (priorAttempts >= 6)
2234
+ return false;
2235
+ if (age < Math.min(60, 2 ** priorAttempts) * 60 * 1000)
2236
+ return false;
2237
+ }
1570
2238
  }
2239
+ else if (age < 60 * 1000) {
2240
+ return false; // just finished; give the board a moment to reflect it
2241
+ }
2242
+ // status 'deployed' while the card still says "deploying": completing it failed last time, or the human
2243
+ // asked again. Falling through confirms it (no second deployment when it already shipped).
1571
2244
  }
1572
- // Cards that were already deployed during the Review stage only need the Done confirmation, not another deploy
1573
2245
  const deployStage = (card.deploy_stage || 'review').toLowerCase();
1574
2246
  const provider = card.agent_provider || 'cursor';
1575
- const rawText = `${card.title || ''} ${card.description || ''}`;
1576
- const isContainerRestartCard = /\b(restart|redeploy|rebuild)\b.*\b(container|docker|server|prod)/i.test(rawText);
2247
+ // Only a card that literally asks for a redeploy gets one; ordinary cards never trigger a second deployment
2248
+ const isContainerRestartCard = isRedeployOnlyCard(card);
1577
2249
  const writeLock = (status, extra = {}) => {
1578
2250
  try {
1579
2251
  fs.writeFileSync(deployLock, JSON.stringify({
1580
2252
  card_id: card.id,
1581
2253
  status,
1582
2254
  title: card.title,
1583
- redeploy_of: explicitRedeploy ? card.updated_at : null,
2255
+ redeploy_of: awaitingDeploy ? card.updated_at : null,
1584
2256
  started_at: new Date().toISOString(),
2257
+ attempts: status === 'failed' ? priorAttempts + 1 : priorAttempts,
1585
2258
  ...extra,
1586
2259
  }), 'utf8');
1587
2260
  }
1588
2261
  catch { }
1589
2262
  };
2263
+ const confirmDone = async (msg, commit) => {
2264
+ await withRetry('completeTask', () => client.completeTask(card.id, provider, msg));
2265
+ writeLock('deployed', { commit: commit || null, completed_at: new Date().toISOString() });
2266
+ setCardState(projectPath, card.id, null);
2267
+ appendIdeActivityLog(projectPath, {
2268
+ cardId: card.id,
2269
+ cardTitle: card.title,
2270
+ agentName: card.agent_display_name || provider,
2271
+ model: card.agent_model || 'auto',
2272
+ status: 'done',
2273
+ summary: msg,
2274
+ });
2275
+ };
2276
+ activeWork++;
1590
2277
  writeLock('running');
1591
2278
  try {
1592
2279
  if (deployStage === 'manual') {
2280
+ // A human ships this project: nothing is waiting on us any more
2281
+ setCardState(projectPath, card.id, null);
1593
2282
  writeLock('deployed', { note: 'manual deploy stage' });
1594
2283
  return false;
1595
2284
  }
1596
- if (deployStage === 'review' && !explicitRedeploy && !isContainerRestartCard) {
1597
- // Already deployed + verified before moving to Testing — nothing more to run.
1598
- writeLock('deployed', { note: 'deployed at review stage' });
2285
+ const memory = loadMemory(projectPath);
2286
+ const state = memory.cards[card.id] || null;
2287
+ const stillPending = memory.pending.find((p) => p.card_id === card.id) || null;
2288
+ const earlierSuccess = memory.deployed_cards[card.id] || null;
2289
+ // The deployment started at review time is still running in CI: wait for its verdict rather than deploying again
2290
+ if (stillPending && !isContainerRestartCard) {
2291
+ const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card), memory });
2292
+ const check = await checkCiOnce(projectPath, stillPending.sha, stillPending.workflow_names, profile.workflows.some((w) => w.isDeploy && w.workflowRun));
2293
+ if (check.verdict === 'success') {
2294
+ removePendingVerification(projectPath, card.id);
2295
+ recordAttempt(projectPath, { card_id: card.id, method: 'github_actions', ok: true, commit: stillPending.sha.slice(0, 7) });
2296
+ if (awaitingDeploy) {
2297
+ await confirmDone(`🚀 **Deployed**: GitHub Actions deployment passed for \`${stillPending.sha.slice(0, 7)}\`. Confirmed and moved card to **Done**.`, stillPending.sha.slice(0, 7));
2298
+ return true;
2299
+ }
2300
+ writeLock('deployed', { note: 'verified after review' });
2301
+ return false;
2302
+ }
2303
+ if (check.verdict === 'failure') {
2304
+ removePendingVerification(projectPath, card.id);
2305
+ writeLock('failed', { error: check.reason });
2306
+ await withRetry('blockTask', () => client.blockTask(card.id, provider, `⚠️ The deployment for this card failed in GitHub Actions, so it was NOT marked as deployed.\n\nWhat failed: ${check.reason || 'the deployment workflow failed'} ${check.url || ''}${check.logTail ? `\nFailing step output: ${check.logTail}` : ''}\n\nFix the issue, then approve the card again to retry.`));
2307
+ return false;
2308
+ }
2309
+ writeLock('waiting_ci', { sha: stillPending.sha });
1599
2310
  return false;
1600
2311
  }
1601
- const res = await runProjectDeploymentPipeline(projectPath, card.title || 'update', shortId, isContainerRestartCard, card);
2312
+ if (!state) {
2313
+ if (earlierSuccess) {
2314
+ // This machine already shipped the card before it reached Testing — approval just confirms it.
2315
+ if (awaitingDeploy) {
2316
+ await confirmDone(earlierSuccess.verified
2317
+ ? `🚀 **Deployed**: already live and verified at review time (commit \`${earlierSuccess.commit || 'n/a'}\`). Confirmed and moved card to **Done**.`
2318
+ : `📤 This change was already pushed at review time (commit \`${earlierSuccess.commit || 'n/a'}\`); its deployment could not be verified automatically. Moved card to **Done**.`, earlierSuccess.commit || undefined);
2319
+ return true;
2320
+ }
2321
+ writeLock('deployed', { note: 'deployed at review stage' });
2322
+ return false;
2323
+ }
2324
+ if (!awaitingDeploy) {
2325
+ // Already Done and nobody is waiting on a deployment: nothing to do.
2326
+ writeLock('deployed', { note: 'no deployment requested' });
2327
+ return false;
2328
+ }
2329
+ // Approved, but this machine has no record of deploying it (e.g. an IDE agent did the work):
2330
+ // fall through and ship what is committed. Nothing new is committed on the card's behalf.
2331
+ }
2332
+ const res = await runDeployment({
2333
+ projectPath,
2334
+ title: card.title || 'update',
2335
+ cardId: card.id,
2336
+ files: state?.files,
2337
+ redeploy: isContainerRestartCard,
2338
+ bound: boundProfileOf(card),
2339
+ });
2340
+ await reportOutcome(client, card, projectPath, res);
1602
2341
  if (!res.ok) {
1603
2342
  writeLock('failed', { error: res.errorDetail });
1604
- await client.blockTask(card.id, provider, `⚠️ Deployment could not be verified, so this card was NOT marked as deployed.\n\nWhat failed: ${res.errorDetail}\n\nFix the issue and move the card back to Done (or set it to Deploying) to retry.`);
2343
+ // Keep what is needed so a reply ("retry" / "deploy: …") redoes only the deployment
2344
+ setCardState(projectPath, card.id, {
2345
+ stage: 'deploy_failed',
2346
+ files: state?.files || [],
2347
+ summary: state?.summary || `Deployment of "${card.title}".`,
2348
+ verification_steps: state?.verification_steps || `1. Check the live update for "${card.title}".`,
2349
+ diff: state?.diff || '',
2350
+ commit: res.commitHash || null,
2351
+ at: new Date().toISOString(),
2352
+ attempts: (state?.attempts || 0) + 1,
2353
+ last_error: res.errorDetail,
2354
+ pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
2355
+ });
2356
+ await withRetry('blockTask', () => client.blockTask(card.id, provider, res.errorKind === 'needs_confirmation'
2357
+ ? `⚠️ This card is approved, but I need one answer before deploying this project for the first time.\n\n${describeFailureForHuman(res)}`
2358
+ : `⚠️ Deployment could not be verified, so this card was NOT marked as deployed.\n\n${describeFailureForHuman(res)}`));
1605
2359
  return false;
1606
2360
  }
1607
- const msg = `${res.deployedNote} Confirmed and moved card to **Done**.`;
1608
- await client.completeTask(card.id, provider, msg);
1609
- writeLock('deployed', { commit: res.commitHash, completed_at: new Date().toISOString() });
1610
- appendIdeActivityLog(projectPath, {
1611
- cardId: card.id,
1612
- cardTitle: card.title,
1613
- agentName: card.agent_display_name || provider,
1614
- model: card.agent_model || 'auto',
1615
- status: 'done',
1616
- summary: msg,
1617
- });
2361
+ if (res.pending && res.pendingVerification) {
2362
+ addPendingVerification(projectPath, { ...res.pendingVerification, provider });
2363
+ setCardState(projectPath, card.id, null);
2364
+ writeLock('waiting_ci', { sha: res.fullSha });
2365
+ await client.addProgress(card.id, provider, `${res.deployedNote} The card will move to **Done** once the pipeline passes.`);
2366
+ return false;
2367
+ }
2368
+ let uncommittedNote = '';
2369
+ if (!state?.files?.length) {
2370
+ const dirty = (await exec('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: projectPath, timeoutMs: 15000 })).stdout
2371
+ .split('\n')
2372
+ .filter((l) => l.trim()).length;
2373
+ if (dirty > 0) {
2374
+ uncommittedNote = ` Note: ${dirty} modified file(s) in the working tree are not committed, so they are NOT part of this deployment.`;
2375
+ }
2376
+ }
2377
+ await confirmDone(`${res.deployedNote}${uncommittedNote} Confirmed and moved card to **Done**.`, res.commitHash);
1618
2378
  sendDesktopNotification(`Agile Monster -> Production Confirmed`, `Deployed "${card.title}" (${res.commitHash})`);
1619
2379
  return true;
1620
2380
  }
1621
2381
  catch (err) {
2382
+ log.error('deploy.approved_failed', { card: card.id, project: projectPath, error: errorMessage(err) });
1622
2383
  writeLock('failed', { error: String(err?.message || err).slice(0, 500) });
1623
2384
  return false;
1624
2385
  }
2386
+ finally {
2387
+ activeWork--;
2388
+ }
2389
+ }
2390
+ /**
2391
+ * Deferred verification: deployments whose CI pipeline outlived our inline wait are re-checked on later
2392
+ * pulses, and the card is told the real outcome (instead of the old "still running after 8 minutes" failure).
2393
+ */
2394
+ async function processPendingVerifications(client, projectPaths) {
2395
+ for (const projectPath of projectPaths) {
2396
+ if (!projectPath || !fs.existsSync(projectPath))
2397
+ continue;
2398
+ const memory = loadMemory(projectPath);
2399
+ if (memory.pending.length === 0)
2400
+ continue;
2401
+ const profile = resolveDeployProfile(projectPath, { memory });
2402
+ const chained = profile.workflows.some((w) => w.isDeploy && w.workflowRun);
2403
+ for (const p of memory.pending) {
2404
+ try {
2405
+ const ageMs = Date.now() - (Date.parse(p.started_at) || 0);
2406
+ const check = await checkCiOnce(projectPath, p.sha, p.workflow_names, chained);
2407
+ const short = p.sha.slice(0, 7);
2408
+ if (check.verdict === 'success') {
2409
+ removePendingVerification(projectPath, p.card_id);
2410
+ recordAttempt(projectPath, { card_id: p.card_id, method: 'github_actions', ok: true, commit: short });
2411
+ await client.addProgress(p.card_id, p.provider, `🚀 **Deployed**: GitHub Actions deployment passed for \`${short}\`${check.url ? ` (${check.url})` : ''}. The change is live.`);
2412
+ await client.reportDeployment({ card_id: p.card_id, project_path: projectPath, method: 'github_actions', ok: true, commit: short, source: 'verified' });
2413
+ }
2414
+ else if (check.verdict === 'failure') {
2415
+ removePendingVerification(projectPath, p.card_id);
2416
+ const detail = `GitHub Actions: ${check.reason || 'the deployment workflow failed'} for commit ${short}. ${check.url || ''}${check.logTail ? `\nFailing step output: ${check.logTail}` : ''}`;
2417
+ recordAttempt(projectPath, { card_id: p.card_id, method: 'github_actions', ok: false, kind: 'ci_failed', error: detail, commit: short });
2418
+ setCardState(projectPath, p.card_id, {
2419
+ stage: 'deploy_failed',
2420
+ files: [],
2421
+ summary: `Deployment of "${p.card_title}" (commit ${short}).`,
2422
+ verification_steps: `1. Check the live update for "${p.card_title}".`,
2423
+ diff: '',
2424
+ commit: short,
2425
+ at: new Date().toISOString(),
2426
+ attempts: 1,
2427
+ last_error: detail,
2428
+ });
2429
+ await client.askQuestion(p.card_id, p.provider, `The deployment I started for this card did not succeed, so the change is NOT live yet.\n\nWhat failed: ${detail}\n\nFix the failing step and reply **retry**, or tell me the right route with \`deploy: <method or command>\`.`);
2430
+ await client.reportDeployment({ card_id: p.card_id, project_path: projectPath, method: 'github_actions', ok: false, error: detail.slice(0, 480), error_kind: 'ci_failed', commit: short });
2431
+ }
2432
+ else if (ageMs > 90 * 60 * 1000) {
2433
+ removePendingVerification(projectPath, p.card_id);
2434
+ await client.addProgress(p.card_id, p.provider, `I could not confirm the GitHub Actions deployment for \`${short}\` within 90 minutes${p.run_url ? ` (${p.run_url})` : ''}. Please check the pipeline before approving.`);
2435
+ }
2436
+ }
2437
+ catch (err) {
2438
+ log.warn('deploy.pending_check_failed', { card: p.card_id, project: projectPath, error: errorMessage(err) });
2439
+ }
2440
+ }
2441
+ }
1625
2442
  }
1626
2443
  /**
1627
2444
  * Machine-wide single-worker election. Only ONE process per machine may claim, edit, or deploy cards.
@@ -1639,7 +2456,7 @@ function isPidAlive(pid) {
1639
2456
  }
1640
2457
  }
1641
2458
  export function acquireWorkerLock(role) {
1642
- const lockPath = path.join(os.homedir(), '.agilemonster', 'worker.lock.json');
2459
+ const lockPath = path.join(agileMonsterHome(), 'worker.lock.json');
1643
2460
  try {
1644
2461
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
1645
2462
  let holder = null;
@@ -1672,11 +2489,17 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
1672
2489
  const notifiedCardIds = new Set();
1673
2490
  let updateTriggered = false;
1674
2491
  let pulseCount = 0;
2492
+ let pulseRunning = false;
2493
+ let holdsWorkerLock = false;
2494
+ let journalsRecovered = false;
2495
+ let consecutiveFailures = 0;
1675
2496
  const autoProvisionSavedBindings = (apiKey, bindings) => {
1676
2497
  for (const b of bindings) {
1677
2498
  const projPath = (b.project_path || '').trim();
1678
2499
  if (!projPath || !fs.existsSync(projPath))
1679
2500
  continue;
2501
+ // These files carry the personal API key: make sure no `git add -A` (ours or anyone's) can ever commit them
2502
+ ensureLocalGitExcludes(projPath);
1680
2503
  const amConfigPath = path.join(projPath, '.agilemonster.json');
1681
2504
  let needsWrite = true;
1682
2505
  if (fs.existsSync(amConfigPath)) {
@@ -1724,11 +2547,21 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
1724
2547
  if (!fs.existsSync(claudeMcp))
1725
2548
  fs.writeFileSync(claudeMcp, mcpPayload, 'utf8');
1726
2549
  }
1727
- catch { }
2550
+ catch (err) {
2551
+ log.warn('binding.provision_failed', { project: projPath, error: errorMessage(err) });
2552
+ }
1728
2553
  }
1729
2554
  }
1730
2555
  };
1731
2556
  const runPulse = async () => {
2557
+ // A pulse can run for minutes while it builds and deploys. Never start a second one on top of it —
2558
+ // just keep the worker lock fresh so no other process mistakes this one for dead and re-runs the same card.
2559
+ if (pulseRunning) {
2560
+ if (holdsWorkerLock)
2561
+ acquireWorkerLock(role);
2562
+ return;
2563
+ }
2564
+ pulseRunning = true;
1732
2565
  try {
1733
2566
  const config = client.loadProjectConfig();
1734
2567
  const apiKey = client.getApiKey();
@@ -1745,6 +2578,10 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
1745
2578
  discoveredProjects: includeDiscovery ? client.discoverLocalProjects() : undefined,
1746
2579
  discoveredModels: includeDiscovery ? client.discoverLocalAgentModels() : undefined,
1747
2580
  });
2581
+ if (consecutiveFailures > 0) {
2582
+ log.info('pulse.recovered', { after_failures: consecutiveFailures });
2583
+ consecutiveFailures = 0;
2584
+ }
1748
2585
  if (res.saved_bindings && res.saved_bindings.length > 0) {
1749
2586
  autoProvisionSavedBindings(apiKey, res.saved_bindings);
1750
2587
  }
@@ -1765,8 +2602,13 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
1765
2602
  }
1766
2603
  }
1767
2604
  // Zero-touch autonomous execution & deployment — ONLY the elected machine-wide worker may do this
1768
- const isWorker = acquireWorkerLock(role);
1769
- if (isWorker) {
2605
+ holdsWorkerLock = acquireWorkerLock(role);
2606
+ if (holdsWorkerLock && !journalsRecovered) {
2607
+ journalsRecovered = true;
2608
+ recoverEditJournals();
2609
+ pruneStaleLocks();
2610
+ }
2611
+ if (holdsWorkerLock) {
1770
2612
  const boundQueued = res.all_bound_queued_cards || [];
1771
2613
  for (const qCard of boundQueued) {
1772
2614
  const targetPath = (qCard.bound_project_path || '').trim() ||
@@ -1774,6 +2616,9 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
1774
2616
  if (targetPath && fs.existsSync(targetPath)) {
1775
2617
  await executeQueuedCardAutonomously(client, qCard, targetPath);
1776
2618
  }
2619
+ else if (targetPath) {
2620
+ log.warn('card.project_missing', { card: qCard.id, project: targetPath });
2621
+ }
1777
2622
  }
1778
2623
  const boundDone = res.all_bound_done_cards || [];
1779
2624
  for (const dCard of boundDone) {
@@ -1783,6 +2628,12 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
1783
2628
  await deployApprovedCardIfNeeded(client, dCard, targetPath);
1784
2629
  }
1785
2630
  }
2631
+ const knownPaths = new Set([config.project_path || process.cwd()]);
2632
+ for (const b of res.saved_bindings || []) {
2633
+ if (b.project_path)
2634
+ knownPaths.add(b.project_path.trim());
2635
+ }
2636
+ await processPendingVerifications(client, Array.from(knownPaths));
1786
2637
  }
1787
2638
  if ((res.version?.force_update_requested || res.version?.update_available) &&
1788
2639
  !updateTriggered &&
@@ -1791,8 +2642,16 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
1791
2642
  triggerBackgroundSelfUpdate(res.version?.latest_version);
1792
2643
  }
1793
2644
  }
1794
- catch {
1795
- // Silent during background pulse
2645
+ catch (err) {
2646
+ // Background pulses must never crash the host, but a silent failure is undiagnosable: log the first
2647
+ // one and then periodically while the outage lasts.
2648
+ consecutiveFailures++;
2649
+ if (consecutiveFailures === 1 || consecutiveFailures % 15 === 0) {
2650
+ log.error('pulse.failed', { error: errorMessage(err), consecutive: consecutiveFailures });
2651
+ }
2652
+ }
2653
+ finally {
2654
+ pulseRunning = false;
1796
2655
  }
1797
2656
  };
1798
2657
  setTimeout(runPulse, 2500).unref();
@@ -1801,6 +2660,9 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
1801
2660
  return timer;
1802
2661
  }
1803
2662
  export async function runStdioServer() {
2663
+ configureLogger({ echo: false }); // stdout is the MCP protocol channel
2664
+ addWellKnownToolDirs();
2665
+ void ensureUserPath(); // asks the login shell in the background; never delays the MCP handshake
1804
2666
  const client = new AgileMonsterClient();
1805
2667
  const server = createServer(client);
1806
2668
  const transport = new StdioServerTransport();
@@ -1808,21 +2670,39 @@ export async function runStdioServer() {
1808
2670
  startBuiltInPullPulse(server, client, 20000, 'stdio');
1809
2671
  }
1810
2672
  export async function runBackgroundPulseDaemon(intervalMs = 20000) {
2673
+ configureLogger({ echo: false });
2674
+ const addedPaths = await ensureUserPath();
1811
2675
  const client = new AgileMonsterClient();
1812
2676
  console.log(`[Agile Monster Pulse Daemon v${MCP_SERVER_VERSION}] Listening for queued agent tasks across all bound repositories (interval: ${intervalMs / 1000}s)...`);
2677
+ log.info('daemon.start', {
2678
+ version: MCP_SERVER_VERSION,
2679
+ cwd: process.cwd(),
2680
+ path_added: addedPaths,
2681
+ tools: { docker: which('docker'), gh: which('gh'), git: which('git') },
2682
+ });
2683
+ process.on('unhandledRejection', (reason) => log.error('daemon.unhandled_rejection', { error: errorMessage(reason) }));
2684
+ process.on('uncaughtException', (err) => {
2685
+ // State is undefined after an uncaught exception: record it and let launchd/systemd start a clean process.
2686
+ log.error('daemon.uncaught_exception', { error: errorMessage(err), stack: err instanceof Error ? err.stack : undefined });
2687
+ process.exit(1);
2688
+ });
1813
2689
  startBuiltInPullPulse(null, client, intervalMs, 'daemon');
1814
- const selfScript = process.argv[1] || '';
1815
- const initialMtime = selfScript && fs.existsSync(selfScript) ? fs.statSync(selfScript).mtimeMs : 0;
1816
- // Auto-restart daemon if mcp-server binary/dist on disk is updated (launchd KeepAlive respawns it cleanly)
2690
+ // Watch dist/server.js for updates so launchd reloads new code after a rebuild — but only between jobs,
2691
+ // never in the middle of an edit or a deployment.
2692
+ const distFile = fileURLToPath(import.meta.url);
2693
+ const initialMtime = fs.existsSync(distFile) ? fs.statSync(distFile).mtimeMs : 0;
2694
+ let reloadPending = false;
1817
2695
  setInterval(() => {
1818
2696
  try {
1819
- if (selfScript && initialMtime > 0 && fs.existsSync(selfScript)) {
1820
- const curMtime = fs.statSync(selfScript).mtimeMs;
1821
- if (curMtime > initialMtime + 1000) {
1822
- process.exit(0);
2697
+ if (!reloadPending && initialMtime > 0 && fs.existsSync(distFile)) {
2698
+ const curMtime = fs.statSync(distFile).mtimeMs;
2699
+ if (curMtime > initialMtime + 500) {
2700
+ reloadPending = true;
2701
+ console.log(`[Agile Monster Pulse Daemon] dist/server.js updated on disk. Reloading new code once idle...`);
2702
+ exitWhenIdle('dist-updated');
1823
2703
  }
1824
2704
  }
1825
2705
  }
1826
2706
  catch { }
1827
- }, 30000);
2707
+ }, 10000);
1828
2708
  }