micro-models-agent 0.39.0 → 0.40.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 (96) hide show
  1. package/bin/mma.mjs +41 -41
  2. package/dist/cli/commands.js +116 -3
  3. package/dist/cli/main.js +35 -8
  4. package/dist/cli/repl-commands.js +633 -0
  5. package/dist/cli/repl.js +110 -611
  6. package/dist/cli/setup.js +32 -12
  7. package/dist/config/config.js +46 -30
  8. package/dist/config/defaults.js +10 -1
  9. package/dist/config/security.js +15 -8
  10. package/dist/core/agent-moe.js +24 -12
  11. package/dist/core/agent.js +281 -47
  12. package/dist/core/bootstrap.js +52 -36
  13. package/dist/core/session-logger.js +35 -2
  14. package/dist/core/workspace.js +76 -0
  15. package/dist/i18n/en.json +79 -15
  16. package/dist/i18n/index.js +12 -9
  17. package/dist/i18n/ru.json +79 -15
  18. package/dist/index.js +13 -13
  19. package/dist/llm/openai-compat.js +39 -10
  20. package/dist/logger/app-logger.js +83 -16
  21. package/dist/logger/file-log.js +151 -0
  22. package/dist/main.js +537 -284
  23. package/dist/modules/browser/bridge-server.mjs +113 -105
  24. package/dist/modules/browser/session.js +108 -60
  25. package/dist/modules/certification/cli.js +176 -0
  26. package/dist/modules/certification/fact-checker.js +84 -0
  27. package/dist/modules/certification/loader.js +111 -0
  28. package/dist/modules/certification/manifest.js +50 -0
  29. package/dist/modules/certification/runner.js +162 -0
  30. package/dist/modules/certification/scenarios.js +124 -0
  31. package/dist/modules/certification/types.js +1 -0
  32. package/dist/modules/context/manager.js +119 -10
  33. package/dist/modules/execution/auditor.js +33 -39
  34. package/dist/modules/execution/index.js +8 -6
  35. package/dist/modules/execution/module.js +474 -32
  36. package/dist/modules/execution/moe-executor.js +97 -40
  37. package/dist/modules/execution/plan-coverage.js +68 -0
  38. package/dist/modules/execution/plan-persister.js +46 -0
  39. package/dist/modules/execution/plan-store.js +159 -0
  40. package/dist/modules/execution/planner.js +63 -13
  41. package/dist/modules/execution/stuck-detector.js +252 -39
  42. package/dist/modules/execution/tracker.js +21 -7
  43. package/dist/modules/execution/verifier.js +46 -17
  44. package/dist/modules/hallucination/confidence.js +7 -2
  45. package/dist/modules/hallucination/consistency.js +8 -42
  46. package/dist/modules/hallucination/detector.js +26 -21
  47. package/dist/modules/hallucination/factual.js +170 -150
  48. package/dist/modules/hallucination/index.js +5 -4
  49. package/dist/modules/hallucination/js-identifiers.js +72 -0
  50. package/dist/modules/hallucination/llm-judge.js +103 -0
  51. package/dist/modules/index.js +5 -5
  52. package/dist/modules/lsp/client.js +235 -0
  53. package/dist/modules/lsp/config.js +81 -0
  54. package/dist/modules/lsp/index.js +3 -0
  55. package/dist/modules/lsp/module.js +68 -0
  56. package/dist/modules/lsp/types.js +1 -0
  57. package/dist/modules/mcp/client.js +8 -2
  58. package/dist/modules/memory/store.js +4 -0
  59. package/dist/modules/plugins/builtin/lint-on-write.js +143 -38
  60. package/dist/modules/processes/index.js +1 -2
  61. package/dist/modules/processes/registry.js +125 -35
  62. package/dist/modules/processes/runner.js +9 -110
  63. package/dist/modules/security/audit-log.js +30 -10
  64. package/dist/modules/security/command-validator.js +42 -16
  65. package/dist/modules/security/content-scanner.js +9 -8
  66. package/dist/modules/security/network-validator.js +2 -2
  67. package/dist/modules/security/path-validator.js +64 -10
  68. package/dist/modules/security/security-policies.js +221 -67
  69. package/dist/modules/security/session-encryption.js +42 -25
  70. package/dist/modules/session/manager.js +15 -10
  71. package/dist/modules/session/store.js +62 -8
  72. package/dist/modules/skills/index.js +2 -3
  73. package/dist/modules/skills/module.js +10 -23
  74. package/dist/tools/bash.js +287 -90
  75. package/dist/tools/create-dir.js +0 -1
  76. package/dist/tools/delete-file.js +0 -1
  77. package/dist/tools/edit-file.js +10 -8
  78. package/dist/tools/executor.js +57 -7
  79. package/dist/tools/grep-tool.js +51 -29
  80. package/dist/tools/index.js +55 -40
  81. package/dist/tools/load-skill.js +14 -18
  82. package/dist/tools/move-file.js +3 -2
  83. package/dist/tools/pipeline-run.js +1 -1
  84. package/dist/tools/read-file.js +15 -5
  85. package/dist/tools/search-history.js +42 -22
  86. package/dist/tools/subagent.js +21 -12
  87. package/dist/tools/web-browse.js +54 -25
  88. package/dist/tools/web-fetch.js +60 -34
  89. package/dist/tools/web-search.js +39 -20
  90. package/dist/tools/write-file.js +13 -10
  91. package/dist/ui/diff.js +9 -16
  92. package/dist/ui/renderer.js +69 -6
  93. package/package.json +48 -45
  94. package/dist/modules/context/history.js +0 -15
  95. package/dist/modules/processes/detect.js +0 -34
  96. package/dist/modules/skills/matcher.js +0 -27
@@ -4,29 +4,68 @@ import { PlanTracker } from "./tracker";
4
4
  import { StepVerifier } from "./verifier";
5
5
  import { StuckDetector } from "./stuck-detector";
6
6
  import { Auditor } from "./auditor";
7
- import { existsSync } from "fs";
7
+ import { PlanStore } from "./plan-store";
8
+ import { checkPlanCoverage } from "./plan-coverage";
9
+ import { getMessageText } from "../../llm/provider";
10
+ import { existsSync, readFileSync } from "fs";
8
11
  import { resolve } from "path";
9
12
  const STUCK_RECOVERY_COOLDOWN = 5;
13
+ const MAX_PLAN_WARNINGS_BEFORE_BLOCK = 3;
14
+ const FORCE_SKIP_THRESHOLD = 10;
10
15
  export class ExecutionModule {
11
16
  name = "execution";
12
17
  tracker = null;
13
18
  verifier;
14
19
  stuckDetector;
15
20
  auditor;
21
+ store;
16
22
  baseDir;
17
23
  lastRecoveryIteration = -STUCK_RECOVERY_COOLDOWN;
18
- constructor(baseDir, stuckThreshold = 8) {
24
+ consecutivePlanWarnings = 0;
25
+ lastStepId = -1;
26
+ // How many times the deps-step gate has warned for each step id — used to
27
+ // escalate from a soft hint to a MUST-update instruction (models otherwise
28
+ // spin on "install dependencies" steps they invented themselves).
29
+ depsGateHints = new Map();
30
+ // Skip the first audit after restoring a plan from disk — gives the user
31
+ // one chance to change the subject before the audit gate locks in.
32
+ _auditSkipsRemaining = 0;
33
+ stuckNotified = false;
34
+ /**
35
+ * Deferred <system-summary> messages. Injected by onBeforeTool/onAfterTool
36
+ * but flushed in onBeforeThink, so they never land between an assistant
37
+ * tool_call message and its tool results (which breaks message pairing and
38
+ * buries the warnings the model should react to).
39
+ */
40
+ pendingMessages = [];
41
+ constructor(baseDir, stuckThreshold = 6) {
19
42
  this.baseDir = baseDir;
20
43
  this.verifier = new StepVerifier(baseDir);
21
44
  this.stuckDetector = new StuckDetector(stuckThreshold);
22
45
  this.auditor = new Auditor(baseDir);
46
+ this.store = new PlanStore(baseDir);
23
47
  }
24
48
  setPlan(plan) {
25
49
  this.tracker = new PlanTracker(plan);
50
+ this.store.saveActive(plan);
51
+ this._auditSkipsRemaining = 0; // plan explicitly created — arm the audit gate
52
+ }
53
+ restorePlan() {
54
+ const plan = this.store.loadActive();
55
+ if (!plan)
56
+ return false;
57
+ this.tracker = new PlanTracker(plan);
58
+ // Advance to the first non-done step
59
+ this.tracker.syncCurrentStep();
60
+ this._auditSkipsRemaining = 1; // restored plan — one free pass
61
+ return true;
26
62
  }
27
63
  getTracker() {
28
64
  return this.tracker;
29
65
  }
66
+ getStore() {
67
+ return this.store;
68
+ }
30
69
  getStuckDetector() {
31
70
  return this.stuckDetector;
32
71
  }
@@ -37,11 +76,15 @@ export class ExecutionModule {
37
76
  async runFinalAudit() {
38
77
  if (!this.tracker)
39
78
  return null;
79
+ if (this._auditSkipsRemaining > 0) {
80
+ this._auditSkipsRemaining--;
81
+ return null;
82
+ }
40
83
  const plan = this.tracker.getPlan();
41
84
  const audit = await this.auditor.audit(plan);
42
- const pendingSteps = plan.steps
43
- .filter((s) => s.status !== "done" && s.status !== "skipped")
44
- .map((s) => `${s.id}. ${s.description}`);
85
+ const pendingSteps = plan.steps.flatMap((s) => s.status !== "done" && s.status !== "skipped"
86
+ ? [`${s.id}. ${s.description}`]
87
+ : []);
45
88
  const done = plan.steps.filter((s) => s.status === "done").length;
46
89
  const passed = audit.passed && pendingSteps.length === 0;
47
90
  return {
@@ -67,24 +110,106 @@ export class ExecutionModule {
67
110
  return [
68
111
  {
69
112
  name: "plan",
70
- description: 'Create, update, show, or abort a multi-step plan. Use "create" at the start of complex tasks. Use "update" after completing each step to track progress. Use "show" to re-print the current plan checklist.',
113
+ description: 'Create, update, show, abort, list, switch, or re-plan multi-step plans.\n\nActions:\n- create: Start a new plan. Previous active plan is auto-preserved: incomplete → draft, complete → archive.\n- update: Mark step status (done/failed/skipped), or rebuild plan with new steps.\n- show: Print current plan checklist.\n- abort: Archive current plan and clear active slot.\n- list: Show all plans (active, drafts, archived) with progress.\n- switch: Make a different plan active (by plan id).\n- re-plan: Iterative replanning: keep completed steps, replace remaining with new steps.\n\nWrite CONCRETE steps with exact file paths and commands:\n- Specify WHICH files to create with exact paths (e.g. "create src/components/Header.tsx with navigation and logo")\n- Specify WHICH packages to install (e.g. "run npm install react react-dom")\n- Specify WHICH CLI commands to run with exact arguments\n- Each step should include at least one file extension (.ts, .tsx, .json, etc.) or a command verb (install, create, build, run, add, init)\n- Good: "Создать src/components/Header.tsx с навигацией и логотипом"\n- Bad: "Настройка проекта" (too vague — what exactly needs to be configured?)',
71
114
  parameters: {
72
115
  type: "object",
73
116
  properties: {
74
117
  action: {
75
118
  type: "string",
76
- enum: ["create", "update", "show", "abort"],
119
+ enum: [
120
+ "create",
121
+ "update",
122
+ "show",
123
+ "abort",
124
+ "list",
125
+ "switch",
126
+ "re-plan",
127
+ ],
77
128
  },
78
129
  title: { type: "string" },
79
130
  steps: { type: "array", items: { type: "string" } },
80
131
  step: { type: "number" },
81
132
  status: { type: "string", enum: ["done", "failed", "skipped"] },
82
133
  note: { type: "string" },
134
+ id: { type: "string", description: "Plan id (for switch action)" },
83
135
  },
84
136
  required: ["action"],
85
137
  },
86
138
  handler: async (_ctx, args) => {
87
139
  const action = String(args.action);
140
+ if (action === "list") {
141
+ const metas = this.store.listAll();
142
+ if (metas.length === 0) {
143
+ return { success: true, output: t("plan.list_empty") };
144
+ }
145
+ const lines = metas.map((m) => {
146
+ const icon = m.status === "active"
147
+ ? "[*]"
148
+ : m.status === "draft"
149
+ ? "[ ]"
150
+ : "[-]";
151
+ const namePart = m.name ? ` (${m.name})` : "";
152
+ return `${icon} ${m.id}${namePart} — ${m.title} ${m.doneCount}/${m.stepCount}`;
153
+ });
154
+ return {
155
+ success: true,
156
+ output: `${t("plan.list_header")}\n${lines.join("\n")}`,
157
+ };
158
+ }
159
+ if (action === "switch") {
160
+ const planId = String(args.id || "");
161
+ if (!planId) {
162
+ return { success: false, output: t("plan.switch_no_id") };
163
+ }
164
+ const found = this.store.find(planId);
165
+ if (!found) {
166
+ return {
167
+ success: false,
168
+ output: t("plan.not_found", { id: planId }),
169
+ };
170
+ }
171
+ this.preserveActive();
172
+ if (found.status === "draft") {
173
+ this.store.removeDraft(found.plan.id);
174
+ }
175
+ else if (found.status === "archived") {
176
+ this.store.removeArchived(found.plan.id);
177
+ }
178
+ this.setPlan(found.plan);
179
+ const display = PlanCreator.toPromptBlock(found.plan, 0);
180
+ return {
181
+ success: true,
182
+ output: t("plan.switched", {
183
+ id: found.plan.id,
184
+ title: found.plan.title,
185
+ }),
186
+ display,
187
+ };
188
+ }
189
+ if (action === "re-plan") {
190
+ if (!this.tracker) {
191
+ return { success: false, output: t("plan.no_active") };
192
+ }
193
+ const newSteps = Array.isArray(args.steps)
194
+ ? args.steps.map(String)
195
+ : [];
196
+ if (newSteps.length === 0) {
197
+ return { success: false, output: t("plan.replan_no_steps") };
198
+ }
199
+ const oldPlan = this.tracker.getPlan();
200
+ const replanned = PlanCreator.replan(oldPlan, newSteps, args.title ? String(args.title) : undefined);
201
+ const keptCount = replanned.steps.length - newSteps.length;
202
+ this.setPlan(replanned);
203
+ const display = PlanCreator.toPromptBlock(replanned, keptCount);
204
+ return {
205
+ success: true,
206
+ output: t("plan.replanned", {
207
+ kept: String(keptCount),
208
+ steps: String(newSteps.length),
209
+ }),
210
+ display,
211
+ };
212
+ }
88
213
  if (action === "create") {
89
214
  const title = String(args.title || "Task Plan");
90
215
  const steps = Array.isArray(args.steps)
@@ -93,13 +218,30 @@ export class ExecutionModule {
93
218
  if (steps.length === 0) {
94
219
  return { success: false, output: t("plan.no_steps") };
95
220
  }
96
- const plan = PlanCreator.createPlan(title, steps);
221
+ this.preserveActive();
222
+ const plan = PlanCreator.createPlan(title, steps, this.baseDir);
97
223
  this.setPlan(plan);
98
224
  const display = PlanCreator.toPromptBlock(plan, 0);
225
+ let output = t("plan.created", {
226
+ title,
227
+ steps: String(steps.length),
228
+ });
229
+ let displayOut = display;
230
+ const taskText = this.getTaskText(_ctx);
231
+ if (taskText) {
232
+ const { missing } = checkPlanCoverage(taskText, steps);
233
+ if (missing.length > 0) {
234
+ const warn = t("plan.coverage_warning", {
235
+ missing: missing.join(", "),
236
+ });
237
+ output += `\n⚠️ ${warn}`;
238
+ displayOut = `${display}\n⚠️ ${warn}`;
239
+ }
240
+ }
99
241
  return {
100
242
  success: true,
101
- output: t("plan.created", { title, steps: String(steps.length) }),
102
- display,
243
+ output,
244
+ display: displayOut,
103
245
  };
104
246
  }
105
247
  if (action === "show") {
@@ -117,16 +259,42 @@ export class ExecutionModule {
117
259
  this.tracker.updateStepStatus(Number(args.step), args.status || "done");
118
260
  if (args.note)
119
261
  this.tracker.addNote(Number(args.step), String(args.note));
262
+ this.tracker.syncCurrentStep();
263
+ this.store.saveActive(this.tracker.getPlan());
120
264
  const progress = this.tracker.getProgressString();
121
265
  const display = this.tracker.toPromptBlock();
266
+ _ctx.sessionLog?.plan("step-update", `${t("plan.step_status", { step: String(args.step), status: String(args.status || "done") })} | ${progress} | current: step ${this.tracker.getCurrentStepIndex() + 1}`);
122
267
  return {
123
268
  success: true,
124
269
  output: `${t("plan.step_status", { step: String(args.step), status: String(args.status || "done") })}\n${progress}`,
125
270
  display,
126
271
  };
127
272
  }
273
+ if (action === "update" &&
274
+ Array.isArray(args.steps) &&
275
+ args.steps.length > 0 &&
276
+ this.tracker) {
277
+ const title = String(args.title || this.tracker.getPlan().title);
278
+ const steps = args.steps.map(String);
279
+ const plan = PlanCreator.createPlan(title, steps, this.baseDir);
280
+ this.setPlan(plan);
281
+ const display = PlanCreator.toPromptBlock(plan, 0);
282
+ const output = t("plan.updated", {
283
+ title,
284
+ steps: String(steps.length),
285
+ });
286
+ return {
287
+ success: true,
288
+ output,
289
+ display,
290
+ };
291
+ }
128
292
  if (action === "abort") {
293
+ if (this.tracker) {
294
+ this.store.archivePlan(this.tracker.getPlan());
295
+ }
129
296
  this.tracker = null;
297
+ this.store.clearActive();
130
298
  return { success: true, output: t("plan.aborted") };
131
299
  }
132
300
  if (!this.tracker) {
@@ -172,9 +340,20 @@ export class ExecutionModule {
172
340
  };
173
341
  }
174
342
  if (action === "done") {
343
+ if (!currentStep) {
344
+ return { success: false, output: t("plan.step_not_found") };
345
+ }
346
+ this.tracker.updateStepStatus(currentStep.id, "done");
347
+ this.tracker.syncCurrentStep();
348
+ this.store.saveActive(this.tracker.getPlan());
349
+ const display = this.tracker.toPromptBlock();
175
350
  return {
176
351
  success: true,
177
- output: t("todo.marked_done", { count: "1" }),
352
+ output: t("plan.step_status", {
353
+ step: String(currentStep.id),
354
+ status: "done",
355
+ }),
356
+ display,
178
357
  };
179
358
  }
180
359
  if (action === "list") {
@@ -224,17 +403,64 @@ export class ExecutionModule {
224
403
  return {
225
404
  name: "execution",
226
405
  onBeforeThink: (ctx) => {
227
- const step = this.tracker?.getCurrentStep();
228
- if (this.tracker && step) {
229
- this.stuckDetector.setCurrentStep(step.id, step.description);
230
- this.stuckDetector.recordIteration(step.id);
406
+ // Flush deferred <system-summary> messages first so they are visible
407
+ // to the next model call but never interleave tool messages.
408
+ if (ctx.contextManager && this.pendingMessages.length > 0) {
409
+ for (const m of this.pendingMessages.splice(0)) {
410
+ ctx.contextManager.addMessage(m);
411
+ }
231
412
  }
232
- else {
413
+ if (this.tracker?.isComplete()) {
414
+ // All steps done/skipped — stop counting "no progress" against the
415
+ // last step and silence stuck warnings for a finished plan.
233
416
  this.stuckDetector.reset();
417
+ this.consecutivePlanWarnings = 0;
418
+ this.lastStepId = -1;
419
+ this.stuckNotified = false;
420
+ }
421
+ else {
422
+ const step = this.tracker?.getCurrentStep();
423
+ if (this.tracker && step) {
424
+ if (step.id !== this.lastStepId) {
425
+ this.consecutivePlanWarnings = 0;
426
+ this.lastStepId = step.id;
427
+ this.stuckNotified = false;
428
+ }
429
+ this.stuckDetector.setCurrentStep(step.id, step.description);
430
+ this.stuckDetector.recordIteration(step.id);
431
+ }
432
+ else {
433
+ this.stuckDetector.reset();
434
+ this.consecutivePlanWarnings = 0;
435
+ this.lastStepId = -1;
436
+ this.stuckNotified = false;
437
+ const iter = typeof ctx.iteration === "number" ? ctx.iteration : 0;
438
+ if (iter === 3 && !this.tracker && ctx.contextManager) {
439
+ ctx.contextManager.addMessage({
440
+ role: "user",
441
+ content: `<system-summary>You have made 3 tool calls without creating a plan. For any task that involves creating files, installing packages, or multiple steps — you MUST use plan create BEFORE continuing. Use the plan tool now with concrete steps (exact filenames, commands, deliverables). Do NOT make any more write/edit/bash calls until you have a plan.</system-summary>`,
442
+ });
443
+ }
444
+ if (iter >= 6 && !this.tracker && ctx.contextManager) {
445
+ ctx.contextManager.addMessage({
446
+ role: "user",
447
+ content: `<system-summary>STOP. ${iter} iterations without a plan. You MUST call plan create RIGHT NOW. No more tool calls until you create a plan.</system-summary>`,
448
+ });
449
+ }
450
+ }
234
451
  }
235
452
  const stuckReason = this.stuckDetector.getStuckReason();
236
453
  if (stuckReason) {
237
- ctx.logger?.warn(stuckReason);
454
+ // Log once per stuck episode instead of spamming every iteration.
455
+ const logIt = this.stuckDetector.isStuck()
456
+ ? !this.stuckNotified
457
+ : true;
458
+ if (logIt) {
459
+ ctx.logger?.warn(stuckReason);
460
+ ctx.sessionLog?.plan("stuck-warning", stuckReason, typeof ctx.iteration === "number" ? ctx.iteration : undefined);
461
+ if (this.stuckDetector.isStuck())
462
+ this.stuckNotified = true;
463
+ }
238
464
  }
239
465
  if (this.stuckDetector.isStuck() ||
240
466
  this.stuckDetector.hasRepetitiveToolCalls()) {
@@ -243,22 +469,80 @@ export class ExecutionModule {
243
469
  STUCK_RECOVERY_COOLDOWN) {
244
470
  const recovery = this.stuckDetector.getRecoveryMessage();
245
471
  if (recovery && ctx.contextManager) {
472
+ const lastError = this.stuckDetector.getLastErrorOutput();
473
+ const skillHint = lastError
474
+ ? "\nIf you have relevant skills available, consider loading one with load_skill for expert guidance."
475
+ : "";
476
+ // Actionable hints based on actual error output
477
+ const actionableHints = this.stuckDetector.getActionableHints();
478
+ const actionableHintStr = actionableHints.length > 0
479
+ ? `\n${t("exec.hints", { hints: actionableHints.map((h) => `- ${h}`).join("\n") })}`
480
+ : "";
481
+ // Tool alternative suggestion
482
+ const alternative = this.stuckDetector.getToolAlternative();
483
+ const altHint = alternative
484
+ ? `\nTool "${this.stuckDetector.getLastFailedTool()}" is failing. Try "${alternative}" instead.`
485
+ : "";
246
486
  ctx.contextManager.addMessage({
247
487
  role: "user",
248
- content: `<system-summary>${recovery}</system-summary>`,
488
+ content: `<system-summary>${recovery}${skillHint}${actionableHintStr}${altHint}</system-summary>`,
249
489
  });
250
490
  }
491
+ const hints = this.stuckDetector.getHints();
492
+ if (hints.length > 0 && ctx.contextManager) {
493
+ const hintMsg = t("exec.hints", {
494
+ hints: hints.map((h) => `- ${h}`).join("\n"),
495
+ });
496
+ ctx.contextManager.addMessage({
497
+ role: "user",
498
+ content: `<system-summary>${hintMsg}</system-summary>`,
499
+ });
500
+ }
501
+ this.stuckDetector.recordEscalation();
251
502
  this.lastRecoveryIteration = currentIter;
503
+ if (this.stuckDetector.shouldEscalate() && ctx.onMeta) {
504
+ const escalation = t("exec.escalation", {
505
+ stepId: String(this.tracker?.getCurrentStep()?.id ?? "?"),
506
+ description: this.tracker?.getCurrentStep()?.description ?? "",
507
+ });
508
+ ctx.onMeta(escalation);
509
+ }
510
+ // Force skip after too many iterations on the same step
511
+ if (this.stuckDetector.getIterationsOnCurrentStep() >=
512
+ FORCE_SKIP_THRESHOLD &&
513
+ ctx.contextManager) {
514
+ const step = this.tracker?.getCurrentStep();
515
+ ctx.contextManager.addMessage({
516
+ role: "user",
517
+ content: `<system-summary>STOP. Step ${step?.id ?? "?"} ("${step?.description ?? ""}") took ${this.stuckDetector.getIterationsOnCurrentStep()} iterations with no progress. DO NOT continue this step. Immediately call: plan update step=${step?.id ?? "?"} status=done (if code works despite warnings) OR plan update step=${step?.id ?? "?"} status=skipped note="reason". Do NOT make any other tool calls before updating the plan.</system-summary>`,
518
+ });
519
+ }
252
520
  }
253
521
  }
254
522
  },
255
- onBeforeTool: (ctx, call) => {
523
+ onBeforeTool: (_ctx, call) => {
256
524
  const warning = this.checkPlanAlignment(call);
257
- if (warning && ctx.contextManager) {
258
- ctx.contextManager.addMessage({
525
+ if (warning) {
526
+ this.pendingMessages.push({
259
527
  role: "user",
260
528
  content: `<system-summary>${warning}</system-summary>`,
261
529
  });
530
+ this.consecutivePlanWarnings++;
531
+ if (this.consecutivePlanWarnings >= MAX_PLAN_WARNINGS_BEFORE_BLOCK) {
532
+ this.pendingMessages.push({
533
+ role: "user",
534
+ content: `<system-summary>${t("exec.plan_blocked", { step: String(this.tracker?.getCurrentStep()?.id ?? "?"), max: MAX_PLAN_WARNINGS_BEFORE_BLOCK })}</system-summary>`,
535
+ });
536
+ // Block with an explicit reason so the model sees WHY the call
537
+ // was blocked (executor shows it via tool.blocked_reason).
538
+ return t("exec.plan_blocked", {
539
+ step: String(this.tracker?.getCurrentStep()?.id ?? "?"),
540
+ max: MAX_PLAN_WARNINGS_BEFORE_BLOCK,
541
+ });
542
+ }
543
+ }
544
+ else {
545
+ this.consecutivePlanWarnings = 0;
262
546
  }
263
547
  return true;
264
548
  },
@@ -271,19 +555,68 @@ export class ExecutionModule {
271
555
  },
272
556
  onAfterTool: (ctx, call, result) => {
273
557
  if (!result.success) {
274
- this.stuckDetector.recordToolError(call.name);
558
+ this.stuckDetector.recordToolError(call.name, result.output);
559
+ // Immediately queue an actionable hint on failure (don't wait for
560
+ // the stuck threshold). Flushed in onBeforeThink so it does not
561
+ // land between tool messages.
562
+ const actionableHints = this.stuckDetector.getActionableHints();
563
+ const alternative = this.stuckDetector.getToolAlternative();
564
+ if (actionableHints.length > 0 || alternative) {
565
+ const parts = [...actionableHints];
566
+ if (alternative) {
567
+ parts.push(`Tool "${call.name}" crashed. Try "${alternative}" instead.`);
568
+ }
569
+ this.pendingMessages.push({
570
+ role: "user",
571
+ content: `<system-summary>${t("exec.hints", { hints: parts.map((h) => `- ${h}`).join("\n") })}</system-summary>`,
572
+ });
573
+ }
275
574
  }
276
575
  else {
277
576
  this.stuckDetector.recordToolSuccess();
577
+ // When a bash command runs code successfully, suggest marking step as done
578
+ if (call.name === "bash" && result.success) {
579
+ const cmd = String(call.arguments?.command ?? "");
580
+ if (/node|tsx|ts-node|python|npm\s+(start|test|run)/.test(cmd)) {
581
+ this.pendingMessages.push({
582
+ role: "user",
583
+ content: `<system-summary>The command "${cmd}" completed successfully. If this was testing your code, mark the current step as done via plan update step=N status=done.</system-summary>`,
584
+ });
585
+ }
586
+ }
278
587
  }
279
- if (this.tracker &&
280
- result.success &&
588
+ if (result.success &&
281
589
  (call.name === "write_file" || call.name === "edit_file")) {
282
- this.advancePlanIfStepComplete();
590
+ const filePath = call.arguments?.path;
591
+ if (filePath) {
592
+ this.stuckDetector.recordFileRewrite(filePath);
593
+ if (this.stuckDetector.hasExcessiveRewrites()) {
594
+ const file = this.stuckDetector.getExcessiveRewriteFile();
595
+ const count = this.stuckDetector.getFileRewriteCount(file);
596
+ if (ctx.onMeta) {
597
+ ctx.onMeta(t("exec.file_rewrite_warning", {
598
+ file: file,
599
+ count: String(count),
600
+ }));
601
+ }
602
+ }
603
+ }
604
+ this.advancePlanIfStepComplete(ctx.contextManager, ctx.sessionLog);
283
605
  }
284
606
  },
285
607
  };
286
608
  }
609
+ preserveActive() {
610
+ if (!this.tracker)
611
+ return;
612
+ const plan = this.tracker.getPlan();
613
+ if (plan.steps.every((s) => s.status === "done" || s.status === "skipped")) {
614
+ this.store.archivePlan(plan);
615
+ }
616
+ else {
617
+ this.store.saveDraft(plan);
618
+ }
619
+ }
287
620
  checkPlanAlignment(call) {
288
621
  if (!this.tracker)
289
622
  return null;
@@ -300,8 +633,6 @@ export class ExecutionModule {
300
633
  "grep",
301
634
  "file_info",
302
635
  "load_skill",
303
- "question",
304
- "approve",
305
636
  ];
306
637
  if (allowedAlways.includes(call.name))
307
638
  return null;
@@ -312,7 +643,26 @@ export class ExecutionModule {
312
643
  const callPaths = (argStr.match(/\b[\w./\\-]+\.[a-z]+/gi) || []).map((p) => p.toLowerCase());
313
644
  if (callPaths.length === 0)
314
645
  return null;
315
- const offPath = callPaths.some((p) => !stepPaths.some((s) => p.includes(s) || s.includes(p)));
646
+ // Files that already belong to COMPLETED (done/skipped) steps are
647
+ // legitimate rewrite targets — the agent iterates on them while fixing
648
+ // LSP/type errors. Blocking those would stop normal bug-fixing.
649
+ const plan = this.tracker.getPlan();
650
+ const finishedPaths = new Set();
651
+ for (const s of plan.steps) {
652
+ if (s.status === "done" || s.status === "skipped") {
653
+ const ps = s.description
654
+ .match(/\b[\w./\\-]+\.[a-z]+/gi)
655
+ ?.map((p) => p.toLowerCase()) ?? [];
656
+ ps.forEach((p) => finishedPaths.add(p));
657
+ }
658
+ }
659
+ const offPath = callPaths.some((p) => {
660
+ if (finishedPaths.has(p) ||
661
+ [...finishedPaths].some((s) => s.includes(p))) {
662
+ return false;
663
+ }
664
+ return !stepPaths.some((s) => p.includes(s) || s.includes(p));
665
+ });
316
666
  if (!offPath)
317
667
  return null;
318
668
  return t("exec.plan_warning", {
@@ -321,17 +671,109 @@ export class ExecutionModule {
321
671
  tool: call.name,
322
672
  });
323
673
  }
324
- advancePlanIfStepComplete() {
674
+ advancePlanIfStepComplete(contextManager, sessionLog) {
325
675
  const step = this.tracker?.getCurrentStep();
326
676
  if (!step)
327
677
  return;
678
+ const stepText = step.description.toLowerCase();
328
679
  const stepPaths = step.description.match(/\b[\w./\\-]+\.[a-z]+/gi) || [];
680
+ // Step gate: verify dependencies when mentioned — check BEFORE file paths
681
+ const isDepsStep = stepText.includes("install") ||
682
+ stepText.includes("зависим") ||
683
+ stepText.includes("init") ||
684
+ stepText.includes("инициализац");
685
+ if (isDepsStep) {
686
+ const lockFiles = [
687
+ "package-lock.json",
688
+ "bun.lockb",
689
+ "yarn.lock",
690
+ "pnpm-lock.yaml",
691
+ "node_modules",
692
+ "go.sum",
693
+ "Cargo.lock",
694
+ "Pipfile.lock",
695
+ "poetry.lock",
696
+ "requirements.txt",
697
+ ];
698
+ const hasLockFile = lockFiles.some((f) => existsSync(resolve(this.baseDir, f)));
699
+ if (!hasLockFile) {
700
+ if (contextManager) {
701
+ const hints = this.depsGateHints.get(step.id) || 0;
702
+ this.depsGateHints.set(step.id, hints + 1);
703
+ const force = hints >= 1;
704
+ const content = force
705
+ ? t("exec.step_gate_deps_force", { step: String(step.id) })
706
+ : t("exec.step_gate_deps", {
707
+ step: String(step.id),
708
+ description: step.description,
709
+ });
710
+ contextManager.addMessage({
711
+ role: "user",
712
+ content: `<system-summary>${content}</system-summary>`,
713
+ });
714
+ }
715
+ return;
716
+ }
717
+ }
329
718
  if (stepPaths.length === 0)
330
719
  return;
331
720
  const allExist = stepPaths.every((p) => existsSync(resolve(this.baseDir, p)));
332
- if (allExist) {
333
- this.tracker?.updateStepStatus(step.id, "done");
334
- this.tracker?.advance();
721
+ if (!allExist)
722
+ return;
723
+ // Step gate: verify files have content (not empty)
724
+ const emptyFiles = [];
725
+ for (const p of stepPaths) {
726
+ try {
727
+ const content = readFileSync(resolve(this.baseDir, p), "utf-8");
728
+ if (content.trim().length < 10) {
729
+ emptyFiles.push(p);
730
+ }
731
+ }
732
+ catch {
733
+ // If we can't read it, rely on existsSync result
734
+ }
335
735
  }
736
+ if (emptyFiles.length > 0 && contextManager) {
737
+ contextManager.addMessage({
738
+ role: "user",
739
+ content: `<system-summary>${t("exec.step_gate_empty", { step: String(step.id), files: emptyFiles.join(", ") })}</system-summary>`,
740
+ });
741
+ return;
742
+ }
743
+ // All checks passed — advance
744
+ this.tracker?.updateStepStatus(step.id, "done");
745
+ const hadNext = this.tracker?.advance() ?? false;
746
+ if (this.tracker) {
747
+ this.store.saveActive(this.tracker.getPlan());
748
+ sessionLog?.plan("auto-advance", `Step ${step.id} auto-completed | ${this.tracker.getProgressString()} | current: step ${this.tracker.getCurrentStepIndex() + 1}`);
749
+ }
750
+ if (contextManager) {
751
+ const nextStep = hadNext ? this.tracker?.getCurrentStep() : null;
752
+ const nextMsg = nextStep
753
+ ? t("exec.step_gate_ok", {
754
+ step: String(step.id),
755
+ nextStep: String(nextStep.id),
756
+ nextDesc: nextStep.description,
757
+ })
758
+ : t("exec.step_gate_last", { step: String(step.id) });
759
+ contextManager.addMessage({
760
+ role: "user",
761
+ content: `<system-summary>${nextMsg}</system-summary>`,
762
+ });
763
+ }
764
+ }
765
+ /**
766
+ * Extract the original task statement from the first user message in the
767
+ * conversation history. Used to validate plan coverage against requirements.
768
+ */
769
+ getTaskText(ctx) {
770
+ const manager = ctx?.contextManager;
771
+ if (!manager)
772
+ return "";
773
+ const history = manager.getActiveHistory();
774
+ const firstUser = history.find((m) => m.role === "user");
775
+ if (!firstUser)
776
+ return "";
777
+ return getMessageText(firstUser.content).trim();
336
778
  }
337
779
  }