micro-models-agent 0.63.0 → 1.1.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 (186) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/cli/cache-line.js +30 -0
  3. package/dist/cli/command-suggest.js +38 -0
  4. package/dist/cli/commands.js +285 -60
  5. package/dist/cli/completer.js +16 -16
  6. package/dist/cli/json-payload.js +32 -0
  7. package/dist/cli/main.js +165 -77
  8. package/dist/cli/plugin-commands.js +5 -4
  9. package/dist/cli/relaunch.js +37 -0
  10. package/dist/cli/repl-commands.js +441 -307
  11. package/dist/cli/repl.js +360 -83
  12. package/dist/cli/run-result.js +12 -6
  13. package/dist/cli/security-commands.js +64 -60
  14. package/dist/cli/setup-order.js +57 -0
  15. package/dist/cli/setup-prompt.js +49 -0
  16. package/dist/cli/setup.js +52 -48
  17. package/dist/config/budget.js +48 -0
  18. package/dist/config/config.js +132 -70
  19. package/dist/config/defaults.js +37 -11
  20. package/dist/config/domains.js +9 -50
  21. package/dist/config/utils.js +56 -0
  22. package/dist/core/agent/audit-gate.js +49 -0
  23. package/dist/core/agent/compaction.js +89 -0
  24. package/dist/core/agent/constants.js +61 -0
  25. package/dist/core/agent/context-renderer.js +40 -0
  26. package/dist/core/agent/hallucination-gate.js +87 -0
  27. package/dist/core/agent/loop-state.js +53 -0
  28. package/dist/core/agent/prefix-monitor.js +101 -0
  29. package/dist/core/agent/reasoning-resolver.js +56 -0
  30. package/dist/core/agent/token-tracker.js +96 -0
  31. package/dist/core/agent/tool-batch.js +237 -0
  32. package/dist/core/agent/tool-output.js +62 -0
  33. package/dist/core/agent-moe.js +214 -69
  34. package/dist/core/agent.js +506 -546
  35. package/dist/core/bootstrap.js +297 -98
  36. package/dist/core/crash-handler.js +2 -1
  37. package/dist/core/prompt-builder.js +3 -0
  38. package/dist/core/prompt-overflow.js +307 -0
  39. package/dist/core/session-logger.js +34 -2
  40. package/dist/i18n/en.json +8 -4
  41. package/dist/i18n/ru.json +8 -4
  42. package/dist/index.js +5 -1
  43. package/dist/llm/cache-usage.js +76 -0
  44. package/dist/llm/image-utils.js +20 -16
  45. package/dist/llm/llm-errors.js +41 -0
  46. package/dist/llm/model-loader.js +30 -0
  47. package/dist/llm/openai-compat.js +287 -101
  48. package/dist/llm/orchestrator.js +140 -68
  49. package/dist/llm/provider-budget.js +68 -0
  50. package/dist/llm/provider.js +0 -1
  51. package/dist/llm/stream-state.js +26 -0
  52. package/dist/llm/token-counter.js +28 -0
  53. package/dist/logger/app-logger.js +12 -15
  54. package/dist/main.js +1755 -841
  55. package/dist/migration/detect.js +3 -1
  56. package/dist/modules/browser/actions.js +0 -3
  57. package/dist/modules/browser/bridge-client.js +2 -0
  58. package/dist/modules/browser/bridge-server.mjs +37 -4
  59. package/dist/modules/browser/driver.js +46 -4
  60. package/dist/modules/certification/cli.js +85 -42
  61. package/dist/modules/certification/loader.js +15 -1
  62. package/dist/modules/certification/manifest.js +126 -15
  63. package/dist/modules/certification/runner.js +4 -26
  64. package/dist/modules/certification/scenarios.js +184 -5
  65. package/dist/modules/certification/syntax-scenarios.js +51 -0
  66. package/dist/modules/context/chunk-query.js +25 -5
  67. package/dist/modules/context/fact-extractor.js +6 -2
  68. package/dist/modules/context/manager.js +23 -7
  69. package/dist/modules/execution/audit-runners.js +7 -1
  70. package/dist/modules/execution/auditor.js +3 -3
  71. package/dist/modules/execution/execution-plugin.js +22 -15
  72. package/dist/modules/execution/input-from.js +46 -0
  73. package/dist/modules/execution/module.js +107 -18
  74. package/dist/modules/execution/moe-executor.js +166 -54
  75. package/dist/modules/execution/plan-actions.js +524 -0
  76. package/dist/modules/execution/plan-steps.js +23 -0
  77. package/dist/modules/execution/plan-store.js +15 -3
  78. package/dist/modules/execution/plan-tool.js +6 -488
  79. package/dist/modules/execution/plan-validator.js +24 -0
  80. package/dist/modules/execution/stuck-detector.js +3 -18
  81. package/dist/modules/execution/tracker.js +14 -5
  82. package/dist/modules/execution/transient-error.js +30 -0
  83. package/dist/modules/execution/verifier.js +94 -7
  84. package/dist/modules/execution/windows-commands.js +11 -0
  85. package/dist/modules/hallucination/confidence.js +36 -23
  86. package/dist/modules/hallucination/consistency.js +3 -0
  87. package/dist/modules/hallucination/detector.js +8 -3
  88. package/dist/modules/hallucination/factual.js +26 -7
  89. package/dist/modules/hallucination/llm-judge.js +12 -2
  90. package/dist/modules/indexer/map-command.js +35 -0
  91. package/dist/modules/indexer/map-select.js +87 -0
  92. package/dist/modules/indexer/module.js +34 -22
  93. package/dist/modules/indexer/symbols.js +189 -0
  94. package/dist/modules/indexer/walker.js +96 -42
  95. package/dist/modules/lsp/check-tool.js +2 -1
  96. package/dist/modules/lsp/client.js +49 -32
  97. package/dist/modules/lsp/config.js +55 -2
  98. package/dist/modules/lsp/module.js +38 -5
  99. package/dist/modules/lsp/probe.js +4 -3
  100. package/dist/modules/lsp/project-root.js +41 -1
  101. package/dist/modules/lsp/startup-check.js +12 -4
  102. package/dist/modules/mcp/client.js +153 -104
  103. package/dist/modules/mcp/module.js +165 -41
  104. package/dist/modules/memory/module.js +4 -3
  105. package/dist/modules/plugins/builtin/lint-on-write.js +36 -6
  106. package/dist/modules/plugins/manager.js +47 -84
  107. package/dist/modules/pricing/index.js +17 -7
  108. package/dist/modules/pricing/prices.js +30 -12
  109. package/dist/modules/processes/index.js +1 -0
  110. package/dist/modules/processes/kill-tree.js +56 -0
  111. package/dist/modules/processes/registry.js +2 -54
  112. package/dist/modules/providers/cache.js +23 -0
  113. package/dist/modules/providers/factory.js +28 -0
  114. package/dist/modules/providers/fallback.js +7 -5
  115. package/dist/modules/providers/health.js +2 -1
  116. package/dist/modules/providers/index.js +1 -0
  117. package/dist/modules/providers/manager.js +17 -2
  118. package/dist/modules/providers/presets.js +79 -6
  119. package/dist/modules/reasoning/policy.js +40 -0
  120. package/dist/modules/reasoning/probe.js +111 -0
  121. package/dist/modules/security/audit-notifier.js +42 -27
  122. package/dist/modules/security/command-validator.js +25 -20
  123. package/dist/modules/security/encryption.js +6 -12
  124. package/dist/modules/security/network-validator.js +76 -5
  125. package/dist/modules/security/path-validator.js +77 -34
  126. package/dist/modules/security/rate-limiter.js +11 -0
  127. package/dist/modules/security/security-policies.js +1 -1
  128. package/dist/modules/security/session-encryption.js +13 -2
  129. package/dist/modules/security/session-isolation.js +2 -9
  130. package/dist/modules/session/manager.js +11 -0
  131. package/dist/modules/session/module.js +11 -3
  132. package/dist/modules/session/store.js +41 -5
  133. package/dist/modules/skills/loader.js +7 -1
  134. package/dist/modules/skills/module.js +2 -1
  135. package/dist/modules/updater/changelog-reader.js +94 -0
  136. package/dist/modules/updater/dev-detect.js +17 -0
  137. package/dist/modules/updater/index.js +1 -0
  138. package/dist/modules/updater/module.js +14 -3
  139. package/dist/output/bus.js +32 -0
  140. package/dist/output/channel.js +233 -0
  141. package/dist/output/format.js +14 -0
  142. package/dist/output/index.js +7 -0
  143. package/dist/output/json-sink.js +22 -0
  144. package/dist/output/machine.js +8 -0
  145. package/dist/output/session-sink.js +27 -0
  146. package/dist/output/types.js +1 -0
  147. package/dist/tools/approve.js +6 -2
  148. package/dist/tools/attach-image.js +11 -11
  149. package/dist/tools/auto-fixer.js +198 -0
  150. package/dist/tools/bash.js +142 -89
  151. package/dist/tools/chunk-query.js +10 -6
  152. package/dist/tools/download-file.js +1 -1
  153. package/dist/tools/edit-file.js +20 -2
  154. package/dist/tools/executor.js +54 -9
  155. package/dist/tools/glob-tool.js +7 -0
  156. package/dist/tools/grep-tool.js +15 -1
  157. package/dist/tools/index.js +3 -1
  158. package/dist/tools/list-dir.js +3 -1
  159. package/dist/tools/load-skill.js +2 -1
  160. package/dist/tools/mcp-call.js +1 -1
  161. package/dist/tools/move-file.js +5 -4
  162. package/dist/tools/path-utils.js +7 -0
  163. package/dist/tools/pipeline-run.js +1 -1
  164. package/dist/tools/prompt-io.js +28 -0
  165. package/dist/tools/question.js +12 -12
  166. package/dist/tools/scope-request.js +91 -0
  167. package/dist/tools/session-info.js +44 -0
  168. package/dist/tools/set-thinking.js +71 -0
  169. package/dist/tools/subagent.js +50 -9
  170. package/dist/tools/syntax-validator.js +177 -0
  171. package/dist/tools/user-input.js +16 -9
  172. package/dist/tools/write-file.js +17 -1
  173. package/dist/ui/diff.js +10 -0
  174. package/dist/ui/line-editor.js +179 -26
  175. package/dist/ui/line-math.js +20 -3
  176. package/dist/ui/md-formatter.js +100 -10
  177. package/dist/ui/output.js +5 -4
  178. package/dist/ui/plan-view.js +2 -7
  179. package/dist/ui/renderer.js +89 -85
  180. package/dist/ui/spinner.js +14 -4
  181. package/dist/utils/error.js +4 -0
  182. package/dist/utils/index.js +4 -0
  183. package/dist/utils/retry.js +17 -0
  184. package/dist/utils/sleep.js +23 -0
  185. package/dist/utils/truncate.js +9 -0
  186. package/package.json +1 -1
@@ -0,0 +1,524 @@
1
+ import { t } from "../../i18n/index";
2
+ import { PlanCreator, getPlanProgress } from "./planner";
3
+ import { stepTypecheckGate } from "./execution-plugin";
4
+ import { switchStepToDelete } from "./plan-steps";
5
+ import { checkPlanCoverage } from "./plan-coverage";
6
+ function actionList(deps) {
7
+ const metas = deps.store.listAll();
8
+ if (metas.length === 0) {
9
+ return { success: true, output: t("plan.list_empty") };
10
+ }
11
+ const lines = metas.map((m) => {
12
+ const icon = m.status === "active" ? "[*]" : m.status === "draft" ? "[ ]" : "[-]";
13
+ const namePart = m.name ? ` (${m.name})` : "";
14
+ return `${icon} ${m.id}${namePart} — ${m.title} ${m.doneCount}/${m.stepCount}`;
15
+ });
16
+ lines.push("");
17
+ lines.push(t("plan.list_legend"));
18
+ return {
19
+ success: true,
20
+ output: `${t("plan.list_header")}\n${lines.join("\n")}`,
21
+ };
22
+ }
23
+ function actionSwitch(deps, _ctx, args) {
24
+ const planId = String(args.id || "");
25
+ if (!planId) {
26
+ return { success: false, output: t("plan.switch_no_id") };
27
+ }
28
+ const found = deps.store.find(planId);
29
+ if (!found) {
30
+ return {
31
+ success: false,
32
+ output: t("plan.not_found", { id: planId }),
33
+ };
34
+ }
35
+ // Switching to the plan that is already active must be a no-op, not
36
+ // a duplicate (observed: switch on the active plan preserved the
37
+ // active.json and re-set the same plan, leaving two copies).
38
+ if (found.status === "active") {
39
+ return {
40
+ success: true,
41
+ output: t("plan.already_active", {
42
+ id: found.plan.id,
43
+ title: found.plan.title,
44
+ }),
45
+ display: deps.trackerRef.current?.toPromptBlock(),
46
+ };
47
+ }
48
+ deps.preserveActive();
49
+ if (found.status === "draft") {
50
+ deps.store.removeDraft(found.plan.id);
51
+ }
52
+ else if (found.status === "archived") {
53
+ deps.store.removeArchived(found.plan.id);
54
+ }
55
+ deps.setPlan(found.plan);
56
+ // Display from the tracker (setPlan syncs the pointer past any
57
+ // done/skipped steps) so "current: step N" points at the first
58
+ // pending step of the switched-to plan, not always step 1.
59
+ const display = deps.trackerRef.current?.toPromptBlock();
60
+ return {
61
+ success: true,
62
+ output: t("plan.switched", {
63
+ id: found.plan.id,
64
+ title: found.plan.title,
65
+ }),
66
+ display,
67
+ };
68
+ }
69
+ function actionRePlan(deps, _ctx, args) {
70
+ if (!deps.trackerRef.current) {
71
+ return { success: false, output: t("plan.no_active") };
72
+ }
73
+ const newSteps = Array.isArray(args.steps) ? args.steps.map(String) : [];
74
+ if (newSteps.length === 0) {
75
+ return { success: false, output: t("plan.replan_no_steps") };
76
+ }
77
+ const tracker = deps.trackerRef.current;
78
+ const oldPlan = tracker.getPlan();
79
+ const replanned = PlanCreator.replan(oldPlan, newSteps, args.title ? String(args.title) : undefined);
80
+ const keptCount = replanned.steps.length - newSteps.length;
81
+ deps.setPlan(replanned);
82
+ const display = deps.trackerRef.current?.toPromptBlock();
83
+ return {
84
+ success: true,
85
+ output: t("plan.replanned", {
86
+ kept: String(keptCount),
87
+ steps: String(newSteps.length),
88
+ }),
89
+ display,
90
+ };
91
+ }
92
+ function actionCreate(deps, ctx, args) {
93
+ const title = String(args.title || "Task Plan");
94
+ const steps = Array.isArray(args.steps) ? args.steps.map(String) : [];
95
+ if (steps.length === 0) {
96
+ return { success: false, output: t("plan.no_steps") };
97
+ }
98
+ const parsed = deps.parseKinds(args, steps);
99
+ if (parsed.error) {
100
+ return { success: false, output: parsed.error };
101
+ }
102
+ // Never silently discard an in-progress plan (observed:
103
+ // ses_msvuao0h — right after compaction the model called
104
+ // `plan create` again, dumping the 1/6 plan into drafts and
105
+ // starting from scratch). An active plan WITH progress blocks
106
+ // creation until the model consciously aborts it. A fresh plan
107
+ // (all steps pending) is safe to replace — the model may have
108
+ // just changed its mind about the approach.
109
+ const existing = deps.trackerRef.current;
110
+ if (existing) {
111
+ const activePlan = existing.getPlan();
112
+ const progressed = activePlan.steps.some((s) => s.status === "done" || s.status === "failed" || s.status === "skipped");
113
+ if (progressed) {
114
+ const done = activePlan.steps.filter((s) => s.status === "done").length;
115
+ return {
116
+ success: false,
117
+ output: t("plan.active_in_progress", {
118
+ id: activePlan.id,
119
+ done: String(done),
120
+ total: String(activePlan.steps.length),
121
+ current: String(existing.getCurrentStepIndex() + 1),
122
+ }),
123
+ };
124
+ }
125
+ }
126
+ deps.preserveActive();
127
+ const plan = PlanCreator.createPlan(title, steps, deps.baseDir, parsed.kinds ?? []);
128
+ deps.setPlan(plan);
129
+ const display = PlanCreator.toPromptBlock(plan, 0);
130
+ let output = t("plan.created", {
131
+ title,
132
+ steps: String(steps.length),
133
+ });
134
+ // The model sometimes passes an id expecting the plan to be
135
+ // created under it (observed: id "plan_error_check_e07pb2" was
136
+ // ignored, a new id appeared). Be explicit so it doesn't look
137
+ // for a plan by the wrong id afterwards.
138
+ if (args.id) {
139
+ output += `\n${t("plan.id_ignored")}`;
140
+ }
141
+ if (existing) {
142
+ output += `\n${t("plan.existing_fresh")}`;
143
+ }
144
+ let displayOut = display;
145
+ const taskText = deps.getTaskText(ctx);
146
+ if (taskText) {
147
+ const { missing } = checkPlanCoverage(taskText, steps);
148
+ if (missing.length > 0) {
149
+ const warn = t("plan.coverage_warning", {
150
+ missing: missing.join(", "),
151
+ });
152
+ output += `\n⚠️ ${warn}`;
153
+ displayOut = `${display}\n⚠️ ${warn}`;
154
+ }
155
+ }
156
+ return {
157
+ success: true,
158
+ output,
159
+ display: displayOut,
160
+ };
161
+ }
162
+ function actionShow(deps, _ctx, args) {
163
+ const planId = args.id ? String(args.id) : "";
164
+ if (planId && planId !== deps.trackerRef.current?.getPlan()?.id) {
165
+ // Show a specific plan (active, draft, or archived) instead
166
+ // of ignoring the id and always printing the active one
167
+ // (observed: the model asked for an archived plan three
168
+ // times and got the active plan back every time).
169
+ const found = deps.store.find(planId);
170
+ if (!found) {
171
+ return {
172
+ success: false,
173
+ output: t("plan.not_found", { id: planId }),
174
+ };
175
+ }
176
+ const display = PlanCreator.toPromptBlock(found.plan, 0);
177
+ return {
178
+ success: true,
179
+ output: `${t("plan.show_header")}\n${display}`,
180
+ display,
181
+ };
182
+ }
183
+ if (!deps.trackerRef.current) {
184
+ return { success: false, output: t("plan.no_active") };
185
+ }
186
+ const display = deps.trackerRef.current.toPromptBlock();
187
+ return {
188
+ success: true,
189
+ output: `${t("plan.show_header")}\n${display}`,
190
+ display,
191
+ };
192
+ }
193
+ function actionUpdate(deps, ctx, args) {
194
+ // Обновление конкретного шага.
195
+ if (args.step && deps.trackerRef.current) {
196
+ return updateStep(deps, ctx, args);
197
+ }
198
+ // Массовая пересборка плана новыми шагами.
199
+ if (Array.isArray(args.steps) && args.steps.length > 0 && !args.step) {
200
+ return bulkUpdate(deps, args);
201
+ }
202
+ // Ни шага, ни шагов — тот же фолбэк, что был в хвосте монолита.
203
+ return noActiveOrUnknown(deps, args, "update");
204
+ }
205
+ function updateStep(deps, ctx, args) {
206
+ const tracker = deps.trackerRef.current;
207
+ const stepId = Number(args.step);
208
+ const target = tracker.getStep(stepId);
209
+ if (!target) {
210
+ return { success: false, output: t("plan.step_not_found") };
211
+ }
212
+ const status = String(args.status || "done");
213
+ // Completed plan: re-marking a done step of a finished plan is the
214
+ // loop anchor (observed: 3× "plan update step=4 done" on a 4/4 plan
215
+ // while a NEW task waited). Archive + clear so the model can create a
216
+ // fresh plan for the new task instead of spinning.
217
+ if (tracker.isComplete()) {
218
+ const plan = tracker.getPlan();
219
+ deps.store.archivePlan(plan);
220
+ deps.recordCompleted(plan); // final audit still verifies it
221
+ deps.trackerRef.current = null;
222
+ const p = getPlanProgress(plan);
223
+ return {
224
+ success: true,
225
+ output: t("plan.completed_archived", {
226
+ id: plan.id,
227
+ done: String(p.settled),
228
+ total: String(plan.steps.length),
229
+ }),
230
+ };
231
+ }
232
+ // Idempotency: re-marking a done step as done usually means the
233
+ // plan was already auto-advanced (all step files exist) and the
234
+ // model is confirming its own work. Return SUCCESS so the loop
235
+ // isn't confused — a small model misreads success:false as a
236
+ // failed action and retries/spins. Genuine spinning (repeated
237
+ // identical calls without new work) is still caught by the
238
+ // stuck-detector's consecutive-identical-call check.
239
+ if (status === "done" && target.status === "done") {
240
+ const progress = tracker.getProgressString();
241
+ const display = tracker.toPromptBlock();
242
+ return {
243
+ success: true,
244
+ output: `${t("plan.step_already_done", {
245
+ step: String(stepId),
246
+ })}\n${progress}`,
247
+ display,
248
+ };
249
+ }
250
+ // Order enforcement: only the CURRENT step may be marked done.
251
+ // Prevents jumping ahead (step 5 marked done before steps 1-4,
252
+ // observed in the post-mortem session) and the follow-on audit
253
+ // failures ("5/5 steps done, 5 files missing").
254
+ if (status === "done") {
255
+ const blockers = tracker
256
+ .getPlan()
257
+ .steps.filter((s) => s.id < stepId && s.status !== "done" && s.status !== "skipped");
258
+ if (blockers.length > 0) {
259
+ return {
260
+ success: false,
261
+ output: t("plan.order_blocked", {
262
+ step: String(stepId),
263
+ first: String(blockers[0].id),
264
+ desc: blockers[0].description,
265
+ }),
266
+ };
267
+ }
268
+ }
269
+ // Deliverable gate: marking a step done must match its declared
270
+ // intent. "create" (default) → named files must exist; "delete"
271
+ // → named files must be GONE. Intent is model-declared (kind),
272
+ // never keyword-inferred (rule #10).
273
+ //
274
+ // Auto-switch: when kind is "create" but ALL named files are gone,
275
+ // check if the agent actually deleted them (via delete_file/bash rm).
276
+ // Only then switch to "delete" — files that were never created
277
+ // (create-kind steps) remain blocked so the agent must create them.
278
+ if (status === "done" && target.kind !== "delete") {
279
+ const missing = deps.missingStepDeliverables(target);
280
+ if (missing.length > 0) {
281
+ const existing = deps.stillExistingDeliverables(target);
282
+ const allGone = deps.hasStepDeliverables(target) && existing.length === 0;
283
+ if (allGone) {
284
+ // Confirm the agent actually deleted these files (not just
285
+ // never created them) before auto-switching.
286
+ const wasDeleted = deps.wasFileDeleted
287
+ ? missing.every((f) => deps.wasFileDeleted(f))
288
+ : false;
289
+ if (wasDeleted) {
290
+ switchStepToDelete(tracker.getPlan(), target, (p) => deps.store.saveActive(p));
291
+ }
292
+ }
293
+ }
294
+ }
295
+ if (status === "done") {
296
+ if (target.kind === "delete") {
297
+ const leftovers = deps.stillExistingDeliverables(target);
298
+ if (leftovers.length > 0) {
299
+ return {
300
+ success: false,
301
+ output: t("plan.deliverables_remain", {
302
+ step: String(stepId),
303
+ files: leftovers.join(", "),
304
+ }),
305
+ };
306
+ }
307
+ }
308
+ else {
309
+ const missing = deps.missingStepDeliverables(target);
310
+ if (missing.length > 0) {
311
+ return {
312
+ success: false,
313
+ output: t("plan.deliverables_missing", {
314
+ step: String(stepId),
315
+ files: missing.join(", "),
316
+ }),
317
+ };
318
+ }
319
+ }
320
+ }
321
+ // Typecheck gate: "done" while the last edit still fails to compile
322
+ // is a self-certification the harness must refuse (observed: steps
323
+ // marked done with the project typecheck failing on every write —
324
+ // a "fix it later" cascade that never got fixed). The model must
325
+ // either fix the errors (a clean re-edit clears the map) or mark
326
+ // the step skipped.
327
+ if (status === "done") {
328
+ const blocked = stepTypecheckGate(deps.typecheckFailures, target.description);
329
+ if (blocked) {
330
+ return {
331
+ success: false,
332
+ output: t("plan.typecheck_failed", {
333
+ step: String(stepId),
334
+ error: blocked,
335
+ }),
336
+ };
337
+ }
338
+ }
339
+ tracker.updateStepStatus(stepId, status);
340
+ if (args.note)
341
+ tracker.addNote(Number(args.step), String(args.note));
342
+ tracker.syncCurrentStep();
343
+ deps.store.saveActive(tracker.getPlan());
344
+ // A "done" step that names no file tokens has a vacuous gate — its
345
+ // completion cannot be auto-verified. Inform the model so it can
346
+ // run an explicit check or add file paths to the step description.
347
+ const vacuousGate = status === "done" && !deps.hasStepDeliverables(target);
348
+ const vacuousNote = vacuousGate
349
+ ? `\n${t("plan.no_deliverables", { step: String(stepId) })}`
350
+ : "";
351
+ // If this update completed the last step → auto-archive. This breaks
352
+ // the loop where a finished plan stays active and anchors the model
353
+ // to "continue step N, do NOT create a new plan".
354
+ if (tracker.isComplete()) {
355
+ const plan = tracker.getPlan();
356
+ deps.store.archivePlan(plan);
357
+ deps.recordCompleted(plan); // final audit still verifies it
358
+ deps.trackerRef.current = null;
359
+ const p = getPlanProgress(plan);
360
+ return {
361
+ success: true,
362
+ output: `${t("plan.step_status", { step: String(args.step), status: String(args.status || "done") })}\n${t("plan.completed_archived", {
363
+ id: plan.id,
364
+ done: String(p.settled),
365
+ total: String(plan.steps.length),
366
+ })}${vacuousNote}`,
367
+ };
368
+ }
369
+ const progress = tracker.getProgressString();
370
+ const display = tracker.toPromptBlock();
371
+ ctx.sessionLog?.plan("step-update", `${t("plan.step_status", { step: String(args.step), status: String(args.status || "done") })} | ${progress} | current: step ${tracker.getCurrentStepIndex() + 1}`);
372
+ return {
373
+ success: true,
374
+ output: `${t("plan.step_status", { step: String(args.step), status: String(args.status || "done") })}\n${progress}${vacuousNote}`,
375
+ display,
376
+ };
377
+ }
378
+ function bulkUpdate(deps, args) {
379
+ const tracker = deps.trackerRef.current;
380
+ if (!tracker) {
381
+ return { success: false, output: t("plan.no_active") };
382
+ }
383
+ // Rebuilding a plan that already has progress silently discards
384
+ // every status (ses_mt4fn58c: the model sent its whole step list
385
+ // with ✅-emoji statuses expecting progress marks and got a fresh
386
+ // plan back). A not-yet-started plan is safe to rebuild.
387
+ const progressed = tracker.getPlan().steps.some((s) => s.status !== "pending");
388
+ if (progressed) {
389
+ return { success: false, output: t("plan.bulk_update_rejected") };
390
+ }
391
+ const title = String(args.title || tracker.getPlan().title);
392
+ // Диспетчер вызывает bulkUpdate только когда steps — непустой массив, но
393
+ // внутри отдельной функции сужение теряется — восстанавливаем безопасно.
394
+ const steps = Array.isArray(args.steps) ? args.steps.map(String) : [];
395
+ const parsed = deps.parseKinds(args, steps);
396
+ if (parsed.error) {
397
+ return { success: false, output: parsed.error };
398
+ }
399
+ const plan = PlanCreator.createPlan(title, steps, deps.baseDir, parsed.kinds ?? []);
400
+ deps.setPlan(plan);
401
+ const display = deps.trackerRef.current?.toPromptBlock();
402
+ const output = t("plan.updated", {
403
+ title,
404
+ steps: String(steps.length),
405
+ });
406
+ return {
407
+ success: true,
408
+ output,
409
+ display,
410
+ };
411
+ }
412
+ function actionAbort(deps, _ctx, args) {
413
+ const planId = args.id ? String(args.id) : "";
414
+ if (planId) {
415
+ // Abort a SPECIFIC plan (not the active one). Draft → archive,
416
+ // active → archive + clear tracker. Archived plans are already
417
+ // terminal — there is nothing to abort.
418
+ const found = deps.store.find(planId);
419
+ if (!found) {
420
+ return {
421
+ success: false,
422
+ output: t("plan.not_found", { id: planId }),
423
+ };
424
+ }
425
+ if (found.status === "archived") {
426
+ return {
427
+ success: false,
428
+ output: t("plan.already_archived", { id: planId }),
429
+ };
430
+ }
431
+ deps.store.archivePlan(found.plan); // clears active if it was active
432
+ if (found.status === "active") {
433
+ deps.trackerRef.current = null;
434
+ deps.clearCompleted();
435
+ }
436
+ return {
437
+ success: true,
438
+ output: t("plan.aborted_id", { id: found.plan.id }),
439
+ };
440
+ }
441
+ const tracker = deps.trackerRef.current;
442
+ if (!tracker) {
443
+ // Abort with no active plan and no id — previously returned
444
+ // success:true, which made the model believe it aborted a plan
445
+ // that did not exist (observed: 3× abort on an empty list).
446
+ return { success: false, output: t("plan.no_active") };
447
+ }
448
+ deps.store.archivePlan(tracker.getPlan());
449
+ deps.trackerRef.current = null;
450
+ deps.store.clearActive();
451
+ deps.clearCompleted();
452
+ return { success: true, output: t("plan.aborted") };
453
+ }
454
+ function actionDelete(deps, _ctx, args) {
455
+ const planId = String(args.id || "");
456
+ if (!planId) {
457
+ return { success: false, output: t("plan.delete_no_id") };
458
+ }
459
+ const deleted = deps.store.deletePlan(planId);
460
+ if (!deleted) {
461
+ return {
462
+ success: false,
463
+ output: t("plan.not_found", { id: planId }),
464
+ };
465
+ }
466
+ if (deleted === "active") {
467
+ deps.trackerRef.current = null;
468
+ deps.clearCompleted();
469
+ }
470
+ return {
471
+ success: true,
472
+ output: t("plan.deleted", { id: planId }),
473
+ };
474
+ }
475
+ function actionPurge(deps) {
476
+ const count = deps.store.purgeAll();
477
+ deps.trackerRef.current = null;
478
+ deps.clearCompleted();
479
+ return {
480
+ success: true,
481
+ output: t("plan.purged", { count: String(count) }),
482
+ };
483
+ }
484
+ /** Фолбэк из хвоста монолита: readonly-план по id или "нет активного плана". */
485
+ function noActiveOrUnknown(deps, args, action) {
486
+ if (!deps.trackerRef.current) {
487
+ // Update with an id that points at a draft/archived plan: say the
488
+ // plan is read-only instead of a bare "No active plan" (observed:
489
+ // 10+ iterations re-updating an archived plan and getting the same
490
+ // unhelpful answer back).
491
+ if (args.id) {
492
+ const found = deps.store.find(String(args.id));
493
+ if (found && found.status !== "active") {
494
+ return {
495
+ success: false,
496
+ output: t("plan.readonly", { id: found.plan.id, status: found.status }),
497
+ };
498
+ }
499
+ }
500
+ return { success: false, output: t("plan.no_active") };
501
+ }
502
+ return {
503
+ success: false,
504
+ output: t("plan.unknown_action", { action }),
505
+ };
506
+ }
507
+ export const PLAN_ACTIONS = {
508
+ list: actionList,
509
+ switch: actionSwitch,
510
+ "re-plan": actionRePlan,
511
+ create: actionCreate,
512
+ show: actionShow,
513
+ update: actionUpdate,
514
+ abort: actionAbort,
515
+ delete: actionDelete,
516
+ purge: actionPurge,
517
+ };
518
+ /** Диспетчер: возвращает обработчик действия или null для неизвестного action. */
519
+ export function dispatchPlanAction(action, deps, ctx, args) {
520
+ const fn = PLAN_ACTIONS[action];
521
+ if (!fn)
522
+ return null;
523
+ return fn(deps, ctx, args);
524
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Авто-переключение шага плана на kind="delete".
3
+ *
4
+ * Два независимых механизма писали `step.kind = "delete"` и сами персистили
5
+ * план:
6
+ * - plan-tool: шаг помечается done, а все его дативные файлы подтверждённо
7
+ * удалены (deliverable gate);
8
+ * - execution-plugin: модель вызвала delete_file, и удалённый путь
9
+ * пересекается с описанием шага.
10
+ *
11
+ * Здесь — единая точка, чтобы инвариант «сменить kind и сохранить план»
12
+ * жил в одном месте и не расходился между авторами.
13
+ *
14
+ * @returns true, если шаг был переключён (и план сохранён); false, если
15
+ * kind уже был "delete" — ничего не менялось.
16
+ */
17
+ export function switchStepToDelete(plan, step, save) {
18
+ if (step.kind === "delete")
19
+ return false;
20
+ step.kind = "delete";
21
+ save(plan);
22
+ return true;
23
+ }
@@ -1,5 +1,8 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync } from "fs";
1
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readdirSync, rmSync } from "fs";
2
2
  import { join } from "path";
3
+ import { Logger } from "../../logger/app-logger";
4
+ import { errMsg } from "../../utils";
5
+ const log = new Logger("info", "plan-store");
3
6
  const LEGACY_FILE = "plan.json";
4
7
  function readPlanFile(path, fallbackBaseDir) {
5
8
  try {
@@ -23,7 +26,11 @@ function readPlanFile(path, fallbackBaseDir) {
23
26
  }
24
27
  }
25
28
  function writePlanFile(path, plan) {
26
- writeFileSync(path, JSON.stringify(plan, null, 2), "utf-8");
29
+ // Atomic write: a crash mid-write must not leave a truncated plan file
30
+ // (readPlanFile silently returns null on broken JSON → active plan lost).
31
+ const tmpPath = `${path}.tmp`;
32
+ writeFileSync(tmpPath, JSON.stringify(plan, null, 2), "utf-8");
33
+ renameSync(tmpPath, path);
27
34
  }
28
35
  function listDir(dir, baseDir) {
29
36
  if (!existsSync(dir))
@@ -84,7 +91,12 @@ export class PlanStore {
84
91
  try {
85
92
  rmSync(this.legacyPath, { force: true });
86
93
  }
87
- catch { }
94
+ catch (err) {
95
+ log.warn("Failed to remove migrated legacy plan.json", {
96
+ path: this.legacyPath,
97
+ error: errMsg(err),
98
+ });
99
+ }
88
100
  return legacy;
89
101
  }
90
102
  }