micro-models-agent 0.51.1 → 0.52.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 (107) hide show
  1. package/dist/cli/commands.js +162 -38
  2. package/dist/cli/completer.js +5 -5
  3. package/dist/cli/main.js +42 -54
  4. package/dist/cli/repl-commands.js +138 -38
  5. package/dist/cli/repl.js +175 -89
  6. package/dist/cli/run-result.js +11 -0
  7. package/dist/cli/security-commands.js +6 -6
  8. package/dist/cli/setup.js +21 -15
  9. package/dist/config/config.js +54 -27
  10. package/dist/config/defaults.js +17 -0
  11. package/dist/config/domains.js +179 -0
  12. package/dist/config/index.js +2 -1
  13. package/dist/config/security.js +28 -8
  14. package/dist/core/agent.js +162 -30
  15. package/dist/core/bootstrap.js +94 -17
  16. package/dist/core/crash-handler.js +51 -0
  17. package/dist/core/environment.js +199 -0
  18. package/dist/core/session-logger.js +60 -6
  19. package/dist/core/version.js +2 -0
  20. package/dist/i18n/en.json +120 -39
  21. package/dist/i18n/ru.json +91 -10
  22. package/dist/llm/openai-compat.js +191 -53
  23. package/dist/llm/orchestrator.js +5 -3
  24. package/dist/logger/app-logger.js +50 -4
  25. package/dist/main.js +1288 -635
  26. package/dist/modules/browser/session.js +4 -0
  27. package/dist/modules/certification/cli.js +58 -19
  28. package/dist/modules/certification/loader.js +2 -1
  29. package/dist/modules/certification/manifest.js +22 -14
  30. package/dist/modules/certification/runner.js +91 -5
  31. package/dist/modules/certification/scenarios.js +290 -7
  32. package/dist/modules/context/fact-extractor.js +6 -0
  33. package/dist/modules/context/manager.js +19 -2
  34. package/dist/modules/execution/audit-runners.js +61 -7
  35. package/dist/modules/execution/execution-plugin.js +219 -60
  36. package/dist/modules/execution/module.js +207 -18
  37. package/dist/modules/execution/moe-executor.js +33 -20
  38. package/dist/modules/execution/plan-store.js +39 -0
  39. package/dist/modules/execution/plan-tool.js +188 -19
  40. package/dist/modules/execution/planner.js +27 -23
  41. package/dist/modules/execution/stuck-detector.js +244 -8
  42. package/dist/modules/execution/tracker.js +8 -6
  43. package/dist/modules/execution/verifier.js +15 -2
  44. package/dist/modules/hallucination/detector.js +4 -0
  45. package/dist/modules/hallucination/factual.js +45 -5
  46. package/dist/modules/indexer/module.js +1 -0
  47. package/dist/modules/lsp/client.js +123 -12
  48. package/dist/modules/lsp/index.js +1 -1
  49. package/dist/modules/lsp/module.js +30 -2
  50. package/dist/modules/lsp/probe.js +11 -1
  51. package/dist/modules/lsp/startup-check.js +5 -2
  52. package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
  53. package/dist/modules/plugins/manager.js +57 -13
  54. package/dist/modules/pricing/index.js +61 -0
  55. package/dist/modules/pricing/prices.js +129 -0
  56. package/dist/modules/providers/create.js +22 -0
  57. package/dist/modules/providers/fallback.js +79 -0
  58. package/dist/modules/providers/health.js +46 -0
  59. package/dist/modules/providers/index.js +5 -0
  60. package/dist/modules/providers/manager.js +161 -0
  61. package/dist/modules/providers/presets.js +128 -0
  62. package/dist/modules/providers/registry.js +22 -0
  63. package/dist/modules/providers/types.js +1 -0
  64. package/dist/modules/registry.js +1 -0
  65. package/dist/modules/security/command-validator.js +14 -0
  66. package/dist/modules/security/encryption.js +6 -6
  67. package/dist/modules/security/network-validator.js +17 -0
  68. package/dist/modules/security/path-validator.js +22 -26
  69. package/dist/modules/session/store.js +10 -10
  70. package/dist/tools/approve.js +1 -0
  71. package/dist/tools/attach-image.js +12 -0
  72. package/dist/tools/bash.js +27 -4
  73. package/dist/tools/browser.js +1 -0
  74. package/dist/tools/chunk-query.js +1 -0
  75. package/dist/tools/create-dir.js +1 -0
  76. package/dist/tools/delete-file.js +1 -0
  77. package/dist/tools/download-file.js +1 -0
  78. package/dist/tools/edit-file.js +2 -1
  79. package/dist/tools/enable-tools.js +1 -0
  80. package/dist/tools/executor.js +17 -7
  81. package/dist/tools/file-info.js +1 -0
  82. package/dist/tools/glob-tool.js +1 -0
  83. package/dist/tools/grep-tool.js +54 -13
  84. package/dist/tools/list-dir.js +1 -0
  85. package/dist/tools/load-skill.js +1 -0
  86. package/dist/tools/mcp-call.js +1 -0
  87. package/dist/tools/move-file.js +1 -0
  88. package/dist/tools/path-utils.js +51 -1
  89. package/dist/tools/pipeline-run.js +1 -0
  90. package/dist/tools/process-kill.js +11 -0
  91. package/dist/tools/process-list.js +1 -0
  92. package/dist/tools/process-log.js +9 -0
  93. package/dist/tools/question.js +1 -0
  94. package/dist/tools/read-file.js +94 -6
  95. package/dist/tools/recall.js +1 -0
  96. package/dist/tools/remember.js +1 -0
  97. package/dist/tools/scope-check.js +7 -5
  98. package/dist/tools/search-history.js +1 -0
  99. package/dist/tools/subagent.js +4 -4
  100. package/dist/tools/web-browse.js +1 -0
  101. package/dist/tools/web-fetch.js +27 -6
  102. package/dist/tools/web-search.js +70 -43
  103. package/dist/tools/write-file.js +1 -0
  104. package/dist/ui/line-editor.js +142 -23
  105. package/dist/ui/line-math.js +8 -4
  106. package/dist/ui/renderer.js +57 -7
  107. package/package.json +50 -48
@@ -1,15 +1,24 @@
1
1
  import { t } from "../../i18n/index";
2
2
  import { PlanTracker } from "./tracker";
3
3
  import { StepVerifier } from "./verifier";
4
- import { StuckDetector } from "./stuck-detector";
4
+ import { StuckDetector, isSearchableError, buildSearchQuery } from "./stuck-detector";
5
5
  import { Auditor, findExistingFile } from "./auditor";
6
6
  import { PlanStore } from "./plan-store";
7
7
  import { getMessageText } from "../../llm/provider";
8
8
  import { extractFileLikeTokens, stripUrls } from "../hallucination/js-identifiers";
9
9
  import { existsSync, readFileSync } from "fs";
10
10
  import { resolve } from "path";
11
+ import { performWebSearch } from "../../tools/web-search";
12
+ import { getSessionSecurityConfig } from "../security/session-isolation";
11
13
  import { createPlanToolDefinitions } from "./plan-tool";
12
- import { createExecutionPlugin, STUCK_RECOVERY_COOLDOWN, } from "./execution-plugin";
14
+ import { createExecutionPlugin, STUCK_RECOVERY_COOLDOWN, STUCK_WARN_REPEAT_EVERY, } from "./execution-plugin";
15
+ /**
16
+ * Minimum interval between automatic error searches. The spec asked for a
17
+ * STUCK_RECOVERY_COOLDOWN-iteration cap, but onAfterTool has no iteration
18
+ * counter (executor context), so the same anti-spam protection is
19
+ * time-based. Injectable via the constructor for tests (0 disables it).
20
+ */
21
+ const ERROR_SEARCH_MIN_INTERVAL_MS = 30000;
13
22
  export class ExecutionModule {
14
23
  name = "execution";
15
24
  tracker = null;
@@ -21,6 +30,13 @@ export class ExecutionModule {
21
30
  // Skip the first audit after restoring a plan from disk — gives the user
22
31
  // one chance to change the subject before the audit gate locks in.
23
32
  _auditSkipsRemaining = 0;
33
+ /**
34
+ * The last plan that was COMPLETED and auto-archived. `plan update` on the
35
+ * final step clears the tracker, which used to make runFinalAudit() return
36
+ * null and silently skip the final audit (tests + tsc + file checks) for a
37
+ * finished task. Keep the completed plan so the audit gate still runs.
38
+ */
39
+ completedPlan = null;
24
40
  /**
25
41
  * Deferred <system-summary> messages. Injected by onBeforeTool/onAfterTool
26
42
  * but flushed in onBeforeThink, so they never land between an assistant
@@ -33,6 +49,13 @@ export class ExecutionModule {
33
49
  // failure is evidence-based (actual command failures), not task
34
50
  // classification (rule #10).
35
51
  forbiddenBashFailures = new Map();
52
+ searchRunner;
53
+ webSearchThreshold;
54
+ searchesThisSession = 0;
55
+ retryWebSearchSignatures = new Set();
56
+ lastErrorSearchAt = 0;
57
+ errorSearchCooldownMs;
58
+ hallucinationDetector = null;
36
59
  /**
37
60
  * The execution-plugin's mutable state. Owned HERE and passed BY REFERENCE
38
61
  * to the plugin factory — the plugin writes deps.state.* directly, so the
@@ -43,7 +66,12 @@ export class ExecutionModule {
43
66
  consecutivePlanWarnings: 0,
44
67
  lastStepId: -1,
45
68
  stuckNotified: false,
69
+ lastStuckWarnKey: "",
70
+ lastStuckWarnIter: -STUCK_WARN_REPEAT_EVERY,
46
71
  depsGateHints: new Map(),
72
+ mutationsWithoutPlan: 0,
73
+ planNudgeSent: false,
74
+ typecheckFailures: new Map(),
47
75
  };
48
76
  /**
49
77
  * Read/write accessor for the tracker, shared with the plan-tool and
@@ -63,18 +91,40 @@ export class ExecutionModule {
63
91
  },
64
92
  };
65
93
  })();
66
- constructor(baseDir, stuckThreshold = 6) {
94
+ constructor(baseDir, stuckThreshold = 6, webSearchThreshold = 5, searchRunner = performWebSearch, errorSearchCooldownMs = ERROR_SEARCH_MIN_INTERVAL_MS) {
67
95
  this.baseDir = baseDir;
68
96
  this.verifier = new StepVerifier(baseDir);
69
- this.stuckDetector = new StuckDetector(stuckThreshold);
97
+ this.stuckDetector = new StuckDetector(stuckThreshold, 3, webSearchThreshold);
70
98
  this.auditor = new Auditor(baseDir);
71
99
  this.store = new PlanStore(baseDir);
100
+ this.webSearchThreshold = webSearchThreshold;
101
+ this.searchRunner = searchRunner;
102
+ this.errorSearchCooldownMs = errorSearchCooldownMs;
72
103
  }
73
104
  setPlan(plan) {
74
105
  this.tracker = new PlanTracker(plan);
106
+ // Advance the pointer past any done/skipped steps of a restored/replanned
107
+ // plan so display ("current: step N") and the off-track alignment match
108
+ // reality instead of always pointing at step 1.
109
+ this.tracker.syncCurrentStep();
75
110
  this.store.saveActive(plan);
111
+ this.completedPlan = null; // a new plan supersedes the completed one
76
112
  this._auditSkipsRemaining = 0; // plan explicitly created — arm the audit gate
77
113
  }
114
+ /** Wire the hallucination detector so the plan tool can check whether
115
+ * a file was tracked as deleted (auto-switch kind gate). */
116
+ setHallucinationDetector(detector) {
117
+ this.hallucinationDetector = detector;
118
+ }
119
+ /** Remember the last completed plan so the final audit can still verify it
120
+ * after the tracker was cleared by the auto-archive. */
121
+ recordCompleted(plan) {
122
+ this.completedPlan = plan;
123
+ }
124
+ /** Forget the completed plan (abort/delete/purge — nothing left to audit). */
125
+ clearCompleted() {
126
+ this.completedPlan = null;
127
+ }
78
128
  restorePlan() {
79
129
  const plan = this.store.loadActive();
80
130
  if (!plan)
@@ -97,19 +147,107 @@ export class ExecutionModule {
97
147
  getTracker() {
98
148
  return this.tracker;
99
149
  }
150
+ /** The currently tracked plan (single source of truth for UI consumers).
151
+ * Null in a fresh session — a disk-resident plan is restored only when
152
+ * resuming a session with history, so the REPL must never see stale plans. */
153
+ getActivePlan() {
154
+ return this.tracker?.getPlan() ?? null;
155
+ }
100
156
  getStore() {
101
157
  return this.store;
102
158
  }
103
159
  getStuckDetector() {
104
160
  return this.stuckDetector;
105
161
  }
162
+ /**
163
+ * Automatic web search for a repeatedly failing error (>= errorWebSearch.threshold
164
+ * identical signatures). Fires the search fire-and-forget (never blocks the tool
165
+ * result) and queues the results as a <system-summary> in pendingMessages.
166
+ *
167
+ * Gates: webSearch master switch + errorWebSearch switch → repeated signature
168
+ * → not-searched (or retried) → session cap → meaningful error text →
169
+ * cooldown. One search per signature; empty results trigger a single retry
170
+ * (unmarkErrorSearched once), then the signature stays searched.
171
+ */
172
+ maybeSearchError(ctx, call) {
173
+ if (ctx.config?.webSearch?.enabled === false)
174
+ return;
175
+ const cfg = ctx.config?.errorWebSearch;
176
+ if (!cfg?.enabled)
177
+ return;
178
+ const sig = this.stuckDetector.getRepeatedErrorSignature();
179
+ if (!sig)
180
+ return;
181
+ if (this.stuckDetector.hasErrorSearched(sig) && !this.retryWebSearchSignatures.has(sig))
182
+ return;
183
+ if (this.searchesThisSession >= (cfg.maxSearchesPerSession ?? 3))
184
+ return;
185
+ const lastError = this.stuckDetector.getLastErrorOutput();
186
+ if (!isSearchableError(lastError)) {
187
+ ctx.logger?.debug(t("exec.error_search_no_query"));
188
+ return;
189
+ }
190
+ const now = Date.now();
191
+ if (now - this.lastErrorSearchAt < this.errorSearchCooldownMs)
192
+ return;
193
+ const stepDesc = this.tracker?.getCurrentStep()?.description ?? "";
194
+ const query = buildSearchQuery(stepDesc, this.stuckDetector.getLastBashCommand(), lastError, call.name, cfg.maxQueryChars ?? 200);
195
+ const count = this.stuckDetector.getErrorSignatureCount(sig);
196
+ this.stuckDetector.markErrorSearched(sig);
197
+ this.searchesThisSession++;
198
+ this.lastErrorSearchAt = now;
199
+ const networkConfig = getSessionSecurityConfig(ctx.config, ctx.sessionContext).network;
200
+ this.searchRunner(query, cfg.maxResults ?? 5, networkConfig, cfg.requestTimeoutMs ?? 10000)
201
+ .then((res) => {
202
+ if (!res.success || res.results.length === 0) {
203
+ if (!this.retryWebSearchSignatures.has(sig)) {
204
+ this.retryWebSearchSignatures.add(sig);
205
+ this.stuckDetector.unmarkErrorSearched(sig);
206
+ }
207
+ ctx.logger?.warn(t("exec.error_search_failed", { query }));
208
+ ctx.sessionLog?.plan("web-search", `empty/failed: ${query}`);
209
+ return;
210
+ }
211
+ const resultsText = res.results
212
+ .map((r, i) => `${i + 1}. ${r.title} \u2014 ${r.url}\n ${r.snippet}`)
213
+ .join("\n");
214
+ this.pendingMessages.push({
215
+ role: "user",
216
+ content: `<system-summary>${t("exec.error_search_results", {
217
+ sig,
218
+ count: String(count),
219
+ query,
220
+ results: resultsText,
221
+ })}</system-summary>`,
222
+ });
223
+ })
224
+ .catch((e) => ctx.logger?.warn(`error web search: ${e.message}`));
225
+ }
106
226
  /**
107
227
  * Final audit before the agent may declare the task done. Returns null when
108
228
  * no plan is active; otherwise checks plan completion + artifact existence.
109
229
  */
110
230
  async runFinalAudit() {
111
- if (!this.tracker)
112
- return null;
231
+ // No active tracker: audit the last completed plan instead of bailing.
232
+ // A plan finished via `plan update` auto-archives and clears the tracker;
233
+ // without this the final audit (tests + tsc + file checks) was silently
234
+ // skipped and the model could declare success on broken artifacts.
235
+ if (!this.tracker) {
236
+ if (!this.completedPlan)
237
+ return null;
238
+ const plan = this.completedPlan;
239
+ const audit = await this.auditor.audit(plan);
240
+ const pendingSteps = plan.steps.flatMap((s) => s.status !== "done" && s.status !== "skipped" ? [`${s.id}. ${s.description}`] : []);
241
+ const done = plan.steps.filter((s) => s.status === "done").length;
242
+ return {
243
+ passed: audit.passed && pendingSteps.length === 0,
244
+ done,
245
+ total: plan.steps.length,
246
+ pendingSteps,
247
+ missingFiles: audit.missingFiles,
248
+ summary: audit.summary,
249
+ };
250
+ }
113
251
  if (this._auditSkipsRemaining > 0) {
114
252
  this._auditSkipsRemaining--;
115
253
  return null;
@@ -167,11 +305,18 @@ export class ExecutionModule {
167
305
  trackerRef: this.trackerRef,
168
306
  preserveActive: () => this.preserveActive(),
169
307
  setPlan: (p) => this.setPlan(p),
308
+ recordCompleted: (p) => this.recordCompleted(p),
309
+ clearCompleted: () => this.clearCompleted(),
310
+ hasStepDeliverables: (s) => this.hasStepDeliverables(s),
170
311
  missingStepDeliverables: (s) => this.missingStepDeliverables(s),
171
312
  stillExistingDeliverables: (s) => this.stillExistingDeliverables(s),
172
313
  parseKinds: (a, st) => this.parseKinds(a, st),
173
314
  getTaskText: (c) => this.getTaskText(c),
315
+ typecheckFailures: this.state.typecheckFailures,
174
316
  baseDir: this.baseDir,
317
+ wasFileDeleted: this.hallucinationDetector
318
+ ? (path) => this.hallucinationDetector.getConsistencyCheck().getDeletedFiles().includes(path)
319
+ : undefined,
175
320
  });
176
321
  }
177
322
  getPlugin() {
@@ -181,8 +326,10 @@ export class ExecutionModule {
181
326
  pendingMessages: this.pendingMessages,
182
327
  forbiddenBashFailures: this.forbiddenBashFailures,
183
328
  state: this.state,
329
+ store: this.store,
184
330
  checkPlanAlignment: (call) => this.checkPlanAlignment(call),
185
331
  advancePlanIfStepComplete: (cm, sl) => this.advancePlanIfStepComplete(cm, sl),
332
+ maybeSearchError: (ctx, call) => this.maybeSearchError(ctx, call),
186
333
  });
187
334
  }
188
335
  preserveActive() {
@@ -218,7 +365,11 @@ export class ExecutionModule {
218
365
  const stepPaths = extractFileLikeTokens(stripUrls(step.description)).map((p) => p.toLowerCase());
219
366
  if (stepPaths.length === 0)
220
367
  return null;
221
- const argStr = JSON.stringify(call.arguments);
368
+ // Only path-ish arguments are inspected. JSON-stringifying the whole args
369
+ // blob made write_file/edit_file `content` feed its code tokens into the
370
+ // off-path check (e.g. `import "./styles.css"` inside the file body) and
371
+ // raised phantom alignment warnings for on-track writes.
372
+ const argStr = this.pathArgStrings(call.arguments).join(" ");
222
373
  const callPaths = extractFileLikeTokens(stripUrls(argStr)).map((p) => p.toLowerCase());
223
374
  if (callPaths.length === 0)
224
375
  return null;
@@ -248,6 +399,28 @@ export class ExecutionModule {
248
399
  tool: call.name,
249
400
  });
250
401
  }
402
+ /** String values of the path-like arguments of a tool call. Limits the
403
+ * off-path alignment check to arguments that actually name files (path,
404
+ * file, dir, src, dst, source, target, input, output, destination, glob,
405
+ * workdir, cwd, command, pattern) and never scans payload content. */
406
+ pathArgStrings(args) {
407
+ if (!args)
408
+ return [];
409
+ const out = [];
410
+ for (const [key, value] of Object.entries(args)) {
411
+ if (!/path|file|dir|src|dst|source|target|input|output|destination|glob|workdir|cwd|command|pattern|query/i.test(key)) {
412
+ continue;
413
+ }
414
+ if (typeof value === "string")
415
+ out.push(value);
416
+ else if (Array.isArray(value)) {
417
+ for (const v of value)
418
+ if (typeof v === "string")
419
+ out.push(v);
420
+ }
421
+ }
422
+ return out;
423
+ }
251
424
  advancePlanIfStepComplete(contextManager, sessionLog) {
252
425
  const step = this.tracker?.getCurrentStep();
253
426
  if (!step)
@@ -259,11 +432,11 @@ export class ExecutionModule {
259
432
  // allGone trivially true and auto-advancing a delete step while the real
260
433
  // files to delete still exist (the raw regex path did exactly that).
261
434
  const stepPaths = extractFileLikeTokens(stripUrls(step.description)) || [];
262
- // Step gate: verify dependencies when mentioned — check BEFORE file paths
263
- const isDepsStep = stepText.includes("install") ||
264
- stepText.includes("зависим") ||
265
- stepText.includes("init") ||
266
- stepText.includes("инициализац");
435
+ // Step gate: verify dependencies when mentioned — check BEFORE file paths.
436
+ // Requires an actual package-manager verb (rule #10 — no bare keyword
437
+ // matching: "init" alone or "setup project" must NOT trigger the lockfile
438
+ // hint, only concrete dependency-install commands do).
439
+ const isDepsStep = /(?:npm|bun|yarn|pnpm|pip|pip3|pipenv|poetry|composer|deno|go|gem|cargo)\s+(?:install|add|init|i|get)\b|bun\s+(?:add|install)|npm\s+(?:i|install|add)|(?:install|установ\w*)\s+(?:dependencies|зависимост\w*|пакет\w*|packages?)/i.test(stepText);
267
440
  if (isDepsStep) {
268
441
  const lockFiles = [
269
442
  "package-lock.json",
@@ -299,23 +472,32 @@ export class ExecutionModule {
299
472
  }
300
473
  if (stepPaths.length === 0)
301
474
  return;
302
- const allExist = stepPaths.every((p) => existsSync(resolve(this.baseDir, p)));
303
- const allGone = stepPaths.every((p) => !existsSync(resolve(this.baseDir, p)));
475
+ // Resolve tokens with the SAME subtree walk the deliverable gate and the
476
+ // auditor use (findExistingFile), so a nested file
477
+ // ("bicycle-shop/src/bicycles.ts" for token "src/bicycles.ts") counts as
478
+ // present. The old exact existsSync(resolve()) never matched nested
479
+ // projects, so auto-advance stayed silent even after the model wrote the
480
+ // real file.
481
+ const resolved = stepPaths.map((p) => findExistingFile(this.baseDir, p));
482
+ const allExist = resolved.every((r) => r !== null);
483
+ const allGone = resolved.every((r) => r === null);
304
484
  const satisfied = step.kind === "delete" ? allGone && !allExist : allExist;
305
485
  if (!satisfied)
306
486
  return;
307
487
  // Step gate: verify files have content (not empty)
308
488
  if (step.kind !== "delete") {
309
489
  const emptyFiles = [];
310
- for (const p of stepPaths) {
490
+ for (const r of resolved) {
491
+ if (!r)
492
+ continue;
311
493
  try {
312
- const content = readFileSync(resolve(this.baseDir, p), "utf-8");
494
+ const content = readFileSync(r, "utf-8");
313
495
  if (content.trim().length < 10) {
314
- emptyFiles.push(p);
496
+ emptyFiles.push(r);
315
497
  }
316
498
  }
317
499
  catch {
318
- // If we can't read it, rely on existsSync result
500
+ // If we can't read it, rely on the existence result
319
501
  }
320
502
  }
321
503
  if (emptyFiles.length > 0 && contextManager) {
@@ -354,6 +536,7 @@ export class ExecutionModule {
354
536
  if (this.tracker?.isComplete()) {
355
537
  const completed = this.tracker.getPlan();
356
538
  this.store.archivePlan(completed);
539
+ this.recordCompleted(completed); // final audit still verifies it
357
540
  this.tracker = null;
358
541
  sessionLog?.plan("auto-archive", `Plan ${completed.id} complete — archived`);
359
542
  }
@@ -383,6 +566,12 @@ export class ExecutionModule {
383
566
  return [];
384
567
  return tokens.filter((p) => findExistingFile(this.baseDir, p));
385
568
  }
569
+ /** Whether a step description names at least one real file token. Steps
570
+ * with no file tokens (pure commands, verification runs) cannot be
571
+ * auto-verified — the done-gate is vacuous for them. */
572
+ hasStepDeliverables(step) {
573
+ return extractFileLikeTokens(stripUrls(step.description)).length > 0;
574
+ }
386
575
  /**
387
576
  * Validate the optional per-step `kinds` argument for plan create and
388
577
  * update-with-steps. Returns length-aligned kinds or an i18n error string.
@@ -86,7 +86,6 @@ async function executeSubtask(subtask, deps, _sharedContext) {
86
86
  }
87
87
  const maxAttempts = expertConfig.max_attempts || 3;
88
88
  let lastError = "";
89
- let lastResult = null;
90
89
  const stuckDetector = new StuckDetector(maxAttempts * 2, maxAttempts);
91
90
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
92
91
  stuckDetector.recordIteration(1);
@@ -144,7 +143,6 @@ async function executeSubtask(subtask, deps, _sharedContext) {
144
143
  };
145
144
  }
146
145
  lastError = result.output;
147
- lastResult = result;
148
146
  stuckDetector.recordToolError("subagent", result.output);
149
147
  // Inject actionable hints into the next retry's prompt
150
148
  if (attempt < maxAttempts) {
@@ -260,23 +258,41 @@ export class MoEExecutor {
260
258
  }
261
259
  for (let waveIdx = 0; waveIdx < waves.length; waveIdx++) {
262
260
  const wave = waves[waveIdx];
263
- const wavePromises = wave.map((subtask) => executeSubtask(subtask, this.deps, plan.shared_context)
264
- .then((result) => {
261
+ // Cap concurrent subtasks per wave (rateLimits.maxParallelTasks, default 5).
262
+ const maxParallel = this.deps.config?.security?.rateLimits?.maxParallelTasks ?? 5;
263
+ let active = 0;
264
+ const waiters = [];
265
+ const acquire = () => active < maxParallel
266
+ ? Promise.resolve(active++)
267
+ : new Promise((resolve) => waiters.push(() => resolve(active++)));
268
+ const release = () => {
269
+ active--;
270
+ const next = waiters.shift();
271
+ if (next)
272
+ next();
273
+ };
274
+ const wavePromises = wave.map(async (subtask) => {
275
+ await acquire();
276
+ let result;
277
+ try {
278
+ result = await executeSubtask(subtask, this.deps, plan.shared_context);
279
+ }
280
+ catch (e) {
281
+ result = {
282
+ subtaskId: subtask.id,
283
+ success: false,
284
+ summary: `Unhandled error: ${subtask.id}`,
285
+ result: "",
286
+ error: e instanceof Error ? e.message : String(e),
287
+ durationMs: 0,
288
+ };
289
+ }
290
+ finally {
291
+ release();
292
+ }
265
293
  results.push(result);
266
294
  return result;
267
- })
268
- .catch((e) => {
269
- const errResult = {
270
- subtaskId: subtask.id,
271
- success: false,
272
- summary: `Unhandled error: ${subtask.id}`,
273
- result: "",
274
- error: e.message,
275
- durationMs: 0,
276
- };
277
- results.push(errResult);
278
- return errResult;
279
- }));
295
+ });
280
296
  await Promise.all(wavePromises);
281
297
  }
282
298
  const failed = results.filter((r) => !r.success);
@@ -285,7 +301,4 @@ export class MoEExecutor {
285
301
  }
286
302
  return { success: failed.length === 0, results, errors, warnings };
287
303
  }
288
- getChainMaxAttempts() {
289
- return 3;
290
- }
291
304
  }
@@ -154,4 +154,43 @@ export class PlanStore {
154
154
  return { plan: archived, status: "archived" };
155
155
  return null;
156
156
  }
157
+ // ---- Deletion (the user asked for plans to actually go away) ----
158
+ /** Permanently delete a plan wherever it lives. Returns its status, or null
159
+ * when no plan with that id exists. */
160
+ deletePlan(id) {
161
+ const active = this.loadActive();
162
+ if (active && active.id === id) {
163
+ this.clearActive();
164
+ return "active";
165
+ }
166
+ const draftPath = join(this.draftsDir, `${id}.json`);
167
+ if (existsSync(draftPath)) {
168
+ rmSync(draftPath, { force: true });
169
+ return "draft";
170
+ }
171
+ const archivedPath = join(this.archiveDir, `${id}.json`);
172
+ if (existsSync(archivedPath)) {
173
+ rmSync(archivedPath, { force: true });
174
+ return "archived";
175
+ }
176
+ return null;
177
+ }
178
+ /** Delete every plan (active, drafts, archive). Returns the number removed. */
179
+ purgeAll() {
180
+ let n = 0;
181
+ const active = this.loadActive();
182
+ if (active) {
183
+ this.clearActive();
184
+ n++;
185
+ }
186
+ for (const p of this.listDrafts()) {
187
+ this.removeDraft(p.id);
188
+ n++;
189
+ }
190
+ for (const p of this.listArchived()) {
191
+ this.removeArchived(p.id);
192
+ n++;
193
+ }
194
+ return n;
195
+ }
157
196
  }