@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/README.md CHANGED
@@ -74,7 +74,7 @@ This generates:
74
74
 
75
75
  ## 🛠️ Available MCP Tools
76
76
 
77
- Agents have access to 8 purpose-built lifecycle tools:
77
+ Agents have access to these purpose-built lifecycle tools:
78
78
 
79
79
  | Tool | Purpose |
80
80
  | :--- | :--- |
@@ -86,6 +86,32 @@ Agents have access to 8 purpose-built lifecycle tools:
86
86
  | `agilemonster_add_progress` | Logs implementation progress notes to task comments. |
87
87
  | `agilemonster_submit_for_review` | Marks work complete, moves card to Review column, reassigns to human member for confirmation, and posts test instructions. |
88
88
  | `agilemonster_create_followup_tasks` | Creates follow-up cards (e.g. "Deploy to Test", "Deploy to Production", "Write integration tests"). |
89
+ | `agilemonster_get_deploy_profile` | How this project deploys: route, exact command, confidence, the evidence behind it, alternatives, and recent outcomes. |
90
+ | `agilemonster_record_deployment` | Records how a deployment went (or pins the route the user stated) so every later task deploys the same proven way. |
91
+
92
+ ---
93
+
94
+ ## 🚀 How deployment is decided (and remembered)
95
+
96
+ Cards started from the Agile Monster web app run in the background without your IDE's context, so the
97
+ server works out how a project ships from evidence and from what worked before:
98
+
99
+ 1. **Explicit** — `.agilemonster/deploy.json` in the repo (`{"method": "docker_compose", "command": "...", "live_url": "..."}`), or a route pinned with `agilemonster_record_deployment({ pin: true })` / a card reply.
100
+ 2. **Learned** — the last deployment that was verified on this machine.
101
+ 3. **Evidence, ranked** — a deploy workflow that really fires on a push to the checked-out branch (path filters and `workflow_run` chains included) › a deploy script › a cloud CLI config › the Docker Compose stack that is running from this folder (reused with its exact project name and files) › a compose file › a plain `git push`.
102
+
103
+ What the runner guarantees:
104
+
105
+ - **Commits only the files it changed** — never `git add -A`; local files holding your API key are filtered and added to `.git/info/exclude`.
106
+ - **Verifies before claiming "Deployed"** — deploy workflow run for that commit, container state and health, script exit code, and a smoke check of a URL you provided. A guessed URL can never fail a deploy.
107
+ - **Asks once, then remembers** — an unconfirmed deploy script, cloud CLI or manually-dispatched workflow is not run on a guess. Reply on the card with `deploy: <command or method>` to confirm; `retry` re-runs only the deployment, not the code change.
108
+ - **Heals what it can** — starts Docker Desktop/OrbStack if it is installed but stopped, rebases and re-pushes when the remote moved ahead, retries network errors, and falls back to the next route when the chosen one plainly does not apply.
109
+ - **Never reports a slow pipeline as failed** — a CI run that outlives the wait is marked pending and re-checked on later pulses; the outcome is posted on the card.
110
+
111
+ State lives outside your repositories: `~/.agilemonster/projects/` (per-project deployment history) and
112
+ `~/.agilemonster/logs/daemon.jsonl` (structured daemon log).
113
+
114
+ Card reply syntax: `retry` · `deploy: github_actions` · `deploy: docker_compose` · `deploy: manual` · `deploy: ./deploy/deploy.sh` · `live url: https://example.com`
89
115
 
90
116
  ---
91
117
 
package/bin/mcp.js CHANGED
File without changes
File without changes
package/bin/worker.js CHANGED
File without changes
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { AgileMonsterProjectConfig, AutonomousStepResponse, Board, BoardAgentConfig, Card, ContextResponse, DiscoveredProject, PulseResponse } from './types.js';
2
- export declare const MCP_SERVER_VERSION = "1.0.15";
1
+ import { AgileMonsterProjectConfig, AutonomousStepResponse, CardComment, DeployReport, Board, BoardAgentConfig, Card, ContextResponse, DiscoveredProject, PulseResponse } from './types.js';
2
+ export declare const MCP_SERVER_VERSION = "1.0.21";
3
3
  export declare class AgileMonsterClient {
4
4
  private apiKey;
5
5
  private baseUrl;
@@ -40,7 +40,13 @@ export declare class AgileMonsterClient {
40
40
  default_model?: string | null;
41
41
  models: Record<string, string> | string[];
42
42
  }>;
43
+ installedAgents?: string[];
43
44
  }): Promise<PulseResponse>;
45
+ /**
46
+ * Cross-platform detection of AI coding agents installed on this computer.
47
+ * Checks for Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf, Aider.
48
+ */
49
+ static detectInstalledAgents(): string[];
44
50
  /**
45
51
  * Discover live models installed/configured across the developer's local AI coding agents
46
52
  * (Codex, Cursor, Claude Code, Hermes, Antigravity) so the Agile Monster app & website
@@ -50,6 +56,20 @@ export declare class AgileMonsterClient {
50
56
  default_model?: string | null;
51
57
  models: Record<string, string>;
52
58
  }>;
59
+ /**
60
+ * Describe how a project folder builds and deploys, for the server's project/board bindings.
61
+ * Backed by the evidence-ranked resolver (running containers, real workflow triggers, deploy scripts,
62
+ * project docs) plus this machine's deployment memory, so what the web app shows matches what will run.
63
+ */
64
+ static inspectProjectProfile(fullPath: string, _name?: string): {
65
+ build_command: string | null;
66
+ deploy_method: string | null;
67
+ deploy_command: string | null;
68
+ live_url: string | null;
69
+ deploy_source: string;
70
+ deploy_confidence: string;
71
+ deploy_evidence: string;
72
+ };
53
73
  /**
54
74
  * Scan the developer's local workspace roots (sibling folders in parent dev dir, ~/dev, ~/Projects, etc.)
55
75
  * to discover real project folders so the web UI can auto-match and let the user pick from a dropdown.
@@ -79,10 +99,17 @@ export declare class AgileMonsterClient {
79
99
  }>): Promise<{
80
100
  tasks: Card[];
81
101
  }>;
102
+ listComments(cardId: string): Promise<CardComment[]>;
103
+ /**
104
+ * Tell the server how a deployment went so the board binding learns the verified route (or the failure).
105
+ * Older backends do not have this endpoint; that must never break a deployment.
106
+ */
107
+ reportDeployment(report: DeployReport): Promise<boolean>;
82
108
  autonomousStep(payload: {
83
109
  card_id: string;
84
110
  action: 'select_files' | 'generate_edits';
85
111
  file_index?: string[];
86
112
  files?: Record<string, string>;
113
+ project_context?: string;
87
114
  }): Promise<AutonomousStepResponse>;
88
115
  }
package/dist/client.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execSync } from 'node:child_process';
4
- export const MCP_SERVER_VERSION = '1.0.15';
4
+ import { resolveDeployProfile } from './deploy-profile.js';
5
+ import { loadMemory } from './deploy-memory.js';
6
+ import { stripRemoteCredentials } from './redact.js';
7
+ export const MCP_SERVER_VERSION = '1.0.21';
5
8
  export class AgileMonsterClient {
6
9
  apiKey = '';
7
10
  baseUrl;
@@ -85,7 +88,7 @@ export class AgileMonsterClient {
85
88
  catch {
86
89
  // Ignore reading error
87
90
  }
88
- this.cachedConfig = null;
91
+ // Not cached: bootstrap may write the file while this server is running, and the tools must then see it
89
92
  return null;
90
93
  }
91
94
  saveProjectConfig(config, dir) {
@@ -155,7 +158,7 @@ export class AgileMonsterClient {
155
158
  if (boardId)
156
159
  params.set('board_id', boardId);
157
160
  if (gitRemote)
158
- params.set('git_remote', gitRemote);
161
+ params.set('git_remote', stripRemoteCredentials(gitRemote));
159
162
  if (detectedIdes && detectedIdes.length > 0) {
160
163
  params.set('detected_ides', detectedIdes.join(','));
161
164
  }
@@ -180,13 +183,14 @@ export class AgileMonsterClient {
180
183
  agents,
181
184
  project_name: projectName,
182
185
  project_path: projectPath,
183
- git_remote: gitRemote,
186
+ git_remote: gitRemote ? stripRemoteCredentials(gitRemote) : gitRemote,
184
187
  mcp_version: MCP_SERVER_VERSION,
185
188
  connected: true,
186
189
  }),
187
190
  });
188
191
  }
189
192
  async pulse(options) {
193
+ const installed = options.installedAgents || AgileMonsterClient.detectInstalledAgents();
190
194
  return this.request('/api/agent/pulse', {
191
195
  method: 'POST',
192
196
  body: JSON.stringify({
@@ -197,9 +201,73 @@ export class AgileMonsterClient {
197
201
  mcp_version: MCP_SERVER_VERSION,
198
202
  discovered_projects: options.discoveredProjects,
199
203
  discovered_models: options.discoveredModels,
204
+ installed_agents: installed,
200
205
  }),
201
206
  });
202
207
  }
208
+ /**
209
+ * Cross-platform detection of AI coding agents installed on this computer.
210
+ * Checks for Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf, Aider.
211
+ */
212
+ static detectInstalledAgents() {
213
+ const homedir = process.env.HOME || process.env.USERPROFILE || '';
214
+ const detected = new Set();
215
+ const exists = (...p) => {
216
+ try {
217
+ const full = path.join(...p);
218
+ return fs.existsSync(full);
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ };
224
+ // 1. Antigravity
225
+ if (exists(homedir, '.gemini') ||
226
+ exists(homedir, '.antigravity') ||
227
+ Boolean(process.env.ANTIGRAVITY_APP_DATA_DIR) ||
228
+ Boolean(process.env.GEMINI_CLI)) {
229
+ detected.add('antigravity');
230
+ }
231
+ // 2. Cursor
232
+ if (exists(homedir, '.cursor') ||
233
+ exists(homedir, 'Library', 'Application Support', 'Cursor') ||
234
+ exists(process.env.APPDATA || '', 'Cursor') ||
235
+ exists(homedir, '.config', 'Cursor') ||
236
+ exists(process.cwd(), '.cursor')) {
237
+ detected.add('cursor');
238
+ }
239
+ // 3. Claude Code
240
+ if (exists(homedir, '.claude') ||
241
+ exists(homedir, '.claude.json') ||
242
+ exists(homedir, '.config', 'claude') ||
243
+ exists(process.cwd(), '.mcp.json')) {
244
+ detected.add('claude_code');
245
+ }
246
+ // 4. Codex
247
+ if (exists(homedir, '.codex') ||
248
+ exists(process.cwd(), '.codex')) {
249
+ detected.add('codex');
250
+ }
251
+ // 5. Hermes
252
+ if (exists(homedir, '.hermes') ||
253
+ exists(homedir, '.local', 'bin', 'hermes')) {
254
+ detected.add('hermes');
255
+ }
256
+ // 6. Windsurf
257
+ if (exists(homedir, '.codeium', 'windsurf') ||
258
+ exists(homedir, '.windsurf') ||
259
+ exists(homedir, 'Library', 'Application Support', 'Windsurf') ||
260
+ exists(process.env.APPDATA || '', 'Windsurf') ||
261
+ exists(homedir, '.config', 'Windsurf')) {
262
+ detected.add('windsurf');
263
+ }
264
+ // 7. Aider
265
+ if (exists(homedir, '.aider') ||
266
+ exists(homedir, '.aider.conf.yml')) {
267
+ detected.add('aider');
268
+ }
269
+ return Array.from(detected);
270
+ }
203
271
  /**
204
272
  * Discover live models installed/configured across the developer's local AI coding agents
205
273
  * (Codex, Cursor, Claude Code, Hermes, Antigravity) so the Agile Monster app & website
@@ -315,17 +383,49 @@ export class AgileMonsterClient {
315
383
  catch { }
316
384
  // 5. Antigravity
317
385
  result.antigravity = {
318
- default_model: 'gemini-3.1-pro',
386
+ default_model: 'gemini-4-argon',
319
387
  models: {
388
+ 'gemini-4-argon': 'Gemini 4 Argon (Fallback -> Claude Opus 5.5)',
389
+ 'claude-opus-5-5': 'Claude Opus 5.5',
320
390
  'gemini-3.1-pro': 'Gemini 3.1 Pro',
321
391
  'gemini-3.0-flash': 'Gemini 3.0 Flash',
322
- 'claude-opus-5-5': 'Claude Opus 5.5',
323
392
  'claude-sonnet-5-5': 'Claude Sonnet 5.5',
324
393
  'gpt-6.1-sol': 'GPT-6.1 Sol',
325
394
  },
326
395
  };
327
396
  return result;
328
397
  }
398
+ /**
399
+ * Describe how a project folder builds and deploys, for the server's project/board bindings.
400
+ * Backed by the evidence-ranked resolver (running containers, real workflow triggers, deploy scripts,
401
+ * project docs) plus this machine's deployment memory, so what the web app shows matches what will run.
402
+ */
403
+ static inspectProjectProfile(fullPath, _name) {
404
+ try {
405
+ const profile = resolveDeployProfile(fullPath, { memory: loadMemory(fullPath) });
406
+ const source = profile.source === 'explicit' ? 'user' : profile.source === 'learned' || profile.source === 'verified' ? 'verified' : 'auto';
407
+ return {
408
+ build_command: profile.buildCommand,
409
+ deploy_method: profile.method === 'none' ? null : profile.method,
410
+ deploy_command: profile.command,
411
+ live_url: profile.liveUrl,
412
+ deploy_source: source,
413
+ deploy_confidence: profile.confidence,
414
+ deploy_evidence: profile.evidence.slice(0, 3).join(' · ').slice(0, 480),
415
+ };
416
+ }
417
+ catch {
418
+ return {
419
+ build_command: null,
420
+ deploy_method: null,
421
+ deploy_command: null,
422
+ live_url: null,
423
+ deploy_source: 'auto',
424
+ deploy_confidence: 'low',
425
+ deploy_evidence: '',
426
+ };
427
+ }
428
+ }
329
429
  /**
330
430
  * Scan the developer's local workspace roots (sibling folders in parent dev dir, ~/dev, ~/Projects, etc.)
331
431
  * to discover real project folders so the web UI can auto-match and let the user pick from a dropdown.
@@ -396,19 +496,19 @@ export class AgileMonsterClient {
396
496
  if (fs.existsSync(path.join(fullPath, '.git'))) {
397
497
  try {
398
498
  gitRemote =
399
- execSync('git config --get remote.origin.url', {
499
+ stripRemoteCredentials(execSync('git config --get remote.origin.url', {
400
500
  cwd: fullPath,
401
501
  stdio: ['ignore', 'pipe', 'ignore'],
402
- })
403
- .toString()
404
- .trim() || null;
502
+ }).toString()) || null;
405
503
  }
406
504
  catch { }
407
505
  }
506
+ const profile = AgileMonsterClient.inspectProjectProfile(fullPath, name);
408
507
  found.set(fullPath, {
409
508
  name,
410
509
  path: fullPath,
411
510
  git_remote: gitRemote,
511
+ ...profile,
412
512
  });
413
513
  }
414
514
  }
@@ -506,6 +606,26 @@ export class AgileMonsterClient {
506
606
  body: JSON.stringify({ provider, tasks }),
507
607
  });
508
608
  }
609
+ async listComments(cardId) {
610
+ const res = await this.request(`/api/cards/${cardId}/comments`);
611
+ return Array.isArray(res) ? res : res?.comments || [];
612
+ }
613
+ /**
614
+ * Tell the server how a deployment went so the board binding learns the verified route (or the failure).
615
+ * Older backends do not have this endpoint; that must never break a deployment.
616
+ */
617
+ async reportDeployment(report) {
618
+ try {
619
+ await this.request('/api/agent/deploy-report', {
620
+ method: 'POST',
621
+ body: JSON.stringify({ ...report, mcp_version: MCP_SERVER_VERSION }),
622
+ });
623
+ return true;
624
+ }
625
+ catch {
626
+ return false;
627
+ }
628
+ }
509
629
  async autonomousStep(payload) {
510
630
  return this.request('/api/agent/autonomous-step', {
511
631
  method: 'POST',
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Machine-local deployment memory, one file per project folder.
3
+ *
4
+ * This is the feedback loop that was missing: every deployment attempt (from the daemon or reported
5
+ * by an IDE agent) is recorded here, so the next card starts from "what worked last time" instead of
6
+ * re-guessing — and a method that keeps failing because it does not apply gets demoted instead of retried.
7
+ *
8
+ * Lives in ~/.agilemonster/projects/ (never inside the repository, so it can never be committed).
9
+ */
10
+ export interface DeployAttempt {
11
+ at: string;
12
+ card_id?: string | null;
13
+ method: string;
14
+ command?: string | null;
15
+ ok: boolean;
16
+ pending?: boolean;
17
+ kind?: string | null;
18
+ error?: string | null;
19
+ commit?: string | null;
20
+ duration_ms?: number;
21
+ reported_by?: string | null;
22
+ }
23
+ export interface MethodFailures {
24
+ count: number;
25
+ last_kind: string | null;
26
+ last_error: string | null;
27
+ last_at: string;
28
+ /** The failure says the method does not apply here (as opposed to a real deploy breaking). */
29
+ wrong_method: boolean;
30
+ }
31
+ /** A deployment whose CI run had not finished when the daemon stopped waiting; re-checked on later pulses. */
32
+ export interface PendingVerification {
33
+ card_id: string;
34
+ card_title: string;
35
+ provider: string;
36
+ sha: string;
37
+ branch: string;
38
+ workflow_names: string[];
39
+ started_at: string;
40
+ run_url?: string | null;
41
+ }
42
+ /** What the agent changed for a card whose deployment has not succeeded (or not run) yet. */
43
+ export interface CardDeployState {
44
+ stage: 'deploy_failed' | 'awaiting_approval';
45
+ files: string[];
46
+ summary: string;
47
+ verification_steps: string;
48
+ diff: string;
49
+ commit: string | null;
50
+ at: string;
51
+ attempts: number;
52
+ last_error?: string | null;
53
+ /** Set when the agent asked "may I deploy with X?": a plain "yes"/"retry" then confirms X. */
54
+ pending_confirmation?: {
55
+ method: string;
56
+ command: string | null;
57
+ } | null;
58
+ }
59
+ export interface ProjectMemory {
60
+ project_path: string;
61
+ updated_at: string;
62
+ explicit?: {
63
+ method: string;
64
+ command?: string | null;
65
+ live_url?: string | null;
66
+ notes?: string | null;
67
+ set_by?: string | null;
68
+ set_at: string;
69
+ } | null;
70
+ last_success?: {
71
+ method: string;
72
+ command?: string | null;
73
+ live_url?: string | null;
74
+ at: string;
75
+ commit?: string | null;
76
+ card_id?: string | null;
77
+ } | null;
78
+ failures: Record<string, MethodFailures>;
79
+ history: DeployAttempt[];
80
+ pending: PendingVerification[];
81
+ cards: Record<string, CardDeployState>;
82
+ /**
83
+ * Cards this machine already shipped (kept apart from the rolling history, so approving a card long after
84
+ * its review-time deployment never looks like "not deployed yet" and triggers a second one).
85
+ */
86
+ deployed_cards: Record<string, {
87
+ at: string;
88
+ commit: string | null;
89
+ method: string;
90
+ verified: boolean;
91
+ }>;
92
+ }
93
+ export declare function memoryFileFor(projectPath: string): string;
94
+ /**
95
+ * Recover "how the last deployment worked" from the activity log earlier daemon versions wrote into the
96
+ * project (`.agilemonster/ACTIVITY.md`), so projects that already deployed successfully keep their route
97
+ * instead of starting from a fresh guess.
98
+ */
99
+ export declare function parseLastDeploymentFromActivityLog(logText: string): {
100
+ method: string;
101
+ command: null;
102
+ } | null;
103
+ export declare function loadMemory(projectPath: string): ProjectMemory;
104
+ export declare function saveMemory(memory: ProjectMemory): void;
105
+ /** Record the outcome of an attempt and update what the project "knows" about its own deployment. */
106
+ export declare function recordAttempt(projectPath: string, attempt: Omit<DeployAttempt, 'at'> & {
107
+ live_url?: string | null;
108
+ wrong_method?: boolean;
109
+ /** The change went out but nothing could confirm the deployment (no CI run found, `gh` unavailable). */
110
+ unverified?: boolean;
111
+ }): ProjectMemory;
112
+ export declare function setExplicitProfile(projectPath: string, explicit: {
113
+ method: string;
114
+ command?: string | null;
115
+ live_url?: string | null;
116
+ notes?: string | null;
117
+ set_by?: string | null;
118
+ }): ProjectMemory;
119
+ export declare function setCardState(projectPath: string, cardId: string, state: CardDeployState | null): void;
120
+ export declare function getCardState(projectPath: string, cardId: string): CardDeployState | null;
121
+ export declare function addPendingVerification(projectPath: string, pending: PendingVerification): void;
122
+ export declare function removePendingVerification(projectPath: string, cardId: string): void;
@@ -0,0 +1,192 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { agileMonsterHome, readJson, writeJsonAtomic } from './runtime.js';
5
+ const HISTORY_LIMIT = 30;
6
+ const CARD_STATE_LIMIT = 200;
7
+ const DEPLOYED_CARDS_LIMIT = 300;
8
+ const STALE_CARD_STATE_MS = 45 * 24 * 60 * 60 * 1000;
9
+ export function memoryFileFor(projectPath) {
10
+ const resolved = path.resolve(projectPath);
11
+ const hash = crypto.createHash('sha1').update(resolved).digest('hex').slice(0, 10);
12
+ const slug = path.basename(resolved).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
13
+ return path.join(agileMonsterHome(), 'projects', `${slug}-${hash}.json`);
14
+ }
15
+ /**
16
+ * Recover "how the last deployment worked" from the activity log earlier daemon versions wrote into the
17
+ * project (`.agilemonster/ACTIVITY.md`), so projects that already deployed successfully keep their route
18
+ * instead of starting from a fresh guess.
19
+ */
20
+ export function parseLastDeploymentFromActivityLog(logText) {
21
+ // The log mixes daemon output with card titles and model-written summaries, so it is only trusted for
22
+ // WHICH of the two routine routes last succeeded — never for a command to run. (A script route found
23
+ // this way would still have to be confirmed by a human.) Headings carry card titles: skip them.
24
+ const lines = logText
25
+ .split(/\r?\n/)
26
+ .filter((l) => l.includes('🚀 **Deployed**') && !l.trimStart().startsWith('#'));
27
+ for (let i = lines.length - 1; i >= 0; i--) {
28
+ const line = lines[i].slice(lines[i].indexOf('🚀 **Deployed**'));
29
+ if (/deployed (?:via|with) `/i.test(line))
30
+ return null; // last route was a script/CLI: needs confirmation, not seeding
31
+ if (/Docker (containers rebuilt|Compose)/i.test(line))
32
+ return { method: 'docker_compose', command: null };
33
+ if (/GitHub Actions (deployment )?passed/i.test(line))
34
+ return { method: 'github_actions', command: null };
35
+ }
36
+ return null;
37
+ }
38
+ function seedFromActivityLog(projectPath) {
39
+ try {
40
+ const file = path.join(projectPath, '.agilemonster', 'ACTIVITY.md');
41
+ if (!fs.existsSync(file))
42
+ return null;
43
+ const stat = fs.statSync(file);
44
+ const fd = fs.openSync(file, 'r');
45
+ const length = Math.min(stat.size, 300 * 1024);
46
+ const buffer = Buffer.alloc(length);
47
+ fs.readSync(fd, buffer, 0, length, stat.size - length);
48
+ fs.closeSync(fd);
49
+ const found = parseLastDeploymentFromActivityLog(buffer.toString('utf8'));
50
+ if (!found)
51
+ return null;
52
+ return { method: found.method, command: found.command, at: stat.mtime.toISOString(), commit: null, card_id: null };
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ export function loadMemory(projectPath) {
59
+ const file = memoryFileFor(projectPath);
60
+ const stored = readJson(file);
61
+ if (!stored && !fs.existsSync(file)) {
62
+ const seeded = seedFromActivityLog(projectPath);
63
+ if (seeded) {
64
+ const memory = {
65
+ project_path: path.resolve(projectPath),
66
+ updated_at: new Date().toISOString(),
67
+ explicit: null,
68
+ last_success: seeded,
69
+ failures: {},
70
+ history: [],
71
+ pending: [],
72
+ cards: {},
73
+ deployed_cards: {},
74
+ };
75
+ saveMemory(memory);
76
+ return memory;
77
+ }
78
+ }
79
+ return {
80
+ project_path: path.resolve(projectPath),
81
+ updated_at: stored?.updated_at || new Date(0).toISOString(),
82
+ explicit: stored?.explicit || null,
83
+ last_success: stored?.last_success || null,
84
+ failures: stored?.failures && typeof stored.failures === 'object' ? stored.failures : {},
85
+ history: Array.isArray(stored?.history) ? stored.history : [],
86
+ pending: Array.isArray(stored?.pending) ? stored.pending : [],
87
+ cards: stored?.cards && typeof stored.cards === 'object' ? stored.cards : {},
88
+ deployed_cards: stored?.deployed_cards && typeof stored.deployed_cards === 'object' ? stored.deployed_cards : {},
89
+ };
90
+ }
91
+ export function saveMemory(memory) {
92
+ memory.updated_at = new Date().toISOString();
93
+ // Card state means "edits exist that still have to ship": only drop it when it is clearly abandoned.
94
+ const cardIds = Object.keys(memory.cards);
95
+ for (const id of cardIds) {
96
+ if (Date.now() - (Date.parse(memory.cards[id].at) || 0) > STALE_CARD_STATE_MS)
97
+ delete memory.cards[id];
98
+ }
99
+ const remaining = Object.keys(memory.cards);
100
+ if (remaining.length > CARD_STATE_LIMIT) {
101
+ remaining
102
+ .sort((a, b) => (memory.cards[a].at < memory.cards[b].at ? -1 : 1))
103
+ .slice(0, remaining.length - CARD_STATE_LIMIT)
104
+ .forEach((id) => delete memory.cards[id]);
105
+ }
106
+ const deployedIds = Object.keys(memory.deployed_cards);
107
+ if (deployedIds.length > DEPLOYED_CARDS_LIMIT) {
108
+ deployedIds
109
+ .sort((a, b) => (memory.deployed_cards[a].at < memory.deployed_cards[b].at ? -1 : 1))
110
+ .slice(0, deployedIds.length - DEPLOYED_CARDS_LIMIT)
111
+ .forEach((id) => delete memory.deployed_cards[id]);
112
+ }
113
+ memory.history = memory.history.slice(-HISTORY_LIMIT);
114
+ try {
115
+ writeJsonAtomic(memoryFileFor(memory.project_path), memory);
116
+ }
117
+ catch {
118
+ // Memory is an optimization; failing to persist it must never fail a deployment.
119
+ }
120
+ }
121
+ /** Record the outcome of an attempt and update what the project "knows" about its own deployment. */
122
+ export function recordAttempt(projectPath, attempt) {
123
+ const memory = loadMemory(projectPath);
124
+ const at = new Date().toISOString();
125
+ const { live_url: liveUrl, wrong_method: wrongMethod, unverified, ...rest } = attempt;
126
+ memory.history.push({ at, ...rest, error: rest.error ? rest.error.slice(0, 600) : rest.error });
127
+ if (attempt.ok && attempt.card_id) {
128
+ memory.deployed_cards[attempt.card_id] = {
129
+ at,
130
+ commit: attempt.commit || null,
131
+ method: attempt.method,
132
+ verified: !attempt.pending && !unverified,
133
+ };
134
+ }
135
+ // Only a deployment that was actually verified teaches the project its route
136
+ if (attempt.ok && !attempt.pending && !unverified) {
137
+ memory.last_success = {
138
+ method: attempt.method,
139
+ command: attempt.command || null,
140
+ live_url: liveUrl || memory.last_success?.live_url || null,
141
+ at,
142
+ commit: attempt.commit || null,
143
+ card_id: attempt.card_id || null,
144
+ };
145
+ delete memory.failures[attempt.method];
146
+ }
147
+ else if (!attempt.ok) {
148
+ const prev = memory.failures[attempt.method];
149
+ memory.failures[attempt.method] = {
150
+ count: (prev?.count || 0) + 1,
151
+ last_kind: attempt.kind || null,
152
+ last_error: attempt.error ? attempt.error.slice(0, 400) : null,
153
+ last_at: at,
154
+ wrong_method: Boolean(wrongMethod),
155
+ };
156
+ }
157
+ saveMemory(memory);
158
+ return memory;
159
+ }
160
+ export function setExplicitProfile(projectPath, explicit) {
161
+ const memory = loadMemory(projectPath);
162
+ memory.explicit = { ...explicit, set_at: new Date().toISOString() };
163
+ // A human or IDE agent just told us how to deploy: earlier failures of that method are no longer a reason to avoid it.
164
+ delete memory.failures[explicit.method];
165
+ saveMemory(memory);
166
+ return memory;
167
+ }
168
+ export function setCardState(projectPath, cardId, state) {
169
+ const memory = loadMemory(projectPath);
170
+ if (state)
171
+ memory.cards[cardId] = state;
172
+ else
173
+ delete memory.cards[cardId];
174
+ saveMemory(memory);
175
+ }
176
+ export function getCardState(projectPath, cardId) {
177
+ return loadMemory(projectPath).cards[cardId] || null;
178
+ }
179
+ export function addPendingVerification(projectPath, pending) {
180
+ const memory = loadMemory(projectPath);
181
+ memory.pending = memory.pending.filter((p) => p.card_id !== pending.card_id);
182
+ memory.pending.push(pending);
183
+ saveMemory(memory);
184
+ }
185
+ export function removePendingVerification(projectPath, cardId) {
186
+ const memory = loadMemory(projectPath);
187
+ const next = memory.pending.filter((p) => p.card_id !== cardId);
188
+ if (next.length !== memory.pending.length) {
189
+ memory.pending = next;
190
+ saveMemory(memory);
191
+ }
192
+ }