antigravity-ai-kit 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.agent/agents/planner.md +205 -62
  2. package/.agent/contexts/plan-quality-log.md +30 -0
  3. package/.agent/engine/loading-rules.json +37 -3
  4. package/.agent/hooks/hooks.json +10 -0
  5. package/.agent/manifest.json +4 -3
  6. package/.agent/skills/plan-validation/SKILL.md +192 -0
  7. package/.agent/skills/plan-writing/SKILL.md +47 -8
  8. package/.agent/skills/plan-writing/domain-enhancers.md +114 -0
  9. package/.agent/skills/plan-writing/plan-retrospective.md +116 -0
  10. package/.agent/skills/plan-writing/plan-schema.md +119 -0
  11. package/.agent/workflows/plan.md +49 -5
  12. package/README.md +30 -29
  13. package/bin/ag-kit.js +26 -5
  14. package/lib/agent-registry.js +17 -3
  15. package/lib/agent-reputation.js +3 -11
  16. package/lib/circuit-breaker.js +195 -0
  17. package/lib/cli-commands.js +88 -1
  18. package/lib/config-validator.js +274 -0
  19. package/lib/conflict-detector.js +29 -22
  20. package/lib/constants.js +35 -0
  21. package/lib/engineering-manager.js +9 -27
  22. package/lib/error-budget.js +105 -29
  23. package/lib/hook-system.js +8 -4
  24. package/lib/identity.js +22 -27
  25. package/lib/io.js +74 -0
  26. package/lib/loading-engine.js +248 -35
  27. package/lib/logger.js +118 -0
  28. package/lib/marketplace.js +43 -20
  29. package/lib/plugin-system.js +55 -31
  30. package/lib/plugin-verifier.js +197 -0
  31. package/lib/rate-limiter.js +113 -0
  32. package/lib/security-scanner.js +1 -4
  33. package/lib/self-healing.js +58 -24
  34. package/lib/session-manager.js +51 -48
  35. package/lib/skill-sandbox.js +1 -1
  36. package/lib/task-governance.js +10 -11
  37. package/lib/task-model.js +42 -27
  38. package/lib/updater.js +1 -1
  39. package/lib/verify.js +4 -4
  40. package/lib/workflow-engine.js +88 -68
  41. package/lib/workflow-events.js +166 -0
  42. package/lib/workflow-persistence.js +19 -19
  43. package/package.json +2 -2
@@ -2,7 +2,9 @@
2
2
  * Antigravity AI Kit — Loading Rules Engine
3
3
  *
4
4
  * Runtime implementation of loading-rules.json keyword matching
5
- * and context budget enforcement.
5
+ * and context budget enforcement. Includes enhanced planning
6
+ * resolution with mandatory cross-cutting concerns, implicit
7
+ * domain triggers, and protected budget items.
6
8
  *
7
9
  * @module lib/loading-engine
8
10
  * @author Emre Dursun
@@ -14,10 +16,22 @@
14
16
  const fs = require('fs');
15
17
  const path = require('path');
16
18
 
17
- const AGENT_DIR = '.agent';
18
- const ENGINE_DIR = 'engine';
19
+ const { AGENT_DIR, ENGINE_DIR } = require('./constants');
19
20
  const LOADING_RULES_FILE = 'loading-rules.json';
20
21
 
22
+ /** Default maximum agents per session */
23
+ const DEFAULT_MAX_AGENTS = 4;
24
+ /** Default maximum skills per session */
25
+ const DEFAULT_MAX_SKILLS = 8;
26
+ /** Default warning threshold percentage */
27
+ const DEFAULT_WARNING_THRESHOLD_PERCENT = 80;
28
+
29
+ /**
30
+ * @typedef {object} ProtectedItems
31
+ * @property {string[]} [agents] - Agents exempt from budget trimming
32
+ * @property {string[]} [skills] - Skills exempt from budget trimming
33
+ */
34
+
21
35
  /**
22
36
  * @typedef {object} LoadPlan
23
37
  * @property {string[]} agents - Agents to load
@@ -29,6 +43,7 @@ const LOADING_RULES_FILE = 'loading-rules.json';
29
43
  * @property {number} budgetUsage.skillsUsed - Number of skills selected
30
44
  * @property {number} budgetUsage.skillsMax - Maximum allowed
31
45
  * @property {string[]} matchedDomains - Which domains matched
46
+ * @property {string[]} [mandatoryRules] - Rule files that must be consulted (planning only)
32
47
  */
33
48
 
34
49
  /**
@@ -47,6 +62,55 @@ function loadRules(projectRoot) {
47
62
  return JSON.parse(fs.readFileSync(rulesPath, 'utf-8'));
48
63
  }
49
64
 
65
+ /**
66
+ * Trims an array of items respecting a protected set.
67
+ * Protected items survive trimming; non-protected items are dropped from end.
68
+ *
69
+ * @param {string[]} items - Items to potentially trim
70
+ * @param {Set<string>} protectedSet - Items exempt from trimming
71
+ * @param {number} max - Maximum allowed count
72
+ * @returns {{ result: string[], trimmed: boolean, warnings: string[] }}
73
+ */
74
+ function trimWithProtection(items, protectedSet, max, label) {
75
+ if (items.length <= max) {
76
+ return { result: items, trimmed: false, warnings: [] };
77
+ }
78
+
79
+ const protectedArr = items.filter((i) => protectedSet.has(i));
80
+ const nonProtected = items.filter((i) => !protectedSet.has(i));
81
+ const slotsForNonProtected = Math.max(0, max - protectedArr.length);
82
+ const result = [...protectedArr, ...nonProtected.slice(0, slotsForNonProtected)];
83
+
84
+ const warnings = [
85
+ `${label} budget exceeded: ${items.length}/${max} — trimmed to ${result.length}`,
86
+ ];
87
+
88
+ if (protectedArr.length > max) {
89
+ warnings.push(`Protected ${label.toLowerCase()} (${protectedArr.length}) exceed budget (${max}) — budget override in effect`);
90
+ }
91
+
92
+ return { result, trimmed: true, warnings };
93
+ }
94
+
95
+ /**
96
+ * Builds a pre-compiled word-boundary regex from an array of trigger strings.
97
+ * Combines all triggers into a single alternation pattern for efficient matching.
98
+ *
99
+ * @param {string[]} triggers - Trigger strings to compile
100
+ * @returns {RegExp | null} Compiled regex, or null if no triggers
101
+ */
102
+ function buildImplicitTriggerRegex(triggers) {
103
+ if (!triggers || triggers.length === 0) {
104
+ return null;
105
+ }
106
+
107
+ const pattern = triggers
108
+ .map((t) => t.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
109
+ .join('|');
110
+
111
+ return new RegExp(`\\b(?:${pattern})\\b`);
112
+ }
113
+
50
114
  /**
51
115
  * Matches a task description against domain keywords.
52
116
  *
@@ -55,7 +119,22 @@ function loadRules(projectRoot) {
55
119
  * @returns {{ matchedDomains: string[], agents: string[], skills: string[] }}
56
120
  */
57
121
  function resolveForTask(taskDescription, projectRoot) {
122
+ if (typeof taskDescription !== 'string') {
123
+ return { matchedDomains: [], agents: [], skills: [] };
124
+ }
125
+
58
126
  const rules = loadRules(projectRoot);
127
+ return resolveForTaskWithRules(taskDescription, rules);
128
+ }
129
+
130
+ /**
131
+ * Internal: Matches a task description against domain keywords using pre-loaded rules.
132
+ *
133
+ * @param {string} taskDescription - Human-readable task text
134
+ * @param {object} rules - Pre-loaded rules object
135
+ * @returns {{ matchedDomains: string[], agents: string[], skills: string[] }}
136
+ */
137
+ function resolveForTaskWithRules(taskDescription, rules) {
59
138
  const domainRules = rules.domainRules || [];
60
139
  const lowerTask = taskDescription.toLowerCase();
61
140
 
@@ -88,6 +167,83 @@ function resolveForTask(taskDescription, projectRoot) {
88
167
  };
89
168
  }
90
169
 
170
+ /**
171
+ * Enhanced task resolution for planning workflows.
172
+ * Extends standard keyword matching with implicit trigger detection
173
+ * and mandatory planning resources from planningMandates config.
174
+ *
175
+ * @param {string} taskDescription - Human-readable task text
176
+ * @param {string} projectRoot - Root directory of the project
177
+ * @returns {{ matchedDomains: string[], agents: string[], skills: string[], mandatoryRules: string[] }}
178
+ */
179
+ function resolveForPlanning(taskDescription, projectRoot) {
180
+ if (typeof taskDescription !== 'string') {
181
+ return { matchedDomains: [], agents: [], skills: [], mandatoryRules: [] };
182
+ }
183
+
184
+ const rules = loadRules(projectRoot);
185
+ return resolveForPlanningWithRules(taskDescription, rules);
186
+ }
187
+
188
+ /**
189
+ * Internal: Enhanced planning resolution using pre-loaded rules.
190
+ *
191
+ * @param {string} taskDescription - Human-readable task text
192
+ * @param {object} rules - Pre-loaded rules object
193
+ * @returns {{ matchedDomains: string[], agents: string[], skills: string[], mandatoryRules: string[] }}
194
+ */
195
+ function resolveForPlanningWithRules(taskDescription, rules) {
196
+ const domainRules = rules.domainRules || [];
197
+ const planningMandates = rules.planningMandates || {};
198
+ const lowerTask = taskDescription.toLowerCase();
199
+
200
+ /** @type {Set<string>} */
201
+ const agents = new Set();
202
+ /** @type {Set<string>} */
203
+ const skills = new Set();
204
+ /** @type {string[]} */
205
+ const matchedDomains = [];
206
+
207
+ // Step 1: Standard keyword matching + implicit trigger detection
208
+ for (const rule of domainRules) {
209
+ const hasKeywordMatch = (rule.keywords || []).some(
210
+ (keyword) => lowerTask.includes(keyword.toLowerCase())
211
+ );
212
+
213
+ // Pre-compile a single regex for all implicit triggers per domain rule
214
+ const triggerRegex = buildImplicitTriggerRegex(rule.implicitTriggers);
215
+ const hasImplicitMatch = triggerRegex !== null && triggerRegex.test(lowerTask);
216
+
217
+ if (hasKeywordMatch || hasImplicitMatch) {
218
+ matchedDomains.push(rule.domain);
219
+
220
+ for (const agent of (rule.loadAgents || [])) {
221
+ agents.add(agent);
222
+ }
223
+ for (const skill of (rule.loadSkills || [])) {
224
+ skills.add(skill);
225
+ }
226
+ }
227
+ }
228
+
229
+ // Step 2: Merge mandatory planning skills
230
+ for (const skill of (planningMandates.alwaysLoadSkills || [])) {
231
+ skills.add(skill);
232
+ }
233
+
234
+ // Step 3: Build mandatory rules list (file paths for planner to reference)
235
+ const mandatoryRules = (planningMandates.alwaysLoadRules || []).map(
236
+ (ruleName) => path.join(AGENT_DIR, 'rules', `${ruleName}.md`)
237
+ );
238
+
239
+ return {
240
+ matchedDomains,
241
+ agents: [...agents],
242
+ skills: [...skills],
243
+ mandatoryRules,
244
+ };
245
+ }
246
+
91
247
  /**
92
248
  * Resolves agents and skills for a named workflow using workflow bindings.
93
249
  *
@@ -97,6 +253,17 @@ function resolveForTask(taskDescription, projectRoot) {
97
253
  */
98
254
  function resolveForWorkflow(workflowName, projectRoot) {
99
255
  const rules = loadRules(projectRoot);
256
+ return resolveForWorkflowWithRules(workflowName, rules);
257
+ }
258
+
259
+ /**
260
+ * Internal: Resolves workflow bindings using pre-loaded rules.
261
+ *
262
+ * @param {string} workflowName - Name of the workflow
263
+ * @param {object} rules - Pre-loaded rules object
264
+ * @returns {{ agents: string[], skills: string[], bindingType: string }}
265
+ */
266
+ function resolveForWorkflowWithRules(workflowName, rules) {
100
267
  const bindings = rules.workflowBindings || [];
101
268
  const match = bindings.find((b) => b.workflow === workflowName);
102
269
 
@@ -113,47 +280,62 @@ function resolveForWorkflow(workflowName, projectRoot) {
113
280
 
114
281
  /**
115
282
  * Enforces context budget limits by trimming agents and skills.
283
+ * Protected items (from planning mandates) are exempt from trimming.
116
284
  *
117
285
  * @param {string[]} agents - Candidate agents
118
286
  * @param {string[]} skills - Candidate skills
119
287
  * @param {string} projectRoot - Root directory of the project
288
+ * @param {ProtectedItems} [protectedItems] - Items exempt from budget trimming
120
289
  * @returns {{ agents: string[], skills: string[], trimmed: boolean, warnings: string[] }}
121
290
  */
122
- function enforceContextBudget(agents, skills, projectRoot) {
291
+ function enforceContextBudget(agents, skills, projectRoot, protectedItems) {
123
292
  const rules = loadRules(projectRoot);
293
+ return enforceContextBudgetWithRules(agents, skills, rules, protectedItems);
294
+ }
295
+
296
+ /**
297
+ * Internal: Enforces context budget using pre-loaded rules.
298
+ *
299
+ * @param {string[]} agents - Candidate agents
300
+ * @param {string[]} skills - Candidate skills
301
+ * @param {object} rules - Pre-loaded rules object
302
+ * @param {ProtectedItems} [protectedItems] - Items exempt from budget trimming
303
+ * @returns {{ agents: string[], skills: string[], trimmed: boolean, warnings: string[] }}
304
+ */
305
+ function enforceContextBudgetWithRules(agents, skills, rules, protectedItems) {
124
306
  const budget = rules.contextBudget || {};
125
- const maxAgents = budget.maxAgentsPerSession || 4;
126
- const maxSkills = budget.maxSkillsPerSession || 6;
127
- const warningThreshold = (budget.warningThresholdPercent || 80) / 100;
307
+ const maxAgents = budget.maxAgentsPerSession || DEFAULT_MAX_AGENTS;
308
+ const maxSkills = budget.maxSkillsPerSession || DEFAULT_MAX_SKILLS;
309
+ const warningThreshold = (budget.warningThresholdPercent || DEFAULT_WARNING_THRESHOLD_PERCENT) / 100;
128
310
 
129
- /** @type {string[]} */
130
- const warnings = [];
131
- let trimmed = false;
132
-
133
- let finalAgents = [...agents];
134
- let finalSkills = [...skills];
135
-
136
- if (finalAgents.length > maxAgents) {
137
- warnings.push(`Agent budget exceeded: ${finalAgents.length}/${maxAgents} — trimmed to ${maxAgents}`);
138
- finalAgents = finalAgents.slice(0, maxAgents);
139
- trimmed = true;
140
- } else if (finalAgents.length >= maxAgents * warningThreshold) {
141
- warnings.push(`Agent budget near limit: ${finalAgents.length}/${maxAgents} (${Math.round(finalAgents.length / maxAgents * 100)}%)`);
142
- }
311
+ const protectedAgentSet = new Set((protectedItems && protectedItems.agents) || []);
312
+ const protectedSkillSet = new Set((protectedItems && protectedItems.skills) || []);
313
+
314
+ const agentTrim = trimWithProtection([...agents], protectedAgentSet, maxAgents, 'Agent');
315
+ const skillTrim = trimWithProtection([...skills], protectedSkillSet, maxSkills, 'Skill');
316
+
317
+ const warnings = [...agentTrim.warnings, ...skillTrim.warnings];
143
318
 
144
- if (finalSkills.length > maxSkills) {
145
- warnings.push(`Skill budget exceeded: ${finalSkills.length}/${maxSkills} trimmed to ${maxSkills}`);
146
- finalSkills = finalSkills.slice(0, maxSkills);
147
- trimmed = true;
148
- } else if (finalSkills.length >= maxSkills * warningThreshold) {
149
- warnings.push(`Skill budget near limit: ${finalSkills.length}/${maxSkills} (${Math.round(finalSkills.length / maxSkills * 100)}%)`);
319
+ // Add near-limit warnings for non-trimmed cases
320
+ if (!agentTrim.trimmed && agentTrim.result.length >= maxAgents * warningThreshold) {
321
+ warnings.push(`Agent budget near limit: ${agentTrim.result.length}/${maxAgents} (${Math.round(agentTrim.result.length / maxAgents * 100)}%)`);
322
+ }
323
+ if (!skillTrim.trimmed && skillTrim.result.length >= maxSkills * warningThreshold) {
324
+ warnings.push(`Skill budget near limit: ${skillTrim.result.length}/${maxSkills} (${Math.round(skillTrim.result.length / maxSkills * 100)}%)`);
150
325
  }
151
326
 
152
- return { agents: finalAgents, skills: finalSkills, trimmed, warnings };
327
+ return {
328
+ agents: agentTrim.result,
329
+ skills: skillTrim.result,
330
+ trimmed: agentTrim.trimmed || skillTrim.trimmed,
331
+ warnings,
332
+ };
153
333
  }
154
334
 
155
335
  /**
156
336
  * Full resolution: combines domain matching, workflow binding, and budget enforcement.
337
+ * Uses enhanced planning resolution when workflow is 'plan'.
338
+ * Loads rules once and passes through to all internal functions.
157
339
  *
158
340
  * @param {string} taskDescription - Task text for domain matching
159
341
  * @param {string} [workflowName] - Optional workflow name for binding resolution
@@ -161,11 +343,28 @@ function enforceContextBudget(agents, skills, projectRoot) {
161
343
  * @returns {LoadPlan}
162
344
  */
163
345
  function getLoadPlan(taskDescription, workflowName, projectRoot) {
346
+ if (typeof taskDescription !== 'string') {
347
+ return {
348
+ agents: [],
349
+ skills: [],
350
+ warnings: ['Invalid task description'],
351
+ budgetUsage: { agentsUsed: 0, agentsMax: DEFAULT_MAX_AGENTS, skillsUsed: 0, skillsMax: DEFAULT_MAX_SKILLS },
352
+ matchedDomains: [],
353
+ };
354
+ }
355
+
356
+ // Load rules ONCE and pass through to all helpers (H-1 fix)
164
357
  const rules = loadRules(projectRoot);
165
358
  const budget = rules.contextBudget || {};
359
+ const planningMandates = rules.planningMandates || {};
360
+ const isPlanWorkflow = workflowName === 'plan';
361
+ const maxAgents = budget.maxAgentsPerSession || DEFAULT_MAX_AGENTS;
362
+ const maxSkills = budget.maxSkillsPerSession || DEFAULT_MAX_SKILLS;
166
363
 
167
- // Step 1: Domain keyword matching
168
- const taskResolution = resolveForTask(taskDescription, projectRoot);
364
+ // Step 1: Domain matching (enhanced for planning workflows)
365
+ const taskResolution = isPlanWorkflow
366
+ ? resolveForPlanningWithRules(taskDescription, rules)
367
+ : resolveForTaskWithRules(taskDescription, rules);
169
368
 
170
369
  // Step 2: Workflow bindings (if workflow specified)
171
370
  /** @type {Set<string>} */
@@ -174,7 +373,7 @@ function getLoadPlan(taskDescription, workflowName, projectRoot) {
174
373
  const allSkills = new Set(taskResolution.skills);
175
374
 
176
375
  if (workflowName) {
177
- const wfResolution = resolveForWorkflow(workflowName, projectRoot);
376
+ const wfResolution = resolveForWorkflowWithRules(workflowName, rules);
178
377
  for (const agent of wfResolution.agents) {
179
378
  allAgents.add(agent);
180
379
  }
@@ -183,25 +382,39 @@ function getLoadPlan(taskDescription, workflowName, projectRoot) {
183
382
  }
184
383
  }
185
384
 
186
- // Step 3: Budget enforcement
187
- const budgetResult = enforceContextBudget([...allAgents], [...allSkills], projectRoot);
385
+ // Step 3: Budget enforcement (with protected items for planning)
386
+ const protectedItems = isPlanWorkflow
387
+ ? { agents: [], skills: planningMandates.alwaysLoadSkills || [] }
388
+ : undefined;
389
+
390
+ const budgetResult = enforceContextBudgetWithRules(
391
+ [...allAgents],
392
+ [...allSkills],
393
+ rules,
394
+ protectedItems
395
+ );
188
396
 
397
+ // Construct complete object in one expression (H-3 immutability fix)
189
398
  return {
190
399
  agents: budgetResult.agents,
191
400
  skills: budgetResult.skills,
192
401
  warnings: budgetResult.warnings,
193
402
  budgetUsage: {
194
403
  agentsUsed: budgetResult.agents.length,
195
- agentsMax: budget.maxAgentsPerSession || 4,
404
+ agentsMax: maxAgents,
196
405
  skillsUsed: budgetResult.skills.length,
197
- skillsMax: budget.maxSkillsPerSession || 6,
406
+ skillsMax: maxSkills,
198
407
  },
199
408
  matchedDomains: taskResolution.matchedDomains,
409
+ ...(isPlanWorkflow && taskResolution.mandatoryRules
410
+ ? { mandatoryRules: taskResolution.mandatoryRules }
411
+ : {}),
200
412
  };
201
413
  }
202
414
 
203
415
  module.exports = {
204
416
  resolveForTask,
417
+ resolveForPlanning,
205
418
  resolveForWorkflow,
206
419
  enforceContextBudget,
207
420
  getLoadPlan,
package/lib/logger.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Antigravity AI Kit — Structured Logger
3
+ *
4
+ * Provides structured JSON logging with log levels, correlation IDs,
5
+ * and module context. Replaces raw console.log across all runtime modules.
6
+ *
7
+ * @module lib/logger
8
+ * @author Emre Dursun
9
+ * @since v3.2.0
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const crypto = require('crypto');
15
+
16
+ /** @typedef {'debug' | 'info' | 'warn' | 'error'} LogLevel */
17
+
18
+ const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
19
+
20
+ /** Default minimum log level */
21
+ let minLevel = LOG_LEVELS[process.env.AG_LOG_LEVEL] ?? LOG_LEVELS.info;
22
+
23
+ /** Output mode: 'json' for structured, 'text' for human-readable */
24
+ let outputMode = process.env.AG_LOG_FORMAT === 'json' ? 'json' : 'text';
25
+
26
+ /**
27
+ * Configures the logger.
28
+ *
29
+ * @param {object} options - Logger configuration
30
+ * @param {LogLevel} [options.level] - Minimum log level
31
+ * @param {'json' | 'text'} [options.format] - Output format
32
+ * @returns {void}
33
+ */
34
+ function configure(options = {}) {
35
+ if (options.level && LOG_LEVELS[options.level] !== undefined) {
36
+ minLevel = LOG_LEVELS[options.level];
37
+ }
38
+ if (options.format === 'json' || options.format === 'text') {
39
+ outputMode = options.format;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Generates a short correlation ID for request tracing.
45
+ *
46
+ * @returns {string} 8-character hex correlation ID
47
+ */
48
+ function correlationId() {
49
+ return crypto.randomBytes(4).toString('hex');
50
+ }
51
+
52
+ /**
53
+ * Creates a child logger scoped to a specific module.
54
+ *
55
+ * @param {string} moduleName - Module name for context
56
+ * @param {string} [corrId] - Optional correlation ID (auto-generated if omitted)
57
+ * @returns {{ debug: Function, info: Function, warn: Function, error: Function, child: Function }}
58
+ */
59
+ function createLogger(moduleName, corrId) {
60
+ const cid = corrId || correlationId();
61
+
62
+ /**
63
+ * Emits a structured log entry.
64
+ *
65
+ * @param {LogLevel} level - Log level
66
+ * @param {string} message - Log message
67
+ * @param {object} [data] - Additional structured data
68
+ * @returns {void}
69
+ */
70
+ function emit(level, message, data) {
71
+ if (LOG_LEVELS[level] < minLevel) {
72
+ return;
73
+ }
74
+
75
+ const entry = {
76
+ timestamp: new Date().toISOString(),
77
+ level,
78
+ module: moduleName,
79
+ correlationId: cid,
80
+ message,
81
+ ...(data && Object.keys(data).length > 0 ? { data } : {}),
82
+ };
83
+
84
+ if (outputMode === 'json') {
85
+ const stream = level === 'error' ? process.stderr : process.stdout;
86
+ stream.write(JSON.stringify(entry) + '\n');
87
+ } else {
88
+ const prefix = `[${entry.timestamp.slice(11, 19)}] [${level.toUpperCase().padEnd(5)}] [${moduleName}]`;
89
+ const suffix = data && Object.keys(data).length > 0
90
+ ? ` ${JSON.stringify(data)}`
91
+ : '';
92
+ const stream = level === 'error' ? console.error : console.log;
93
+ stream(`${prefix} ${message}${suffix}`);
94
+ }
95
+ }
96
+
97
+ return {
98
+ debug: (message, data) => emit('debug', message, data),
99
+ info: (message, data) => emit('info', message, data),
100
+ warn: (message, data) => emit('warn', message, data),
101
+ error: (message, data) => emit('error', message, data),
102
+
103
+ /**
104
+ * Creates a child logger inheriting the correlation ID.
105
+ *
106
+ * @param {string} childModule - Child module name
107
+ * @returns {ReturnType<typeof createLogger>}
108
+ */
109
+ child: (childModule) => createLogger(childModule, cid),
110
+ };
111
+ }
112
+
113
+ module.exports = {
114
+ createLogger,
115
+ correlationId,
116
+ configure,
117
+ LOG_LEVELS,
118
+ };
@@ -13,10 +13,14 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
- const { execSync } = require('child_process');
17
-
18
- const AGENT_DIR = '.agent';
19
- const ENGINE_DIR = 'engine';
16
+ const { execFileSync } = require('child_process');
17
+ const { createCircuitBreaker } = require('./circuit-breaker');
18
+ const { createRateLimiter } = require('./rate-limiter');
19
+
20
+ const { AGENT_DIR, ENGINE_DIR } = require('./constants');
21
+ const { writeJsonAtomic } = require('./io');
22
+ const { createLogger } = require('./logger');
23
+ const log = createLogger('marketplace');
20
24
  const INDEX_FILE = 'marketplace-index.json';
21
25
 
22
26
  /** Registry index TTL in milliseconds (24 hours) */
@@ -25,6 +29,18 @@ const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
25
29
  /** Git clone timeout in milliseconds */
26
30
  const GIT_CLONE_TIMEOUT_MS = 30000;
27
31
 
32
+ /** Circuit breaker for git clone operations */
33
+ const gitCloneBreaker = createCircuitBreaker('marketplace-git-clone', {
34
+ failureThreshold: 3,
35
+ resetTimeoutMs: 120000,
36
+ });
37
+
38
+ /** Rate limiter for marketplace install operations (5 per minute) */
39
+ const installLimiter = createRateLimiter('marketplace-install', {
40
+ maxTokens: 5,
41
+ refillRateMs: 60000,
42
+ });
43
+
28
44
  /**
29
45
  * @typedef {object} MarketEntry
30
46
  * @property {string} name - Plugin name
@@ -74,15 +90,7 @@ function loadIndex(projectRoot) {
74
90
  */
75
91
  function writeIndex(projectRoot, data) {
76
92
  const filePath = resolveIndexPath(projectRoot);
77
- const dir = path.dirname(filePath);
78
-
79
- if (!fs.existsSync(dir)) {
80
- fs.mkdirSync(dir, { recursive: true });
81
- }
82
-
83
- const tempPath = `${filePath}.tmp`;
84
- fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
85
- fs.renameSync(tempPath, filePath);
93
+ writeJsonAtomic(filePath, data);
86
94
  }
87
95
 
88
96
  /**
@@ -209,18 +217,30 @@ function installFromMarket(projectRoot, pluginName) {
209
217
  return { success: false, message: `Invalid repository URL: ${entry.repository}` };
210
218
  }
211
219
 
220
+ // Rate limit check
221
+ const rateCheck = installLimiter.tryAcquire();
222
+ if (!rateCheck.allowed) {
223
+ return {
224
+ success: false,
225
+ message: `Rate limit exceeded — retry after ${Math.ceil(rateCheck.retryAfterMs / 1000)}s`,
226
+ };
227
+ }
228
+
212
229
  // Create temp directory for clone
213
230
  const tempDir = path.join(projectRoot, AGENT_DIR, ENGINE_DIR, `_temp_${Date.now()}`);
214
231
 
215
232
  try {
216
233
  fs.mkdirSync(tempDir, { recursive: true });
217
234
 
218
- // Shallow clone with timeout (A-4)
235
+ // Shallow clone with circuit breaker and timeout (uses execFileSync to prevent shell injection)
219
236
  try {
220
- execSync(
221
- `git clone --depth 1 --single-branch "${entry.repository}" "${tempDir}"`,
222
- { timeout: GIT_CLONE_TIMEOUT_MS, stdio: 'pipe' }
223
- );
237
+ gitCloneBreaker.execute(() => {
238
+ execFileSync(
239
+ 'git',
240
+ ['clone', '--depth', '1', '--single-branch', entry.repository, tempDir],
241
+ { timeout: GIT_CLONE_TIMEOUT_MS, stdio: 'pipe' }
242
+ );
243
+ });
224
244
  } catch (gitError) {
225
245
  return { success: false, message: `Git clone failed: ${gitError.message || 'timeout or network error'}` };
226
246
  }
@@ -250,8 +270,11 @@ function installFromMarket(projectRoot, pluginName) {
250
270
  // Delegate to plugin system for actual installation
251
271
  try {
252
272
  const pluginSystem = require('./plugin-system');
253
- const result = pluginSystem.installPlugin(projectRoot, tempDir);
254
- return { success: result.success, message: result.message || 'Plugin installed successfully' };
273
+ const result = pluginSystem.installPlugin(tempDir, projectRoot);
274
+ const message = result.errors?.length > 0
275
+ ? result.errors.join('; ')
276
+ : 'Plugin installed successfully';
277
+ return { success: result.success, message };
255
278
  } catch (installError) {
256
279
  return { success: false, message: `Plugin installation failed: ${installError.message}` };
257
280
  }