@agilemonster/mcp-server 1.0.15 → 1.0.21

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,15 +965,32 @@ function syncIdeTaskRules(boardName, queuedCards) {
761
965
  }
762
966
  catch { }
763
967
  }
764
- function triggerBackgroundSelfUpdate() {
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
+ }
982
+ function triggerBackgroundSelfUpdate(targetVersion) {
765
983
  try {
766
- // 1. Pre-warm latest @agilemonster/mcp-server in npx cache
767
- const child = spawn('npx', ['-y', '@agilemonster/mcp-server@latest', '--version'], {
768
- stdio: 'ignore',
769
- detached: true,
770
- });
771
- child.unref();
772
- // 2. Ensure local .cursor/mcp.json and .mcp.json use @latest so next IDE reload uses latest
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 });
993
+ // 1. Ensure local .cursor/mcp.json and .mcp.json use @latest so next IDE reload uses latest
773
994
  const cwd = process.cwd();
774
995
  for (const rel of ['.cursor/mcp.json', '.mcp.json', '.vscode/mcp.json']) {
775
996
  const p = path.join(cwd, rel);
@@ -784,36 +1005,150 @@ function triggerBackgroundSelfUpdate() {
784
1005
  catch { }
785
1006
  }
786
1007
  }
1008
+ // 2. Install @agilemonster/mcp-server@latest into global/npx cache, then exit once idle so launchd respawns the daemon
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'], {
1010
+ stdio: 'ignore',
1011
+ detached: true,
1012
+ });
1013
+ child.on('exit', () => {
1014
+ appendIdeActivityLog(cwd, {
1015
+ cardId: 'system-update',
1016
+ cardTitle: 'MCP Daemon Auto-Update',
1017
+ agentName: 'Agile Monster Daemon',
1018
+ model: 'system',
1019
+ status: 'updated',
1020
+ summary: `Updated @agilemonster/mcp-server from v${MCP_SERVER_VERSION} to v${target} and restarting the daemon.`,
1021
+ });
1022
+ setTimeout(() => exitWhenIdle('self-update'), 1500).unref();
1023
+ });
1024
+ child.unref();
1025
+ }
1026
+ catch (err) {
1027
+ log.warn('daemon.self_update_failed', { error: errorMessage(err) });
1028
+ }
1029
+ }
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) {
1035
+ const sections = [];
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) {
1050
+ const docPath = path.join(projectPath, docName);
1051
+ if (fs.existsSync(docPath)) {
1052
+ try {
1053
+ const raw = fs.readFileSync(docPath, 'utf8').trim();
1054
+ if (raw.length > 0) {
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)}`);
1086
+ }
1087
+ }
1088
+ catch { }
1089
+ }
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 { }
1102
+ }
1103
+ try {
1104
+ const recentCommits = execSync('git log -n 8 --oneline', {
1105
+ cwd: projectPath,
1106
+ encoding: 'utf8',
1107
+ stdio: ['ignore', 'pipe', 'ignore'],
1108
+ timeout: 5000,
1109
+ }).trim();
1110
+ if (recentCommits) {
1111
+ sections.push(`=== Recent Git Commits ===\n${recentCommits}`);
1112
+ }
787
1113
  }
788
1114
  catch { }
1115
+ return sections.join('\n\n').slice(0, maxTotalChars);
789
1116
  }
790
1117
  const activeAutonomousCards = new Set();
791
- function collectProjectSourceFiles(projectPath, keywords) {
792
- const skipDirs = new Set([
793
- 'node_modules',
794
- '.git',
795
- 'dist',
796
- 'build',
797
- '.next',
798
- '.nuxt',
799
- '.dart_tool',
800
- 'vendor',
801
- 'coverage',
802
- 'ios',
803
- 'android',
804
- 'macos',
805
- 'linux',
806
- 'windows',
807
- 'web',
808
- 'public',
809
- 'assets',
810
- 'archive',
811
- 'backups',
812
- ]);
813
- const validExts = new Set(['.tsx', '.ts', '.jsx', '.js', '.dart', '.php', '.py', '.css', '.scss', '.html', '.vue']);
814
- 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 = [];
815
1150
  const walk = (dir, depth) => {
816
- if (depth > 6 || results.length > 600)
1151
+ if (depth > 8 || found.length > 20000)
817
1152
  return;
818
1153
  let entries = [];
819
1154
  try {
@@ -823,67 +1158,157 @@ function collectProjectSourceFiles(projectPath, keywords) {
823
1158
  return;
824
1159
  }
825
1160
  for (const entry of entries) {
826
- const name = entry.name;
827
- if (name.startsWith('.'))
828
- continue;
829
- if (/backup|copy|\s\d+/i.test(name))
1161
+ if (entry.name.startsWith('.'))
830
1162
  continue;
831
- const full = path.join(dir, name);
1163
+ const full = path.join(dir, entry.name);
832
1164
  if (entry.isDirectory()) {
833
- if (skipDirs.has(name.toLowerCase()))
834
- continue;
835
- walk(full, depth + 1);
1165
+ if (!SKIP_DIRS.has(entry.name.toLowerCase()))
1166
+ walk(full, depth + 1);
836
1167
  }
837
1168
  else if (entry.isFile()) {
838
- const ext = path.extname(name).toLowerCase();
839
- if (!validExts.has(ext))
840
- continue;
841
- 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)
842
1196
  continue;
843
- const rel = path.relative(projectPath, full).replace(/\\/g, '/');
844
- const lowerRel = rel.toLowerCase();
845
- 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();
846
1287
  for (const kw of keywords) {
847
- if (kw.length >= 3 && lowerRel.includes(kw)) {
848
- score += 25;
849
- }
850
- }
851
- // Check file content for keyword hits on promising source files
852
- if (ext === '.tsx' || ext === '.ts' || ext === '.dart' || ext === '.php' || ext === '.jsx') {
853
- try {
854
- const stat = fs.statSync(full);
855
- if (stat.size < 120000) {
856
- const contentLower = fs.readFileSync(full, 'utf8').toLowerCase();
857
- for (const kw of keywords) {
858
- if (kw.length >= 4 && contentLower.includes(kw)) {
859
- score += 10;
860
- }
861
- }
862
- }
863
- }
864
- catch { }
865
- }
866
- // Bonus score for UI components / screens AND backend controllers / models
867
- if (ext === '.tsx' || ext === '.jsx' || ext === '.dart' || ext === '.vue') {
868
- if (lowerRel.includes('component') || lowerRel.includes('screen') || lowerRel.includes('widget') || lowerRel.includes('modal') || lowerRel.includes('model')) {
869
- score += 8;
870
- }
1288
+ if (kw.length >= 4 && contentLower.includes(kw))
1289
+ c.score += 10;
871
1290
  }
872
- if (ext === '.php' || ext === '.py' || ext === '.ts') {
873
- if (lowerRel.includes('controller') || lowerRel.includes('service') || lowerRel.includes('route')) {
874
- score += 10;
875
- }
876
- }
877
- results.push({ rel, score });
878
1291
  }
879
1292
  }
880
- };
881
- walk(projectPath, 0);
882
- results.sort((a, b) => b.score - a.score || a.rel.localeCompare(b.rel));
883
- 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 { }
884
1309
  }
885
1310
  function getAgileMonsterLocksDir() {
886
- const dir = path.join(os.homedir(), '.agilemonster', 'locks');
1311
+ const dir = path.join(agileMonsterHome(), 'locks');
887
1312
  try {
888
1313
  if (!fs.existsSync(dir)) {
889
1314
  fs.mkdirSync(dir, { recursive: true });
@@ -894,9 +1319,8 @@ function getAgileMonsterLocksDir() {
894
1319
  }
895
1320
  /**
896
1321
  * Append a live, human-readable IDE activity entry to `<projectPath>/.agilemonster/ACTIVITY.md`
897
- * (and exclude `.agilemonster/` in `.git/info/exclude` so it never pollutes `git status`).
898
- * Developers can open `.agilemonster/ACTIVITY.md` in their IDE at any time to see full agent
899
- * 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.
900
1324
  */
901
1325
  function appendIdeActivityLog(projectPath, entry) {
902
1326
  try {
@@ -904,13 +1328,7 @@ function appendIdeActivityLog(projectPath, entry) {
904
1328
  if (!fs.existsSync(amDir)) {
905
1329
  fs.mkdirSync(amDir, { recursive: true });
906
1330
  }
907
- const gitExclude = path.join(projectPath, '.git', 'info', 'exclude');
908
- if (fs.existsSync(gitExclude)) {
909
- const exContent = fs.readFileSync(gitExclude, 'utf8');
910
- if (!exContent.includes('.agilemonster/')) {
911
- fs.appendFileSync(gitExclude, '\n.agilemonster/\n.agilemonster.json\n', 'utf8');
912
- }
913
- }
1331
+ ensureLocalGitExcludes(projectPath);
914
1332
  const logPath = path.join(amDir, 'ACTIVITY.md');
915
1333
  const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
916
1334
  const header = !fs.existsSync(logPath)
@@ -933,49 +1351,444 @@ function appendIdeActivityLog(projectPath, entry) {
933
1351
  }
934
1352
  catch { }
935
1353
  }
936
- export async function executeQueuedCardAutonomously(client, card, projectPath) {
937
- if (!projectPath || !fs.existsSync(projectPath))
938
- return false;
939
- if (activeAutonomousCards.has(card.id))
940
- return false;
941
- const lockFile = path.join(getAgileMonsterLocksDir(), `task-${card.id.slice(0, 8)}.lock`);
942
- const cardUpdatedAt = card.updated_at || '';
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) {
943
1390
  try {
944
- if (fs.existsSync(lockFile)) {
945
- const stat = fs.statSync(lockFile);
946
- let existingLock = {};
1391
+ const files = {};
1392
+ for (const [abs, original] of originals.entries()) {
1393
+ let current = original;
947
1394
  try {
948
- existingLock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
1395
+ current = fs.readFileSync(abs, 'utf8');
949
1396
  }
950
1397
  catch { }
951
- const sameRevision = cardUpdatedAt !== '' && existingLock?.card_updated_at === cardUpdatedAt;
952
- if (existingLock?.status === 'running' && Date.now() - stat.mtimeMs < 3 * 60 * 1000 && sameRevision) {
953
- return false; // Currently running on this exact revision
954
- }
955
- if (existingLock?.status === 'completed' && sameRevision && Date.now() - stat.mtimeMs < 10 * 60 * 1000) {
956
- return false; // Already completed this exact revision
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;
957
1430
  }
958
- if (!cardUpdatedAt && Date.now() - stat.mtimeMs < 3 * 60 * 1000) {
959
- return false;
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 { }
960
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 });
961
1444
  }
962
- fs.writeFileSync(lockFile, JSON.stringify({
963
- card_id: card.id,
964
- card_updated_at: cardUpdatedAt,
965
- status: 'running',
966
- started_at: new Date().toISOString(),
967
- }), 'utf8');
968
1445
  }
969
- catch {
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)}`);
970
1521
  return false;
971
1522
  }
972
- activeAutonomousCards.add(card.id);
973
- const provider = card.agent_provider || 'cursor';
974
- const workerLabel = card.agent_display_name || provider;
975
- try {
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
+ }
1730
+ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1731
+ if (!projectPath || !fs.existsSync(projectPath))
1732
+ return false;
1733
+ if (activeAutonomousCards.has(card.id))
1734
+ return false;
1735
+ const lockFile = path.join(getAgileMonsterLocksDir(), `task-${card.id.slice(0, 8)}.lock`);
1736
+ const cardUpdatedAt = card.updated_at || '';
1737
+ try {
1738
+ if (fs.existsSync(lockFile)) {
1739
+ const stat = fs.statSync(lockFile);
1740
+ let existingLock = {};
1741
+ try {
1742
+ existingLock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
1743
+ }
1744
+ catch { }
1745
+ const sameRevision = cardUpdatedAt !== '' && existingLock?.card_updated_at === cardUpdatedAt;
1746
+ if (existingLock?.status === 'running' && Date.now() - stat.mtimeMs < 3 * 60 * 1000 && sameRevision) {
1747
+ return false; // Currently running on this exact revision
1748
+ }
1749
+ if (existingLock?.status === 'completed' && sameRevision && Date.now() - stat.mtimeMs < 10 * 60 * 1000) {
1750
+ return false; // Already completed this exact revision
1751
+ }
1752
+ if (!cardUpdatedAt && Date.now() - stat.mtimeMs < 3 * 60 * 1000) {
1753
+ return false;
1754
+ }
1755
+ }
1756
+ writeTaskLock(lockFile, card, 'running');
1757
+ }
1758
+ catch {
1759
+ return false;
1760
+ }
1761
+ activeAutonomousCards.add(card.id);
1762
+ activeWork++;
1763
+ const provider = card.agent_provider || 'cursor';
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 });
1769
+ try {
976
1770
  // 1. Claim the card (automatically moves it from ToDo -> In Progress on the board!)
977
1771
  await client.claimTask(card.id, provider, `auto-pulse-${Date.now()}`, card.board_id);
978
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) });
979
1792
  await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. Scanning repository files...`);
980
1793
  // 2. Build keyword list from card title + description and collect project source files
981
1794
  const rawText = `${card.title || ''} ${card.description || ''}`.toLowerCase();
@@ -985,16 +1798,22 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
985
1798
  .filter((w) => w.length >= 3 && !stopWords.has(w))));
986
1799
  const fileIndex = collectProjectSourceFiles(projectPath, keywords);
987
1800
  if (fileIndex.length === 0) {
988
- 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.`);
989
1803
  return false;
990
1804
  }
991
1805
  // 3. Ask Agile Monster Autonomous Step to pick the 1-6 most relevant files
1806
+ const projectContext = readProjectInstructionsContext(projectPath, profile);
992
1807
  const selectionRes = await client.autonomousStep({
993
1808
  card_id: card.id,
994
1809
  action: 'select_files',
995
1810
  file_index: fileIndex,
1811
+ project_context: projectContext,
996
1812
  });
997
- 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);
998
1817
  if (selectedFiles.length === 0) {
999
1818
  selectedFiles.push(...fileIndex.slice(0, 4));
1000
1819
  }
@@ -1017,12 +1836,17 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1017
1836
  const full = path.join(projectPath, rel);
1018
1837
  if (fs.existsSync(full)) {
1019
1838
  try {
1020
- 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');
1021
1843
  }
1022
1844
  catch { }
1023
1845
  }
1024
1846
  }
1025
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?');
1026
1850
  return false;
1027
1851
  }
1028
1852
  await client.addProgress(card.id, provider, `Selected target files for implementation: ${Object.keys(filesPayload)
@@ -1033,117 +1857,165 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1033
1857
  card_id: card.id,
1034
1858
  action: 'generate_edits',
1035
1859
  files: filesPayload,
1860
+ project_context: projectContext,
1036
1861
  });
1037
- const applyEditsPass = (editsList) => {
1862
+ // Gate A: Strict truth — if the model is not confident / cannot find the target, ask the human instead of guessing
1863
+ if (editsRes.needs_clarification && (editsRes.clarification_question || '').trim() !== '') {
1864
+ removeTaskLock(lockFile);
1865
+ appendIdeActivityLog(projectPath, {
1866
+ cardId: card.id,
1867
+ cardTitle: card.title,
1868
+ agentName: workerLabel,
1869
+ model: card.agent_model || 'auto',
1870
+ status: 'waiting_for_input',
1871
+ summary: `Needs clarification: ${editsRes.clarification_question}`,
1872
+ files: Object.keys(filesPayload),
1873
+ });
1874
+ await client.askQuestion(card.id, provider, (editsRes.clarification_question || '').trim());
1875
+ return false;
1876
+ }
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) => {
1038
1885
  const modFiles = [];
1886
+ const failed = [];
1039
1887
  for (const edit of editsList) {
1040
- const relPath = edit.file || edit.path;
1041
- 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`);
1042
1891
  continue;
1043
- const fullPath = path.resolve(projectPath, relPath);
1044
- if (!fullPath.startsWith(path.resolve(projectPath)))
1892
+ }
1893
+ if (!editableFiles.has(relPath)) {
1894
+ failed.push(`${relPath}: not one of the files provided`);
1045
1895
  continue;
1046
- 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`);
1047
1905
  continue;
1906
+ }
1048
1907
  const currentContent = fs.readFileSync(fullPath, 'utf8');
1049
- 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))
1050
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
+ }
1051
1936
  }
1052
- if (currentContent.includes(edit.search)) {
1053
- const updatedContent = currentContent.replace(edit.search, edit.replace);
1054
- if (updatedContent !== currentContent) {
1055
- const countChar = (s, ch) => s.split(ch).length - 1;
1056
- const searchBraceDelta = countChar(edit.search, '{') - countChar(edit.search, '}');
1057
- const replaceBraceDelta = countChar(edit.replace, '{') - countChar(edit.replace, '}');
1058
- const searchParenDelta = countChar(edit.search, '(') - countChar(edit.search, ')');
1059
- const replaceParenDelta = countChar(edit.replace, '(') - countChar(edit.replace, ')');
1060
- if (searchBraceDelta !== replaceBraceDelta || searchParenDelta !== replaceParenDelta) {
1061
- continue;
1062
- }
1063
- fs.writeFileSync(fullPath, updatedContent, 'utf8');
1064
- if (relPath.endsWith('.dart')) {
1065
- try {
1066
- execSync(`dart analyze "${fullPath}"`, {
1067
- cwd: projectPath,
1068
- stdio: ['ignore', 'pipe', 'pipe'],
1069
- timeout: 25000,
1070
- });
1071
- }
1072
- catch (analyzeErr) {
1073
- const out = String(analyzeErr?.stdout || '') + String(analyzeErr?.stderr || '');
1074
- if (/\berror\s+[•-]/i.test(out)) {
1075
- fs.writeFileSync(fullPath, currentContent, 'utf8');
1076
- continue;
1077
- }
1078
- }
1079
- }
1080
- else if (relPath.endsWith('.php')) {
1081
- try {
1082
- execSync(`php -l "${fullPath}"`, {
1083
- cwd: projectPath,
1084
- stdio: ['ignore', 'pipe', 'pipe'],
1085
- timeout: 10000,
1086
- });
1087
- }
1088
- catch {
1089
- fs.writeFileSync(fullPath, currentContent, 'utf8');
1090
- continue;
1091
- }
1092
- }
1093
- if (!modFiles.includes(relPath)) {
1094
- modFiles.push(relPath);
1095
- }
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;
1096
1944
  }
1097
1945
  }
1946
+ if (!modFiles.includes(relPath))
1947
+ modFiles.push(relPath);
1098
1948
  }
1099
- return modFiles;
1949
+ return { modFiles, failed };
1100
1950
  };
1101
- const originalContents = new Map();
1102
- const modifiedFiles = applyEditsPass(editsRes.edits || []);
1103
- // Pre-submit frontend build verification gate for root AND monorepo subdirectories (frontend/, web/, client/)
1104
- const verifyFrontendBuilds = () => {
1105
- const hasFrontendEdits = modifiedFiles.some((f) => /\.(tsx?|jsx?|vue|css)$/i.test(f));
1106
- if (!hasFrontendEdits)
1107
- return { ok: true };
1108
- const candidateDirs = [
1109
- projectPath,
1110
- path.join(projectPath, 'frontend'),
1111
- path.join(projectPath, 'web'),
1112
- path.join(projectPath, 'client'),
1113
- ];
1114
- for (const dir of candidateDirs) {
1115
- const pkgJsonPath = path.join(dir, 'package.json');
1116
- if (!fs.existsSync(pkgJsonPath) || !fs.existsSync(path.join(dir, 'node_modules')))
1117
- continue;
1951
+ const revertAll = () => {
1952
+ for (const [fPath, orig] of originalContents.entries()) {
1118
1953
  try {
1119
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
1120
- if (pkg?.scripts?.build) {
1121
- const buildCmd = fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))
1122
- ? 'pnpm run build'
1123
- : 'npm run build';
1124
- execSync(buildCmd, {
1125
- cwd: dir,
1126
- stdio: ['ignore', 'pipe', 'pipe'],
1127
- timeout: 60000,
1128
- });
1129
- }
1954
+ fs.writeFileSync(fPath, orig, 'utf8');
1130
1955
  }
1131
- catch (buildErr) {
1132
- const errOut = (String(buildErr?.stdout || '') + '\n' + String(buildErr?.stderr || '')).trim();
1133
- return { ok: false, errorOutput: errOut.slice(-1500) };
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.` };
2008
+ }
2009
+ if (!res.ok) {
2010
+ return { ok: false, errorOutput: `${res.stdout}\n${res.stderr}`.trim().slice(-1500) };
1134
2011
  }
1135
2012
  }
1136
2013
  return { ok: true };
1137
2014
  };
1138
- let buildCheck = verifyFrontendBuilds();
2015
+ let buildCheck = await verifyFrontendBuilds();
1139
2016
  if (!buildCheck.ok) {
1140
2017
  // Revert first pass, attach compiler error context, and do 1 self-correction pass
1141
- for (const [fPath, orig] of originalContents.entries()) {
1142
- try {
1143
- fs.writeFileSync(fPath, orig, 'utf8');
1144
- }
1145
- catch { }
1146
- }
2018
+ revertAll();
1147
2019
  modifiedFiles.length = 0;
1148
2020
  const retryFiles = {
1149
2021
  ...filesPayload,
@@ -1153,228 +2025,245 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1153
2025
  card_id: card.id,
1154
2026
  action: 'generate_edits',
1155
2027
  files: retryFiles,
2028
+ project_context: projectContext,
1156
2029
  });
1157
- const retryMods = applyEditsPass(editsRes.edits || []);
1158
- modifiedFiles.push(...retryMods);
1159
- buildCheck = verifyFrontendBuilds();
1160
- if (!buildCheck.ok) {
1161
- for (const [fPath, orig] of originalContents.entries()) {
1162
- try {
1163
- fs.writeFileSync(fPath, orig, 'utf8');
1164
- }
1165
- 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;
1166
2041
  }
1167
- modifiedFiles.length = 0;
1168
2042
  }
1169
2043
  }
1170
- if (modifiedFiles.length > 0) {
1171
- let rawDiff = '';
1172
- try {
1173
- const quotedFiles = modifiedFiles.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ');
1174
- rawDiff = execSync(`git diff -- ${quotedFiles}`, {
1175
- cwd: projectPath,
1176
- encoding: 'utf8',
1177
- stdio: ['ignore', 'pipe', 'ignore'],
1178
- }).trim();
1179
- }
1180
- catch { }
1181
- // Automatically commit, push, and run the project's defined deployment pipeline (e.g. deploy/deploy.sh, GitHub Actions, or Docker)
1182
- // so changes are live for testing as soon as the card moves to Testing / QA!
1183
- const deployInfo = runProjectDeploymentPipeline(projectPath, card.title || 'update', card.id.slice(0, 8));
1184
- const baseSummary = editsRes.summary ||
1185
- `Updated ${modifiedFiles.join(', ')} to complete "${card.title}".`;
1186
- const cleanPlainSummary = baseSummary.replace(/```[\s\S]*?```/g, '').trim() +
1187
- (deployInfo.deployedNote ? `\n\n${deployInfo.deployedNote}` : '');
1188
- const verificationSteps = editsRes.verification_steps ||
1189
- `1. Check the live update for "${card.title}".\n2. Click **Approve & Deploy to Done** when verified.`;
1190
- const logLines = [
1191
- `Claimed card "${card.title}" (${card.id}) in ${projectPath}`,
1192
- `Agent: ${workerLabel} | Model: ${card.agent_model || 'auto'}`,
1193
- `Selected files: ${Object.keys(filesPayload).join(', ')}`,
1194
- `Modified & verified files: ${modifiedFiles.join(', ')}`,
1195
- ...(deployInfo.deployedNote ? [`Deployment: ${deployInfo.deployedNote}`] : []),
1196
- `Summary: ${cleanPlainSummary}`,
1197
- ];
1198
- appendIdeActivityLog(projectPath, {
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',
1199
2096
  cardId: card.id,
1200
- cardTitle: card.title,
1201
- agentName: workerLabel,
1202
- model: card.agent_model || 'auto',
1203
- status: 'review',
1204
- summary: cleanPlainSummary,
1205
- files: modifiedFiles,
1206
- diff: rawDiff,
2097
+ files: commitFiles,
2098
+ bound: boundProfileOf(card),
1207
2099
  });
1208
- await client.submitReview(card.id, provider, cleanPlainSummary, verificationSteps, undefined, undefined, {
1209
- diffText: rawDiff,
1210
- logText: logLines.join('\n'),
1211
- filesModified: modifiedFiles.join(', '),
1212
- });
1213
- try {
1214
- fs.writeFileSync(lockFile, JSON.stringify({
1215
- card_id: card.id,
1216
- card_updated_at: cardUpdatedAt,
1217
- status: 'completed',
1218
- completed_at: new Date().toISOString(),
1219
- }), 'utf8');
2100
+ await reportOutcome(client, card, projectPath, deployInfo);
2101
+ if (!deployInfo.ok) {
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);
2109
+ appendIdeActivityLog(projectPath, {
2110
+ cardId: card.id,
2111
+ cardTitle: card.title,
2112
+ agentName: workerLabel,
2113
+ model: card.agent_model || 'auto',
2114
+ status: 'deploy_failed',
2115
+ summary: `Code change was made but deployment could not be verified: ${deployInfo.errorDetail.slice(0, 700)}`,
2116
+ files: modifiedFiles,
2117
+ diff: rawDiff,
2118
+ });
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)}`);
2123
+ return false;
1220
2124
  }
1221
- catch { }
1222
- sendDesktopNotification(`Agile Monster -> Ready for Review`, `Completed "${card.title}" (${modifiedFiles.length} file(s) updated) and moved to Testing / Review!`);
1223
- return true;
2125
+ if (deployInfo.pending && deployInfo.pendingVerification) {
2126
+ addPendingVerification(projectPath, { ...deployInfo.pendingVerification, provider });
2127
+ }
2128
+ setCardState(projectPath, card.id, null);
2129
+ deployedNote = deployInfo.deployedNote;
1224
2130
  }
1225
2131
  else {
1226
- try {
1227
- if (fs.existsSync(lockFile))
1228
- fs.unlinkSync(lockFile);
1229
- }
1230
- catch { }
1231
- await client.askQuestion(card.id, provider, `Inspected ${Object.keys(filesPayload)
1232
- .map((f) => `\`${f}\``)
1233
- .join(', ')}, but needed more specific details to apply a verified code change automatically. Reply in the comments with additional details (or open your IDE agent) to continue!`);
1234
- 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.`;
1235
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;
1236
2167
  }
1237
2168
  catch (err) {
1238
- try {
1239
- if (fs.existsSync(lockFile))
1240
- 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
+ }
1241
2179
  }
1242
- catch { }
2180
+ if (!editsHandedOff)
2181
+ clearEditJournal(card.id);
2182
+ log.error('card.failed', { card: card.id, project: projectPath, error: errorMessage(err), reverted: rolledBack });
1243
2183
  try {
1244
- 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)' : ''}`);
1245
2185
  }
1246
2186
  catch { }
1247
2187
  return false;
1248
2188
  }
1249
2189
  finally {
1250
2190
  activeAutonomousCards.delete(card.id);
2191
+ activeWork--;
1251
2192
  }
1252
2193
  }
1253
2194
  /**
1254
- * Detect and execute the project's defined deployment pipeline:
1255
- * 1. Commits any modified tracked files and pushes to `origin/<branch>`.
1256
- * 2. Runs `deploy/deploy.sh` (or `scripts/deploy.sh`) if present in the repo (e.g. Meerlink cPanel SSH deploy).
1257
- * 3. Or triggers GitHub Actions CI/CD via `git push`.
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.
1258
2200
  */
1259
- function runProjectDeploymentPipeline(projectPath, rawTitle, shortId, forceEmptyCommit = false) {
1260
- const cleanTitle = (rawTitle || 'update').replace(/["`\r\n]+/g, ' ').trim();
1261
- let branch = 'main';
1262
- let commitHash = '';
1263
- const actions = [];
1264
- try {
1265
- branch = execSync('git rev-parse --abbrev-ref HEAD', {
1266
- cwd: projectPath,
1267
- encoding: 'utf8',
1268
- stdio: ['ignore', 'pipe', 'ignore'],
1269
- }).trim() || 'main';
1270
- const statusOut = execSync('git status --porcelain -uno', {
1271
- cwd: projectPath,
1272
- encoding: 'utf8',
1273
- stdio: ['ignore', 'pipe', 'ignore'],
1274
- }).trim();
1275
- if (statusOut.length > 0) {
1276
- execSync('git add -u', { cwd: projectPath, stdio: 'ignore' });
1277
- execSync(`git commit -m "feat: ${cleanTitle} [AM-${shortId}]"`, {
1278
- cwd: projectPath,
1279
- stdio: 'ignore',
1280
- });
1281
- execSync(`git push origin ${branch}`, {
1282
- cwd: projectPath,
1283
- stdio: 'ignore',
1284
- timeout: 45000,
1285
- });
1286
- commitHash = execSync('git rev-parse --short HEAD', {
1287
- cwd: projectPath,
1288
- encoding: 'utf8',
1289
- stdio: ['ignore', 'pipe', 'ignore'],
1290
- }).trim();
1291
- actions.push(`pushed \`${commitHash}\` to \`origin/${branch}\``);
1292
- }
1293
- else if (forceEmptyCommit) {
1294
- execSync(`git commit --allow-empty -m "chore: trigger container redeploy [AM-${shortId}]"`, {
1295
- cwd: projectPath,
1296
- stdio: 'ignore',
1297
- });
1298
- execSync(`git push origin ${branch}`, {
1299
- cwd: projectPath,
1300
- stdio: 'ignore',
1301
- timeout: 45000,
1302
- });
1303
- commitHash = execSync('git rev-parse --short HEAD', {
1304
- cwd: projectPath,
1305
- encoding: 'utf8',
1306
- stdio: ['ignore', 'pipe', 'ignore'],
1307
- }).trim();
1308
- actions.push(`triggered redeploy commit \`${commitHash}\` on \`origin/${branch}\``);
1309
- }
1310
- else {
1311
- commitHash = execSync('git rev-parse --short HEAD', {
1312
- cwd: projectPath,
1313
- encoding: 'utf8',
1314
- stdio: ['ignore', 'pipe', 'ignore'],
1315
- }).trim();
1316
- }
1317
- // Check if the project has a dedicated deploy script (e.g. deploy/deploy.sh in Meerlink)
1318
- const deployScript = path.join(projectPath, 'deploy', 'deploy.sh');
1319
- if (fs.existsSync(deployScript)) {
1320
- const scriptContent = fs.readFileSync(deployScript, 'utf8');
1321
- const flag = scriptContent.includes('--skip-preflight') ? ' --skip-preflight' : '';
1322
- execSync(`bash "${deployScript}"${flag}`, {
1323
- cwd: projectPath,
1324
- stdio: ['ignore', 'pipe', 'pipe'],
1325
- timeout: 180000,
1326
- });
1327
- actions.push('deployed live via `deploy/deploy.sh`');
1328
- }
1329
- else if (fs.existsSync(path.join(projectPath, '.github', 'workflows'))) {
1330
- actions.push('triggered GitHub Actions CI/CD deployment');
1331
- }
1332
- const deployedNote = actions.length > 0
1333
- ? `🚀 **Deployed**: Automatically ${actions.join(' and ')}.`
1334
- : `🚀 **Deployed**: Verified commit \`${commitHash}\` is live on \`origin/${branch}\`.`;
1335
- return { ok: true, commitHash, branch, deployedNote };
1336
- }
1337
- catch (err) {
1338
- return {
1339
- ok: false,
1340
- commitHash,
1341
- branch,
1342
- deployedNote: '',
1343
- };
1344
- }
1345
- }
1346
2201
  export async function deployApprovedCardIfNeeded(client, card, projectPath) {
1347
2202
  if (!projectPath || !fs.existsSync(projectPath))
1348
2203
  return false;
1349
2204
  const shortId = card.id.slice(0, 8);
1350
2205
  const deployLock = path.join(getAgileMonsterLocksDir(), `deployed-${shortId}.json`);
1351
- const cardUpdatedAt = card.updated_at || '';
2206
+ let existing = null;
1352
2207
  if (fs.existsSync(deployLock)) {
1353
2208
  try {
1354
- const existing = JSON.parse(fs.readFileSync(deployLock, 'utf8'));
1355
- if (!cardUpdatedAt || existing?.card_updated_at === cardUpdatedAt) {
1356
- return false;
1357
- }
2209
+ existing = JSON.parse(fs.readFileSync(deployLock, 'utf8'));
1358
2210
  }
1359
2211
  catch {
2212
+ existing = { status: 'deployed' }; // unreadable lock: fail safe (never loop)
2213
+ }
2214
+ }
2215
+ const awaitingDeploy = card.agent_status === 'deploying';
2216
+ const priorAttempts = Number(existing?.attempts || 0);
2217
+ if (existing) {
2218
+ // Already handled and nobody is waiting on a deployment
2219
+ if (!awaitingDeploy)
1360
2220
  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
+ }
1361
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).
1362
2244
  }
2245
+ const deployStage = (card.deploy_stage || 'review').toLowerCase();
1363
2246
  const provider = card.agent_provider || 'cursor';
1364
- const rawText = `${card.title || ''} ${card.description || ''}`;
1365
- const isContainerRestartCard = /\b(restart|redeploy|rebuild)\b.*\b(container|docker|server|prod)/i.test(rawText);
1366
- try {
1367
- fs.writeFileSync(deployLock, JSON.stringify({
1368
- card_id: card.id,
1369
- card_updated_at: cardUpdatedAt,
1370
- title: card.title,
1371
- started_at: new Date().toISOString(),
1372
- }), 'utf8');
1373
- const res = runProjectDeploymentPipeline(projectPath, card.title || 'update', shortId, isContainerRestartCard);
1374
- const msg = res.deployedNote
1375
- ? `${res.deployedNote} Confirmed and moved card to **Done**.`
1376
- : `🚀 **Deployed**: Confirmed latest commit \`${res.commitHash}\` is live on \`origin/${res.branch}\` and moved card to **Done**.`;
1377
- await client.completeTask(card.id, provider, msg);
2247
+ // Only a card that literally asks for a redeploy gets one; ordinary cards never trigger a second deployment
2248
+ const isContainerRestartCard = isRedeployOnlyCard(card);
2249
+ const writeLock = (status, extra = {}) => {
2250
+ try {
2251
+ fs.writeFileSync(deployLock, JSON.stringify({
2252
+ card_id: card.id,
2253
+ status,
2254
+ title: card.title,
2255
+ redeploy_of: awaitingDeploy ? card.updated_at : null,
2256
+ started_at: new Date().toISOString(),
2257
+ attempts: status === 'failed' ? priorAttempts + 1 : priorAttempts,
2258
+ ...extra,
2259
+ }), 'utf8');
2260
+ }
2261
+ catch { }
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);
1378
2267
  appendIdeActivityLog(projectPath, {
1379
2268
  cardId: card.id,
1380
2269
  cardTitle: card.title,
@@ -1383,26 +2272,234 @@ export async function deployApprovedCardIfNeeded(client, card, projectPath) {
1383
2272
  status: 'done',
1384
2273
  summary: msg,
1385
2274
  });
1386
- sendDesktopNotification(`Agile Monster -> Production Confirmed`, `Deployed "${card.title}" (${res.commitHash}) and moved to Done!`);
2275
+ };
2276
+ activeWork++;
2277
+ writeLock('running');
2278
+ try {
2279
+ if (deployStage === 'manual') {
2280
+ // A human ships this project: nothing is waiting on us any more
2281
+ setCardState(projectPath, card.id, null);
2282
+ writeLock('deployed', { note: 'manual deploy stage' });
2283
+ return false;
2284
+ }
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 });
2310
+ return false;
2311
+ }
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);
2341
+ if (!res.ok) {
2342
+ writeLock('failed', { error: res.errorDetail });
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)}`));
2359
+ return false;
2360
+ }
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);
2378
+ sendDesktopNotification(`Agile Monster -> Production Confirmed`, `Deployed "${card.title}" (${res.commitHash})`);
1387
2379
  return true;
1388
2380
  }
1389
2381
  catch (err) {
1390
- try {
1391
- await client.addProgress(card.id, provider, `⚠️ **[Auto-Deploy Note]** Attempted to deploy approved changes in \`${projectPath}\`: ${err?.message || String(err)}`);
2382
+ log.error('deploy.approved_failed', { card: card.id, project: projectPath, error: errorMessage(err) });
2383
+ writeLock('failed', { error: String(err?.message || err).slice(0, 500) });
2384
+ return false;
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
+ }
1392
2440
  }
1393
- catch { }
2441
+ }
2442
+ }
2443
+ /**
2444
+ * Machine-wide single-worker election. Only ONE process per machine may claim, edit, or deploy cards.
2445
+ * IDE-spawned MCP instances (Cursor, Claude Code, Antigravity, …) can be long-lived and run stale code,
2446
+ * so they defer to the background pulse daemon (role 'daemon' always wins) or to any live, fresh holder.
2447
+ */
2448
+ const WORKER_LOCK_STALE_MS = 75 * 1000;
2449
+ function isPidAlive(pid) {
2450
+ try {
2451
+ process.kill(pid, 0);
2452
+ return true;
2453
+ }
2454
+ catch {
1394
2455
  return false;
1395
2456
  }
1396
2457
  }
1397
- export function startBuiltInPullPulse(server, client, intervalMs = 20000) {
2458
+ export function acquireWorkerLock(role) {
2459
+ const lockPath = path.join(agileMonsterHome(), 'worker.lock.json');
2460
+ try {
2461
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
2462
+ let holder = null;
2463
+ if (fs.existsSync(lockPath)) {
2464
+ try {
2465
+ holder = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
2466
+ }
2467
+ catch {
2468
+ holder = null;
2469
+ }
2470
+ }
2471
+ const holderLive = !!holder?.pid &&
2472
+ holder.pid !== process.pid &&
2473
+ isPidAlive(holder.pid) &&
2474
+ Date.now() - (holder.heartbeat || 0) < WORKER_LOCK_STALE_MS;
2475
+ if (holderLive) {
2476
+ const holderIsDaemon = holder?.role === 'daemon';
2477
+ // A daemon only yields to another live daemon; stdio instances always yield to a live holder.
2478
+ if (role === 'stdio' || holderIsDaemon)
2479
+ return false;
2480
+ }
2481
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, role, version: MCP_SERVER_VERSION, heartbeat: Date.now() }), 'utf8');
2482
+ return true;
2483
+ }
2484
+ catch {
2485
+ return role === 'daemon';
2486
+ }
2487
+ }
2488
+ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role = 'stdio') {
1398
2489
  const notifiedCardIds = new Set();
1399
2490
  let updateTriggered = false;
1400
2491
  let pulseCount = 0;
2492
+ let pulseRunning = false;
2493
+ let holdsWorkerLock = false;
2494
+ let journalsRecovered = false;
2495
+ let consecutiveFailures = 0;
1401
2496
  const autoProvisionSavedBindings = (apiKey, bindings) => {
1402
2497
  for (const b of bindings) {
1403
2498
  const projPath = (b.project_path || '').trim();
1404
2499
  if (!projPath || !fs.existsSync(projPath))
1405
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);
1406
2503
  const amConfigPath = path.join(projPath, '.agilemonster.json');
1407
2504
  let needsWrite = true;
1408
2505
  if (fs.existsSync(amConfigPath)) {
@@ -1450,11 +2547,21 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000) {
1450
2547
  if (!fs.existsSync(claudeMcp))
1451
2548
  fs.writeFileSync(claudeMcp, mcpPayload, 'utf8');
1452
2549
  }
1453
- catch { }
2550
+ catch (err) {
2551
+ log.warn('binding.provision_failed', { project: projPath, error: errorMessage(err) });
2552
+ }
1454
2553
  }
1455
2554
  }
1456
2555
  };
1457
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;
1458
2565
  try {
1459
2566
  const config = client.loadProjectConfig();
1460
2567
  const apiKey = client.getApiKey();
@@ -1471,6 +2578,10 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000) {
1471
2578
  discoveredProjects: includeDiscovery ? client.discoverLocalProjects() : undefined,
1472
2579
  discoveredModels: includeDiscovery ? client.discoverLocalAgentModels() : undefined,
1473
2580
  });
2581
+ if (consecutiveFailures > 0) {
2582
+ log.info('pulse.recovered', { after_failures: consecutiveFailures });
2583
+ consecutiveFailures = 0;
2584
+ }
1474
2585
  if (res.saved_bindings && res.saved_bindings.length > 0) {
1475
2586
  autoProvisionSavedBindings(apiKey, res.saved_bindings);
1476
2587
  }
@@ -1490,32 +2601,57 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000) {
1490
2601
  catch { }
1491
2602
  }
1492
2603
  }
1493
- // Zero-touch autonomous execution for any queued cards bound to local folders on this machine
1494
- const boundQueued = res.all_bound_queued_cards || [];
1495
- for (const qCard of boundQueued) {
1496
- const targetPath = (qCard.bound_project_path || '').trim() ||
1497
- (qCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
1498
- if (targetPath && fs.existsSync(targetPath)) {
1499
- await executeQueuedCardAutonomously(client, qCard, targetPath);
1500
- }
2604
+ // Zero-touch autonomous execution & deployment — ONLY the elected machine-wide worker may do this
2605
+ holdsWorkerLock = acquireWorkerLock(role);
2606
+ if (holdsWorkerLock && !journalsRecovered) {
2607
+ journalsRecovered = true;
2608
+ recoverEditJournals();
2609
+ pruneStaleLocks();
1501
2610
  }
1502
- // Zero-touch auto-commit & push (deploy to production) for cards approved into Production / Done!
1503
- const boundDone = res.all_bound_done_cards || [];
1504
- for (const dCard of boundDone) {
1505
- const targetPath = (dCard.bound_project_path || '').trim() ||
1506
- (dCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
1507
- if (targetPath && fs.existsSync(targetPath)) {
1508
- await deployApprovedCardIfNeeded(client, dCard, targetPath);
2611
+ if (holdsWorkerLock) {
2612
+ const boundQueued = res.all_bound_queued_cards || [];
2613
+ for (const qCard of boundQueued) {
2614
+ const targetPath = (qCard.bound_project_path || '').trim() ||
2615
+ (qCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
2616
+ if (targetPath && fs.existsSync(targetPath)) {
2617
+ await executeQueuedCardAutonomously(client, qCard, targetPath);
2618
+ }
2619
+ else if (targetPath) {
2620
+ log.warn('card.project_missing', { card: qCard.id, project: targetPath });
2621
+ }
1509
2622
  }
2623
+ const boundDone = res.all_bound_done_cards || [];
2624
+ for (const dCard of boundDone) {
2625
+ const targetPath = (dCard.bound_project_path || '').trim() ||
2626
+ (dCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
2627
+ if (targetPath && fs.existsSync(targetPath)) {
2628
+ await deployApprovedCardIfNeeded(client, dCard, targetPath);
2629
+ }
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));
1510
2637
  }
1511
2638
  if ((res.version?.force_update_requested || res.version?.update_available) &&
1512
- !updateTriggered) {
2639
+ !updateTriggered &&
2640
+ role === 'daemon') {
1513
2641
  updateTriggered = true;
1514
- triggerBackgroundSelfUpdate();
2642
+ triggerBackgroundSelfUpdate(res.version?.latest_version);
1515
2643
  }
1516
2644
  }
1517
- catch {
1518
- // 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;
1519
2655
  }
1520
2656
  };
1521
2657
  setTimeout(runPulse, 2500).unref();
@@ -1524,33 +2660,49 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000) {
1524
2660
  return timer;
1525
2661
  }
1526
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
1527
2666
  const client = new AgileMonsterClient();
1528
2667
  const server = createServer(client);
1529
2668
  const transport = new StdioServerTransport();
1530
2669
  await server.connect(transport);
1531
- startBuiltInPullPulse(server, client, 20000);
2670
+ startBuiltInPullPulse(server, client, 20000, 'stdio');
1532
2671
  }
1533
2672
  export async function runBackgroundPulseDaemon(intervalMs = 20000) {
2673
+ configureLogger({ echo: false });
2674
+ const addedPaths = await ensureUserPath();
1534
2675
  const client = new AgileMonsterClient();
1535
2676
  console.log(`[Agile Monster Pulse Daemon v${MCP_SERVER_VERSION}] Listening for queued agent tasks across all bound repositories (interval: ${intervalMs / 1000}s)...`);
1536
- startBuiltInPullPulse(null, client, intervalMs);
1537
- const selfScript = process.argv[1] || '';
1538
- const initialMtime = selfScript && fs.existsSync(selfScript) ? fs.statSync(selfScript).mtimeMs : 0;
1539
- // Auto-restart daemon if mcp-server binary/dist on disk is updated so it never runs stale code in memory
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
+ });
2689
+ startBuiltInPullPulse(null, client, intervalMs, 'daemon');
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;
1540
2695
  setInterval(() => {
1541
2696
  try {
1542
- if (selfScript && initialMtime > 0 && fs.existsSync(selfScript)) {
1543
- const curMtime = fs.statSync(selfScript).mtimeMs;
1544
- if (curMtime > initialMtime + 1000) {
1545
- const child = spawn(process.execPath, process.argv.slice(1), {
1546
- detached: true,
1547
- stdio: 'ignore',
1548
- });
1549
- child.unref();
1550
- 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');
1551
2703
  }
1552
2704
  }
1553
2705
  }
1554
2706
  catch { }
1555
- }, 30000);
2707
+ }, 10000);
1556
2708
  }