getprismo 0.1.39 → 0.1.41

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.
@@ -13,6 +13,8 @@ module.exports = function createAgent(deps) {
13
13
  runShield,
14
14
  runOptimize,
15
15
  openUrl,
16
+ repairExecutors,
17
+ repairPlanner,
16
18
  } = deps;
17
19
 
18
20
  const DEFAULT_WORKSPACE_URL = "https://getprismo.dev/dashboard/dev";
@@ -21,6 +23,15 @@ module.exports = function createAgent(deps) {
21
23
  const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
22
24
  const SAFE_SHIELD_COMMANDS = new Set(["npm", "pnpm", "yarn", "bun", "npx", "pytest", "python", "python3", "node"]);
23
25
  const VALID_MODES = new Set(["observe", "suggest", "autopilot"]);
26
+ // Backend verification only measures these action types, so self-repairs
27
+ // are registered under the matching type for their cause.
28
+ const CAUSE_ACTION_TYPES = {
29
+ "repeated-file-reads": "doctor",
30
+ "tool-output-flood": "shield",
31
+ "generated-artifacts": "doctor",
32
+ "context-loop": "guard",
33
+ "long-session-buildup": "context",
34
+ };
24
35
 
25
36
  function apiBase(config) {
26
37
  return String(config?.apiUrl || DEFAULT_API_URL).replace(/\/$/, "");
@@ -166,6 +177,66 @@ module.exports = function createAgent(deps) {
166
177
  } catch (_) {}
167
178
  }
168
179
 
180
+ // Register an executed self-repair as a real workspace action row so the
181
+ // backend verification loop measures it like a dashboard-queued repair.
182
+ // Returns false when the endpoint is unavailable (older API) so the
183
+ // caller can fall back to the auto-detect channel.
184
+ async function registerSelfRepair(config, plannerResult, options = {}) {
185
+ if (!plannerResult || !plannerResult.decision || !plannerResult.outcome) return false;
186
+ const { cause, tier } = plannerResult.decision;
187
+ const endpoint = options.selfRepairEndpoint || `${apiBase(config)}/v1/dev/workspace/actions/agent`;
188
+ try {
189
+ const created = await requestJson("POST", endpoint, config.token, {
190
+ actionType: CAUSE_ACTION_TYPES[cause] || "doctor",
191
+ command: `${NPX_COMMAND} repair ${cause}`,
192
+ label: `Self-repair: ${cause} (${tier})`,
193
+ targetCause: cause,
194
+ }, options.timeoutMs || 10000);
195
+ const actionId = created.data && created.data.id;
196
+ if (!actionId) return false;
197
+ await updateAction(config, actionId, {
198
+ status: plannerResult.outcome.status,
199
+ statusMessage: plannerResult.outcome.statusMessage,
200
+ result: {
201
+ executor: "repair-planner",
202
+ targetCause: cause,
203
+ tier,
204
+ generatedFiles: plannerResult.outcome.generatedFiles || [],
205
+ },
206
+ }, options);
207
+ return true;
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+
213
+ // Fallback reporting channel for planner activity (planned-only repairs,
214
+ // or APIs without the agent action endpoint).
215
+ async function reportPlanner(config, plannerResult, mode, options = {}) {
216
+ if (!plannerResult || (!plannerResult.decision && !plannerResult.executed)) return;
217
+ const findings = [];
218
+ if (plannerResult.decision) {
219
+ findings.push({
220
+ type: "self-repair",
221
+ cause: plannerResult.decision.cause,
222
+ tier: plannerResult.decision.tier,
223
+ message: plannerResult.outcome && plannerResult.outcome.statusMessage
224
+ ? plannerResult.outcome.statusMessage
225
+ : plannerResult.decision.reason,
226
+ });
227
+ }
228
+ await reportAutoDetect(config, {
229
+ startedAt: plannerResult.generatedAt,
230
+ completedAt: new Date().toISOString(),
231
+ mode,
232
+ score: null,
233
+ findings,
234
+ generatedFiles: plannerResult.outcome ? plannerResult.outcome.generatedFiles : [],
235
+ applied: Boolean(plannerResult.executed),
236
+ needsApproval: mode !== "autopilot" && Boolean(plannerResult.decision),
237
+ }, options);
238
+ }
239
+
169
240
  function openWorkspace(config) {
170
241
  const url = config?.workspaceUrl || DEFAULT_WORKSPACE_URL;
171
242
  if (openUrl) {
@@ -192,6 +263,20 @@ module.exports = function createAgent(deps) {
192
263
  const config = options._config || null;
193
264
  const progress = config ? (step, detail) => reportProgress(config, action.id, step, detail, options) : async () => {};
194
265
 
266
+ // Cause-specific repair executors take precedence: an action that names a
267
+ // waste cause gets a targeted repair instead of the generic command run.
268
+ const targetCause = action.targetCause
269
+ || (parsed.command === "repair" ? parsed.args.find((arg) => !arg.startsWith("-")) : null);
270
+ const causeExecutor = repairExecutors ? repairExecutors.forCause(targetCause) : null;
271
+ if (causeExecutor) {
272
+ // The backend escalates a repair by queueing "repair <cause> --tier
273
+ // aggressive" when the previous repair's verdict was no-change/regressed.
274
+ const tierIndex = parsed.args.indexOf("--tier");
275
+ const tierArg = tierIndex >= 0 ? parsed.args[tierIndex + 1] : null;
276
+ const executorOptions = tierArg ? { ...options, tier: tierArg } : options;
277
+ return causeExecutor(action, root, { progress, parsed, options: executorOptions });
278
+ }
279
+
195
280
  if (parsed.command === "doctor" || action.actionType === "doctor") {
196
281
  await progress("scanning", "Scanning repo for context issues");
197
282
  const result = runDoctor(root, { limit: options.limit || 3, applySuggestions: true, json: true });
@@ -371,6 +456,23 @@ module.exports = function createAgent(deps) {
371
456
  results.push({ id: action.id, label: action.label, ...result });
372
457
  }
373
458
 
459
+ let plannerResult = null;
460
+ if (options.planRepairs && repairPlanner) {
461
+ try {
462
+ plannerResult = await repairPlanner.runPlannerOnce(rootDir, {
463
+ execute: mode === "autopilot",
464
+ sessionLimit: options.plannerSessionLimit,
465
+ });
466
+ const registered = plannerResult.executed
467
+ ? await registerSelfRepair(config, plannerResult, options)
468
+ : false;
469
+ plannerResult.registered = registered;
470
+ if (!registered) await reportPlanner(config, plannerResult, mode, options);
471
+ } catch (error) {
472
+ plannerResult = { error: error && error.message ? error.message : String(error) };
473
+ }
474
+ }
475
+
374
476
  let syncResult = null;
375
477
  if (options.syncTelemetry) {
376
478
  try {
@@ -400,6 +502,7 @@ module.exports = function createAgent(deps) {
400
502
  actionsFailed: results.filter((item) => item.status === "failed").length,
401
503
  actionsObserved: results.filter((item) => item.status === "observed" || item.status === "pending_approval").length,
402
504
  autoDetect: autoDetectResult,
505
+ planner: plannerResult,
403
506
  sync: syncResult,
404
507
  results,
405
508
  privacy: {
@@ -449,6 +552,25 @@ module.exports = function createAgent(deps) {
449
552
  lines.push(` - ${f.message}`);
450
553
  });
451
554
  }
555
+ if (result.planner && !result.planner.error) {
556
+ lines.push("");
557
+ lines.push("Self-repair");
558
+ if (result.planner.decision) {
559
+ lines.push(` Cause: ${result.planner.decision.cause} (${result.planner.decision.tier} tier)`);
560
+ lines.push(` Why: ${result.planner.decision.reason}`);
561
+ if (result.planner.outcome) {
562
+ lines.push(` Status: ${result.planner.outcome.status}`);
563
+ if (result.planner.outcome.statusMessage) lines.push(` ${result.planner.outcome.statusMessage}`);
564
+ } else {
565
+ lines.push(` Status: planned (mode: ${result.mode}); not executed`);
566
+ }
567
+ } else {
568
+ lines.push(" Nothing to repair right now.");
569
+ }
570
+ (result.planner.skipped || []).forEach((item) => {
571
+ lines.push(` Held back: ${item.cause} (${item.reason})`);
572
+ });
573
+ }
452
574
  if (result.sync) {
453
575
  lines.push("");
454
576
  lines.push("Sync");
@@ -479,11 +601,13 @@ module.exports = function createAgent(deps) {
479
601
  const intervalMs = Math.max(5, Number(options.interval || 15)) * 1000;
480
602
  const syncIntervalMs = Math.max(30, Number(options.syncInterval || 60)) * 1000;
481
603
  const detectIntervalMs = Math.max(60, Number(options.detectInterval || 300)) * 1000;
604
+ const plannerIntervalMs = Math.max(60, Number(options.plannerInterval || 600)) * 1000;
482
605
  let running = true;
483
606
  let sleepResolve = null;
484
607
  let firstRun = true;
485
608
  let lastSyncAt = 0;
486
609
  let lastDetectAt = 0;
610
+ let lastPlanAt = 0;
487
611
 
488
612
  if (options.open) {
489
613
  const config = loadConfig();
@@ -513,9 +637,12 @@ module.exports = function createAgent(deps) {
513
637
  if (shouldSync) lastSyncAt = now;
514
638
  const shouldDetect = options.autoDetect !== false && (firstRun || now - lastDetectAt >= detectIntervalMs);
515
639
  if (shouldDetect) lastDetectAt = now;
640
+ const shouldPlan = options.planRepairs !== false && (firstRun || now - lastPlanAt >= plannerIntervalMs);
641
+ if (shouldPlan) lastPlanAt = now;
516
642
  const runOptions = {
517
643
  ...options,
518
644
  autoDetect: shouldDetect,
645
+ planRepairs: shouldPlan,
519
646
  syncTelemetry: shouldSync,
520
647
  };
521
648
  firstRun = false;
@@ -5,7 +5,7 @@ const VALID_COMMANDS = new Set([
5
5
  "dev", "init", "doctor", "firewall", "benchmark", "shield", "mcp",
6
6
  "connect", "sync", "status", "disconnect", "agent", "connector", "setup", "scan",
7
7
  "optimize", "context", "cc", "cursor", "receipt", "instructions",
8
- "timeline", "replay", "boundaries", "usage", "guard", "watch", "demo",
8
+ "timeline", "replay", "boundaries", "usage", "guard", "watch", "demo", "repair",
9
9
  ]);
10
10
 
11
11
  function parseTokenBudget(value) {
@@ -68,6 +68,11 @@ function createCli(deps) {
68
68
  runSync,
69
69
  renderGuardTerminal,
70
70
  runGuard,
71
+ REPAIR_CAUSES,
72
+ renderRepairTerminal,
73
+ runRepair,
74
+ renderPlannerTerminal,
75
+ runPlannerOnce,
71
76
  renderAgentTerminal,
72
77
  runAgent,
73
78
  renderConnectorTerminal,
@@ -425,6 +430,7 @@ function createCli(deps) {
425
430
  const intervalIndex = rest.indexOf("--interval");
426
431
  const syncIntervalIndex = rest.indexOf("--sync-interval");
427
432
  const detectIntervalIndex = rest.indexOf("--detect-interval");
433
+ const plannerIntervalIndex = rest.indexOf("--planner-interval");
428
434
  const limitIndex = rest.indexOf("--limit");
429
435
  const budgetIndex = rest.indexOf("--budget");
430
436
  const modeIndex = rest.indexOf("--mode");
@@ -432,7 +438,7 @@ function createCli(deps) {
432
438
  if (!AGENT_VALID_MODES.has(modeValue)) {
433
439
  throw new Error(`Invalid agent mode: ${modeValue}. Valid modes: observe, suggest, autopilot`);
434
440
  }
435
- const positional = getPositionals(rest, new Set(["--interval", "--sync-interval", "--detect-interval", "--limit", "--budget", "--mode"]));
441
+ const positional = getPositionals(rest, new Set(["--interval", "--sync-interval", "--detect-interval", "--planner-interval", "--limit", "--budget", "--mode"]));
436
442
  const target = positional[0] || process.cwd();
437
443
  const agentOptions = {
438
444
  json,
@@ -440,10 +446,12 @@ function createCli(deps) {
440
446
  watch: rest.includes("--watch") && !rest.includes("--once"),
441
447
  open: rest.includes("--open"),
442
448
  autoDetect: !rest.includes("--no-detect"),
449
+ planRepairs: !rest.includes("--no-planner"),
443
450
  noSync: rest.includes("--no-sync"),
444
451
  interval: parsePositiveInt(intervalIndex >= 0 ? rest[intervalIndex + 1] : null, 15),
445
452
  syncInterval: parsePositiveInt(syncIntervalIndex >= 0 ? rest[syncIntervalIndex + 1] : null, 60),
446
453
  detectInterval: parsePositiveInt(detectIntervalIndex >= 0 ? rest[detectIntervalIndex + 1] : null, 300),
454
+ plannerInterval: parsePositiveInt(plannerIntervalIndex >= 0 ? rest[plannerIntervalIndex + 1] : null, 600),
447
455
  limit: parsePositiveInt(limitIndex >= 0 ? rest[limitIndex + 1] : null, 5),
448
456
  syncLimit: parsePositiveInt(limitIndex >= 0 ? rest[limitIndex + 1] : null, 20),
449
457
  tokenBudget: parseTokenBudget(budgetIndex >= 0 ? rest[budgetIndex + 1] : null) || 600000,
@@ -758,6 +766,41 @@ function createCli(deps) {
758
766
  return;
759
767
  }
760
768
 
769
+ if (command === "repair") {
770
+ const json = rest.includes("--json");
771
+ const separatorIndex = rest.indexOf("--");
772
+ const ownArgs = separatorIndex >= 0 ? rest.slice(0, separatorIndex) : rest;
773
+ const commandArgs = separatorIndex >= 0 ? rest.slice(separatorIndex + 1) : [];
774
+ const positional = getPositionals(ownArgs, new Set(["--limit", "--budget", "--scope", "--tier"]));
775
+ const cause = (positional[0] || "").toLowerCase();
776
+ const target = positional[1] || process.cwd();
777
+ const limitIndex = ownArgs.indexOf("--limit");
778
+ const budgetIndex = ownArgs.indexOf("--budget");
779
+ const scopeIndex = ownArgs.indexOf("--scope");
780
+ const tierIndex = ownArgs.indexOf("--tier");
781
+ if (cause === "auto") {
782
+ const result = await runPlannerOnce(target, {
783
+ execute: !ownArgs.includes("--dry-run"),
784
+ limit: parsePositiveInt(limitIndex >= 0 ? ownArgs[limitIndex + 1] : null, 5),
785
+ tokenBudget: parseTokenBudget(budgetIndex >= 0 ? ownArgs[budgetIndex + 1] : null),
786
+ });
787
+ if (json) console.log(JSON.stringify(result, null, 2));
788
+ else console.log(renderPlannerTerminal(result));
789
+ return;
790
+ }
791
+ const result = await runRepair(target, cause, {
792
+ limit: parsePositiveInt(limitIndex >= 0 ? ownArgs[limitIndex + 1] : null, 5),
793
+ tokenBudget: parseTokenBudget(budgetIndex >= 0 ? ownArgs[budgetIndex + 1] : null),
794
+ scope: scopeIndex >= 0 ? ownArgs[scopeIndex + 1] : null,
795
+ tier: tierIndex >= 0 ? ownArgs[tierIndex + 1] : null,
796
+ commandArgs,
797
+ });
798
+ if (json) console.log(JSON.stringify(result, null, 2));
799
+ else console.log(renderRepairTerminal(result));
800
+ if (result.status === "failed") process.exitCode = 1;
801
+ return;
802
+ }
803
+
761
804
  if (command === "usage" || command === "watch") {
762
805
  const json = rest.includes("--json");
763
806
  const knownTools = new Set(["codex", "claude", "cursor", "all"]);
@@ -496,6 +496,7 @@ module.exports = function createCloudSync(deps) {
496
496
  return {
497
497
  buildSyncPayload,
498
498
  configPath,
499
+ estimateWaste,
499
500
  loadConfig,
500
501
  renderConnectTerminal,
501
502
  renderDisconnectTerminal,
@@ -33,6 +33,7 @@ Usage:
33
33
  prismo boundaries [codex|claude|cursor|all] [--json] [--limit N] [path]
34
34
  prismo usage [codex|claude|cursor|all] [--json] [--limit N] [path]
35
35
  prismo guard [codex|claude|cursor|all] [--json] [--watch] [--once] [--no-sync] [--dry-run] [--limit N] [--budget N] [--interval N] [path]
36
+ prismo repair <cause|auto> [--json] [--dry-run] [--tier mild|aggressive] [--limit N] [--budget N] [--scope SCOPE] [path] [-- <command ...>]
36
37
  prismo watch [codex|claude|cursor|all] [--json] [--once] [--agents] [--report] [--rescue] [--guardrails] [--throttle] [--events] [--no-events] [--auto] [--budget N] [--redact-paths] [--interval N] [path]
37
38
  prismo demo
38
39
 
@@ -62,6 +63,7 @@ Commands:
62
63
  boundaries Check whether parallel agents are isolated or overlapping.
63
64
  usage Read local Codex/Claude Code/Cursor session logs and summarize token usage.
64
65
  guard Run proactive local guardrails and sync prevention events to Prismo.
66
+ repair Run the cause-specific repair for a detected waste cause; "auto" lets the planner pick.
65
67
  watch Refresh local session usage in the terminal.
66
68
  demo Show sample output without needing a messy repo.
67
69
  setup Detect coding tools, tracking modes, local logs, and Prismo proxy readiness.
@@ -288,6 +290,37 @@ Examples:
288
290
  Output:
289
291
  Runs proactive local protection on top of Prismo watch: live guardrails, rescue prompt, context throttle, context firewall, guard event log, and dashboard-ready prevention events.
290
292
  Guard never uploads prompts, source code, file contents, stdout, stderr, or full command logs.`,
293
+ repair: `PrismoDev Repair
294
+
295
+ Usage:
296
+ prismo repair <cause|auto> [--json] [--dry-run] [--tier mild|aggressive] [--limit N] [--budget N] [--scope SCOPE] [path] [-- <command ...>]
297
+
298
+ Causes:
299
+ repeated-file-reads Refresh ignore rules and context packs, and map hot files into .prismo/hot-files.md.
300
+ tool-output-flood Stage noisy commands behind shield in .prismo/noisy-commands.md; run a safe command shielded when passed after --.
301
+ generated-artifacts Append ignore rules for scan-detected build output plus artifact paths seen in recent sessions.
302
+ context-loop Run a tightened guard snapshot and write loop-breaking rules to .prismo/loop-breaker.md.
303
+ long-session-buildup Generate scoped context packs and a fresh-session restart routine in .prismo/session-restart.md.
304
+ auto Let the planner score recent sessions, pick the top cause, and run its repair.
305
+
306
+ Examples:
307
+ prismo repair auto
308
+ prismo repair auto --dry-run
309
+ prismo repair repeated-file-reads
310
+ prismo repair tool-output-flood -- npm test
311
+ prismo repair generated-artifacts --json
312
+ prismo repair context-loop --budget 400k
313
+ prismo repair long-session-buildup --scope frontend
314
+
315
+ Output:
316
+ Runs the targeted repair for one waste cause instead of the generic doctor flow.
317
+ These are the same executors the workspace agent uses when a queued action carries a targetCause.
318
+ "auto" applies cooldowns and verdict feedback from .prismo/repair-state.json: a repair that came back
319
+ no-change or regressed escalates to an aggressive tier (adds a context firewall policy and tighter budgets);
320
+ a cause that already failed both tiers is held for review instead of being retried forever.
321
+ --dry-run with auto prints the planner decision without executing.
322
+ --tier aggressive forces the stronger repair; cloud-queued actions carry it automatically after a no-change/regressed verdict.
323
+ Repairs only write .prismo/ files and append ignore rules with backups; they never overwrite CLAUDE.md, AGENTS.md, .gitignore, or source code.`,
291
324
  watch: `Prismo Watch
292
325
 
293
326
  Usage:
@@ -492,7 +525,7 @@ Output:
492
525
  agent: `PrismoDev Agent
493
526
 
494
527
  Usage:
495
- prismo agent [--json] [--once] [--watch] [--open] [--no-detect] [--no-sync] [--interval N] [--sync-interval N] [--limit N] [--mode MODE] [path]
528
+ prismo agent [--json] [--once] [--watch] [--open] [--no-detect] [--no-planner] [--no-sync] [--interval N] [--sync-interval N] [--planner-interval N] [--limit N] [--mode MODE] [path]
496
529
 
497
530
  Modes:
498
531
  observe Watch and report actions without executing. No changes made.
@@ -511,6 +544,8 @@ Examples:
511
544
  Options:
512
545
  --open Open the Prismo workspace in the browser on start.
513
546
  --no-detect Skip the initial auto-detect scan on first poll.
547
+ --no-planner Disable the self-repair planner (cause scoring, cooldowns, tier escalation).
548
+ --planner-interval N Seconds between self-repair planner runs in watch mode (default 600).
514
549
  --no-sync Keep watch mode from continuously syncing aggregate telemetry.
515
550
 
516
551
  Output:
@@ -520,6 +555,10 @@ Output:
520
555
  Sends heartbeat to Prismo Cloud on each poll and syncs aggregate telemetry on a controlled interval so the dashboard stays fresh.
521
556
  Handles SIGINT/SIGTERM gracefully and marks the agent offline before exiting.
522
557
  Supported actions are doctor, sync, guard, context/optimize, and shield with a conservative command allowlist.
558
+ Actions queued with a targetCause run cause-specific repair executors instead of the generic command; see prismo repair --help.
559
+ In autopilot the self-repair planner also runs on its own interval: it scores waste causes from local sessions,
560
+ repairs the top cause above threshold, judges the result against later sessions, and escalates or backs off accordingly.
561
+ In observe/suggest modes the planner only reports what it would repair.
523
562
  Agent does not upload prompts, source code, file contents, stdout, stderr, or full command logs.`,
524
563
  ci: `Prismo CI
525
564
 
@@ -0,0 +1,543 @@
1
+ module.exports = function createRepairExecutors(deps) {
2
+ const {
3
+ fs,
4
+ path,
5
+ NPX_COMMAND,
6
+ runDoctor,
7
+ runOptimize,
8
+ runGuard,
9
+ runShield,
10
+ runFirewall,
11
+ getUsageSummary,
12
+ appendIgnoreSuggestions,
13
+ } = deps;
14
+
15
+ const REPAIR_CAUSES = [
16
+ "repeated-file-reads",
17
+ "tool-output-flood",
18
+ "generated-artifacts",
19
+ "context-loop",
20
+ "long-session-buildup",
21
+ ];
22
+
23
+ const SAFE_SHIELD_COMMANDS = new Set(["npm", "pnpm", "yarn", "bun", "npx", "pytest", "python", "python3", "node"]);
24
+ const EXPECTED_REPEATED_PATHS = new Set(["claude.md", "agents.md", "readme.md"]);
25
+
26
+ function nowIso() {
27
+ return new Date().toISOString();
28
+ }
29
+
30
+ function noopProgress() {
31
+ return Promise.resolve();
32
+ }
33
+
34
+ function writeRepairFile(root, name, contents) {
35
+ const relPath = path.join(".prismo", name);
36
+ const fullPath = path.join(root, relPath);
37
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
38
+ fs.writeFileSync(fullPath, contents, "utf8");
39
+ return relPath;
40
+ }
41
+
42
+ function collectSessions(root, limit) {
43
+ try {
44
+ const summary = getUsageSummary({ tool: "all", cwd: root, limit: limit || 5 });
45
+ return summary.sessions || [];
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ function aggregateEntries(sessions, pick) {
52
+ const totals = new Map();
53
+ for (const session of sessions) {
54
+ for (const entry of pick(session) || []) {
55
+ if (!entry || !entry.value) continue;
56
+ totals.set(entry.value, (totals.get(entry.value) || 0) + Number(entry.count || 0));
57
+ }
58
+ }
59
+ return Array.from(totals, ([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count);
60
+ }
61
+
62
+ function isExpectedRepeatedPath(value) {
63
+ const normalized = String(value || "").trim().toLowerCase();
64
+ return EXPECTED_REPEATED_PATHS.has(normalized) || normalized.endsWith("/readme.md");
65
+ }
66
+
67
+ function parseSafeCommandArgs(command) {
68
+ const parts = String(command || "").trim().split(/\s+/).filter(Boolean);
69
+ const separatorIndex = parts.indexOf("--");
70
+ if (separatorIndex < 0) return null;
71
+ const commandArgs = parts.slice(separatorIndex + 1);
72
+ if (!commandArgs.length) return null;
73
+ if (!SAFE_SHIELD_COMMANDS.has(commandArgs[0])) return null;
74
+ if (commandArgs.some((arg) => /[;&|`$<>]/.test(arg))) return null;
75
+ return commandArgs;
76
+ }
77
+
78
+ function repairTier(helpers) {
79
+ return helpers.options && helpers.options.tier === "aggressive" ? "aggressive" : "mild";
80
+ }
81
+
82
+ // Aggressive tier adds a context firewall policy on top of the mild repair,
83
+ // so the cause is fenced off by an allow/block file instead of guidance only.
84
+ function applyAggressiveFirewall(root, cause) {
85
+ if (!runFirewall) return [];
86
+ try {
87
+ const firewall = runFirewall(root, { task: cause, dryRun: false });
88
+ return firewall.generatedFiles || [];
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ function parseScope(action, helpers) {
95
+ if (helpers.options && helpers.options.scope) return helpers.options.scope;
96
+ const args = helpers.parsed ? helpers.parsed.args : String(action.command || "").trim().split(/\s+/).slice(1);
97
+ const scope = (args || []).find((arg) => arg && !arg.startsWith("-"));
98
+ return ["frontend", "backend"].includes(String(scope || "").toLowerCase()) ? String(scope).toLowerCase() : null;
99
+ }
100
+
101
+ function looksLikeIgnoreRule(value) {
102
+ const rule = String(value || "").trim();
103
+ if (!rule || rule.length > 200) return false;
104
+ if (rule.startsWith("/") || rule.startsWith("-") || rule.includes("..")) return false;
105
+ return /^[A-Za-z0-9_.*/@-]+$/.test(rule);
106
+ }
107
+
108
+ function renderHotFilesGuide(hotPaths, contextFile) {
109
+ return [
110
+ "# Prismo Repair: Repeated File Reads",
111
+ "",
112
+ "These files were read repeatedly across recent coding-agent sessions.",
113
+ "Read each one once, then rely on the compact context packs below instead of re-opening the file.",
114
+ "",
115
+ "## Hot Files",
116
+ "",
117
+ ...(hotPaths.length
118
+ ? hotPaths.map((entry) => `- ${entry.value} (${entry.count} reads)`)
119
+ : ["- No repeated reads detected in recent sessions."]),
120
+ "",
121
+ "## Session Rules",
122
+ "",
123
+ `- Start sessions from ${contextFile || ".prismo/architecture-summary.md"} instead of broad exploration.`,
124
+ "- Quote the relevant section from a context pack instead of re-reading a hot file.",
125
+ "- If a hot file changed during the session, re-read only the changed region.",
126
+ "",
127
+ ].join("\n");
128
+ }
129
+
130
+ function renderNoisyCommandsGuide(noisyCommands, shieldedRun) {
131
+ return [
132
+ "# Prismo Repair: Tool-Output Flood",
133
+ "",
134
+ "These commands repeatedly flooded agent context with raw output.",
135
+ `Run them through \`${NPX_COMMAND} shield -- <command>\` so the full output stays on disk and only a compact summary enters context.`,
136
+ "",
137
+ "## Noisy Commands",
138
+ "",
139
+ ...(noisyCommands.length
140
+ ? noisyCommands.map((entry) => `- \`${entry.value}\` (${entry.count} runs) -> \`${NPX_COMMAND} shield -- ${entry.value}\``)
141
+ : ["- No repeated noisy commands detected in recent sessions."]),
142
+ "",
143
+ ...(shieldedRun
144
+ ? [
145
+ "## Last Shielded Run",
146
+ "",
147
+ `- Command: \`${shieldedRun.command}\``,
148
+ `- Exit code: ${shieldedRun.exitCode}`,
149
+ shieldedRun.runDir ? `- Full output stored in: ${shieldedRun.runDir}` : null,
150
+ "",
151
+ ].filter((line) => line !== null)
152
+ : []),
153
+ "## Session Rules",
154
+ "",
155
+ "- Never paste full test, build, or install output into the conversation.",
156
+ "- Use shield summaries; search stored runs with `shield search` when details are needed.",
157
+ "",
158
+ ].join("\n");
159
+ }
160
+
161
+ function renderLoopBreakerGuide(repeatedCommands, loopSessionCount) {
162
+ return [
163
+ "# Prismo Repair: Context Loop",
164
+ "",
165
+ loopSessionCount > 0
166
+ ? `${loopSessionCount} recent session(s) showed loop behavior: the same command retried with growing context.`
167
+ : "No active loop detected, but guardrails below prevent retry loops from building context waste.",
168
+ "",
169
+ "## Repeated Commands",
170
+ "",
171
+ ...(repeatedCommands.length
172
+ ? repeatedCommands.map((entry) => `- \`${entry.value}\` (${entry.count} runs)`)
173
+ : ["- No repeated commands detected in recent sessions."]),
174
+ "",
175
+ "## Loop-Breaking Rules",
176
+ "",
177
+ "- After 2 failed attempts of the same command, stop and change the approach instead of retrying.",
178
+ `- Route retry-heavy commands through \`${NPX_COMMAND} shield -- <command>\` so each retry costs a summary, not full output.`,
179
+ "- If the session keeps circling, start a fresh session from the .prismo context packs and state what already failed.",
180
+ "- Follow .prismo/live-guardrails.md while the session is active.",
181
+ "",
182
+ ].join("\n");
183
+ }
184
+
185
+ function renderSessionRestartGuide(scope, starterPrompt, heavySessionCount) {
186
+ return [
187
+ "# Prismo Repair: Long-Session Buildup",
188
+ "",
189
+ heavySessionCount > 0
190
+ ? `${heavySessionCount} recent session(s) carried High/Medium context risk from accumulated history.`
191
+ : "Recent sessions look healthy; the restart routine below keeps them that way.",
192
+ "",
193
+ "## Restart Routine",
194
+ "",
195
+ "- Split work at task boundaries: one task, one session.",
196
+ `- Start each new session from the scoped context pack${scope ? ` for ${scope}` : ""} instead of carrying the old conversation forward.`,
197
+ "- When a session crosses ~60% of its budget, finish the current task and restart.",
198
+ "",
199
+ ...(starterPrompt
200
+ ? ["## Paste-Ready Starter Prompt", "", "```", starterPrompt, "```", ""]
201
+ : []),
202
+ ].join("\n");
203
+ }
204
+
205
+ // Repeated file reads: refresh ignore rules and context packs via doctor,
206
+ // then map the hot files into a guide agents can read instead of the files.
207
+ async function repairRepeatedFileReads(action, root, helpers = {}) {
208
+ const progress = helpers.progress || noopProgress;
209
+ const options = helpers.options || {};
210
+ const startedAt = nowIso();
211
+
212
+ await progress("analyzing", "Measuring repeated file reads in recent sessions");
213
+ const sessions = collectSessions(root, options.limit || 5);
214
+ const hotPaths = aggregateEntries(sessions, (session) => session.repeatedPathMentions)
215
+ .filter((entry) => !isExpectedRepeatedPath(entry.value))
216
+ .slice(0, 10);
217
+
218
+ await progress("fixing", hotPaths.length
219
+ ? `Found ${hotPaths.length} hot file(s); refreshing ignore rules and context packs`
220
+ : "No hot files found; refreshing ignore rules and context packs as prevention");
221
+ const doctor = runDoctor(root, { limit: options.limit || 3, applySuggestions: true, json: true });
222
+ const generatedFiles = doctor.generatedFiles || [];
223
+ const guidePath = writeRepairFile(root, "hot-files.md", renderHotFilesGuide(hotPaths, doctor.contextFile));
224
+ const tier = repairTier(helpers);
225
+ const firewallFiles = tier === "aggressive" ? applyAggressiveFirewall(root, "repeated-file-reads") : [];
226
+
227
+ await progress("done", `Mapped ${hotPaths.length} hot file(s) into ${guidePath}`);
228
+ return {
229
+ status: "completed",
230
+ statusMessage: (hotPaths.length
231
+ ? `Repaired repeated file reads: ${hotPaths.length} hot file(s) mapped into ${guidePath}; context packs refreshed so agents read summaries once.`
232
+ : `No repeated-read hot files detected; refreshed ignore rules and context packs, and wrote ${guidePath} as prevention.`)
233
+ + (firewallFiles.length ? " Aggressive tier: context firewall policy added." : ""),
234
+ result: {
235
+ command: "repair",
236
+ targetCause: "repeated-file-reads",
237
+ tier,
238
+ startedAt,
239
+ completedAt: nowIso(),
240
+ hotPaths,
241
+ fixActions: doctor.fixActions || [],
242
+ generatedFiles: [...generatedFiles, guidePath, ...firewallFiles],
243
+ score: doctor.after && typeof doctor.after.score === "number" ? doctor.after.score : null,
244
+ },
245
+ };
246
+ }
247
+
248
+ // Tool-output flood: stage the noisy commands behind shield and, when the
249
+ // action carries a safe command, run it shielded so output stays on disk.
250
+ async function repairToolOutputFlood(action, root, helpers = {}) {
251
+ const progress = helpers.progress || noopProgress;
252
+ const options = helpers.options || {};
253
+ const startedAt = nowIso();
254
+
255
+ await progress("analyzing", "Finding commands that flood agent context");
256
+ const sessions = collectSessions(root, options.limit || 5);
257
+ const noisyCommands = aggregateEntries(sessions, (session) => session.repeatedCommands).slice(0, 8);
258
+
259
+ const commandArgs = Array.isArray(options.commandArgs) && options.commandArgs.length
260
+ ? (SAFE_SHIELD_COMMANDS.has(options.commandArgs[0]) && !options.commandArgs.some((arg) => /[;&|`$<>]/.test(arg)) ? options.commandArgs : null)
261
+ : parseSafeCommandArgs(action.command);
262
+
263
+ let shieldedRun = null;
264
+ if (commandArgs) {
265
+ await progress("shielding", `Running shielded command: ${commandArgs.join(" ")}`);
266
+ const shield = runShield(root, commandArgs);
267
+ shieldedRun = {
268
+ command: commandArgs.join(" "),
269
+ exitCode: shield.exitCode,
270
+ summary: shield.summary || null,
271
+ runDir: shield.runDir || null,
272
+ };
273
+ }
274
+
275
+ const guidePath = writeRepairFile(root, "noisy-commands.md", renderNoisyCommandsGuide(noisyCommands, shieldedRun));
276
+ const tier = repairTier(helpers);
277
+ const firewallFiles = tier === "aggressive" ? applyAggressiveFirewall(root, "tool-output-flood") : [];
278
+ await progress("done", shieldedRun
279
+ ? `Shielded run stored; ${noisyCommands.length} noisy command(s) staged in ${guidePath}`
280
+ : `${noisyCommands.length} noisy command(s) staged in ${guidePath}`);
281
+
282
+ const statusMessage = (shieldedRun
283
+ ? `Repaired tool-output flood: ran \`${shieldedRun.command}\` shielded (exit ${shieldedRun.exitCode}); full output stays on disk and ${guidePath} routes noisy commands through shield.`
284
+ : `Repaired tool-output flood: staged ${noisyCommands.length} noisy command(s) behind shield in ${guidePath}. Queue a \`shield -- <command>\` action to capture a run.`)
285
+ + (firewallFiles.length ? " Aggressive tier: context firewall policy added." : "");
286
+
287
+ return {
288
+ status: "completed",
289
+ statusMessage,
290
+ result: {
291
+ command: "repair",
292
+ targetCause: "tool-output-flood",
293
+ tier,
294
+ startedAt,
295
+ completedAt: nowIso(),
296
+ noisyCommands,
297
+ shieldedRun,
298
+ generatedFiles: [guidePath, ...firewallFiles],
299
+ },
300
+ };
301
+ }
302
+
303
+ // Generated artifacts: append ignore rules for scan-detected risks plus the
304
+ // specific artifact paths observed entering recent sessions.
305
+ async function repairGeneratedArtifacts(action, root, helpers = {}) {
306
+ const progress = helpers.progress || noopProgress;
307
+ const options = helpers.options || {};
308
+ const startedAt = nowIso();
309
+
310
+ await progress("analyzing", "Finding generated artifacts leaking into context");
311
+ const sessions = collectSessions(root, options.limit || 5);
312
+ const artifacts = aggregateEntries(sessions, (session) => session.generatedArtifacts).slice(0, 10);
313
+
314
+ await progress("fixing", "Appending ignore rules for generated artifacts");
315
+ const doctor = runDoctor(root, { limit: options.limit || 3, applySuggestions: true, noContextPacks: true, json: true });
316
+ const fixActions = [...(doctor.fixActions || [])];
317
+
318
+ const sessionRules = Array.from(new Set(artifacts.map((entry) => entry.value).filter(looksLikeIgnoreRule)));
319
+ if (sessionRules.length) {
320
+ const hasClaudeIgnore = fs.existsSync(path.join(root, ".claudeignore"));
321
+ const hasCursorIgnore = fs.existsSync(path.join(root, ".cursorignore"));
322
+ fixActions.push(...appendIgnoreSuggestions({
323
+ root,
324
+ hasClaudeIgnore,
325
+ recommendedClaudeIgnore: sessionRules,
326
+ missingClaudeIgnoreSuggestions: sessionRules,
327
+ hasCursorIgnore,
328
+ recommendedCursorIgnore: sessionRules,
329
+ missingCursorIgnoreSuggestions: sessionRules,
330
+ }));
331
+ }
332
+
333
+ const tier = repairTier(helpers);
334
+ const firewallFiles = tier === "aggressive" ? applyAggressiveFirewall(root, "generated-artifacts") : [];
335
+
336
+ await progress("done", `Ignore rules updated; ${artifacts.length} session-observed artifact(s) covered`);
337
+ return {
338
+ status: "completed",
339
+ statusMessage: (artifacts.length
340
+ ? `Repaired generated artifacts: ignore rules now cover ${artifacts.length} artifact path(s) seen in recent sessions plus scan-detected build output.`
341
+ : "No artifact mentions found in recent sessions; refreshed scan-detected ignore rules as prevention.")
342
+ + (firewallFiles.length ? " Aggressive tier: context firewall policy added." : ""),
343
+ result: {
344
+ command: "repair",
345
+ targetCause: "generated-artifacts",
346
+ tier,
347
+ startedAt,
348
+ completedAt: nowIso(),
349
+ artifacts,
350
+ fixActions,
351
+ generatedFiles: firewallFiles,
352
+ score: doctor.after && typeof doctor.after.score === "number" ? doctor.after.score : null,
353
+ },
354
+ };
355
+ }
356
+
357
+ // Context loop: run a guard snapshot with a tighter budget and write a
358
+ // loop-breaker guide built from the actual repeated commands.
359
+ async function repairContextLoop(action, root, helpers = {}) {
360
+ const progress = helpers.progress || noopProgress;
361
+ const options = helpers.options || {};
362
+ const startedAt = nowIso();
363
+
364
+ await progress("analyzing", "Looking for retry loops in recent sessions");
365
+ const sessions = collectSessions(root, options.limit || 5);
366
+ const loopSessionCount = sessions.filter((session) => session.loopSuspicion).length;
367
+ const repeatedCommands = aggregateEntries(sessions, (session) => session.repeatedCommands).slice(0, 8);
368
+
369
+ const tier = repairTier(helpers);
370
+ await progress("guarding", "Running guard snapshot with a tightened token budget");
371
+ const guard = await runGuard(root, {
372
+ tool: "all",
373
+ limit: options.limit || 5,
374
+ tokenBudget: options.tokenBudget || (tier === "aggressive" ? 250000 : 400000),
375
+ noSync: false,
376
+ watch: false,
377
+ });
378
+
379
+ const guidePath = writeRepairFile(root, "loop-breaker.md", renderLoopBreakerGuide(repeatedCommands, loopSessionCount));
380
+ const firewallFiles = tier === "aggressive" ? applyAggressiveFirewall(root, "context-loop") : [];
381
+ const eventCount = guard.events ? guard.events.length : 0;
382
+ await progress("done", `Guard recorded ${eventCount} event(s); loop-breaker rules written to ${guidePath}`);
383
+
384
+ return {
385
+ status: "completed",
386
+ statusMessage: (loopSessionCount > 0
387
+ ? `Repaired context loop: ${loopSessionCount} looping session(s) found; guard tightened and loop-breaker rules written to ${guidePath}.`
388
+ : `No active loop found; guard tightened and loop-breaker rules written to ${guidePath} as prevention.`)
389
+ + (firewallFiles.length ? " Aggressive tier: context firewall policy added and budget tightened." : ""),
390
+ result: {
391
+ command: "repair",
392
+ targetCause: "context-loop",
393
+ tier,
394
+ startedAt,
395
+ completedAt: nowIso(),
396
+ loopSessions: loopSessionCount,
397
+ repeatedCommands,
398
+ guardEvents: eventCount,
399
+ generatedFiles: [guidePath, ...firewallFiles],
400
+ },
401
+ };
402
+ }
403
+
404
+ // Long-session buildup: generate scoped context packs and a restart routine
405
+ // so new sessions start small instead of inheriting stale history.
406
+ async function repairLongSessionBuildup(action, root, helpers = {}) {
407
+ const progress = helpers.progress || noopProgress;
408
+ const options = helpers.options || {};
409
+ const startedAt = nowIso();
410
+
411
+ await progress("analyzing", "Checking recent sessions for context buildup");
412
+ const sessions = collectSessions(root, options.limit || 5);
413
+ const heavySessionCount = sessions.filter((session) => session.contextRisk === "High" || session.contextRisk === "Medium").length;
414
+
415
+ const scope = parseScope(action, helpers);
416
+ await progress("generating", `Generating ${scope ? `${scope} ` : ""}context packs for fresh-session starts`);
417
+ const optimize = runOptimize(root, { scope });
418
+ const generatedFiles = optimize.generatedFiles || [];
419
+ const guidePath = writeRepairFile(root, "session-restart.md", renderSessionRestartGuide(optimize.scope || scope, optimize.starterPrompt, heavySessionCount));
420
+ const tier = repairTier(helpers);
421
+ const firewallFiles = tier === "aggressive" ? applyAggressiveFirewall(root, "long-session-buildup") : [];
422
+
423
+ await progress("done", `Generated ${generatedFiles.length} context file(s); restart routine in ${guidePath}`);
424
+ return {
425
+ status: "completed",
426
+ statusMessage: (heavySessionCount > 0
427
+ ? `Repaired long-session buildup: ${heavySessionCount} heavy session(s) found; scoped context packs and a restart routine are in place so new sessions start small.`
428
+ : `Recent sessions look healthy; generated scoped context packs and a restart routine in ${guidePath} to keep buildup down.`)
429
+ + (firewallFiles.length ? " Aggressive tier: context firewall policy added." : ""),
430
+ result: {
431
+ command: "repair",
432
+ targetCause: "long-session-buildup",
433
+ tier,
434
+ startedAt,
435
+ completedAt: nowIso(),
436
+ heavySessions: heavySessionCount,
437
+ scope: optimize.scope || scope,
438
+ starterPrompt: optimize.starterPrompt || null,
439
+ generatedFiles: [...generatedFiles, guidePath, ...firewallFiles],
440
+ },
441
+ };
442
+ }
443
+
444
+ const executors = {
445
+ "repeated-file-reads": repairRepeatedFileReads,
446
+ "tool-output-flood": repairToolOutputFlood,
447
+ "generated-artifacts": repairGeneratedArtifacts,
448
+ "context-loop": repairContextLoop,
449
+ "long-session-buildup": repairLongSessionBuildup,
450
+ };
451
+
452
+ function forCause(cause) {
453
+ return executors[String(cause || "").trim().toLowerCase()] || null;
454
+ }
455
+
456
+ async function runRepair(rootDir = process.cwd(), cause, options = {}) {
457
+ const root = path.resolve(rootDir);
458
+ const executor = forCause(cause);
459
+ if (!executor) {
460
+ return {
461
+ schemaVersion: 1,
462
+ command: "repair",
463
+ cause: cause || null,
464
+ status: "failed",
465
+ statusMessage: `Unknown repair cause: ${cause || "(none)"}. Valid causes: ${REPAIR_CAUSES.join(", ")}.`,
466
+ result: null,
467
+ generatedAt: nowIso(),
468
+ };
469
+ }
470
+ const normalizedCause = String(cause).trim().toLowerCase();
471
+ const action = {
472
+ id: null,
473
+ actionType: "repair",
474
+ command: `${NPX_COMMAND} repair ${normalizedCause}${options.commandArgs && options.commandArgs.length ? ` -- ${options.commandArgs.join(" ")}` : ""}`,
475
+ label: `Repair ${normalizedCause}`,
476
+ targetCause: normalizedCause,
477
+ };
478
+ const outcome = await executor(action, root, { options });
479
+ return {
480
+ schemaVersion: 1,
481
+ command: "repair",
482
+ cause: normalizedCause,
483
+ status: outcome.status,
484
+ statusMessage: outcome.statusMessage,
485
+ result: outcome.result,
486
+ generatedAt: nowIso(),
487
+ };
488
+ }
489
+
490
+ function renderRepairTerminal(report) {
491
+ const lines = [];
492
+ lines.push("");
493
+ lines.push("PrismoDev Repair");
494
+ lines.push("");
495
+ lines.push(`Cause: ${report.cause || "unknown"}`);
496
+ lines.push(`Status: ${report.status}`);
497
+ if (report.statusMessage) lines.push(report.statusMessage);
498
+ const result = report.result || {};
499
+ if (result.hotPaths && result.hotPaths.length) {
500
+ lines.push("");
501
+ lines.push("Hot files:");
502
+ result.hotPaths.forEach((entry) => lines.push(`- ${entry.value} (${entry.count} reads)`));
503
+ }
504
+ if (result.noisyCommands && result.noisyCommands.length) {
505
+ lines.push("");
506
+ lines.push("Noisy commands:");
507
+ result.noisyCommands.forEach((entry) => lines.push(`- ${entry.value} (${entry.count} runs)`));
508
+ }
509
+ if (result.artifacts && result.artifacts.length) {
510
+ lines.push("");
511
+ lines.push("Artifacts seen in sessions:");
512
+ result.artifacts.forEach((entry) => lines.push(`- ${entry.value} (${entry.count} mentions)`));
513
+ }
514
+ if (result.repeatedCommands && result.repeatedCommands.length) {
515
+ lines.push("");
516
+ lines.push("Repeated commands:");
517
+ result.repeatedCommands.forEach((entry) => lines.push(`- ${entry.value} (${entry.count} runs)`));
518
+ }
519
+ if (result.fixActions && result.fixActions.length) {
520
+ lines.push("");
521
+ lines.push("Fixes:");
522
+ result.fixActions.forEach((item) => lines.push(`- ${item}`));
523
+ }
524
+ if (result.generatedFiles && result.generatedFiles.length) {
525
+ lines.push("");
526
+ lines.push("Generated:");
527
+ result.generatedFiles.forEach((file) => lines.push(`- ${file}`));
528
+ }
529
+ if (report.status === "failed" && !result.generatedFiles) {
530
+ lines.push("");
531
+ lines.push("Valid causes:");
532
+ REPAIR_CAUSES.forEach((item) => lines.push(`- ${item}`));
533
+ }
534
+ return lines.join("\n");
535
+ }
536
+
537
+ return {
538
+ REPAIR_CAUSES,
539
+ forCause,
540
+ renderRepairTerminal,
541
+ runRepair,
542
+ };
543
+ };
@@ -0,0 +1,307 @@
1
+ module.exports = function createRepairPlanner(deps) {
2
+ const {
3
+ fs,
4
+ path,
5
+ NPX_COMMAND,
6
+ getUsageSummary,
7
+ estimateWaste,
8
+ repairExecutors,
9
+ } = deps;
10
+
11
+ // Decision thresholds. Verdict math mirrors the backend's
12
+ // _measure_repair_impact (14-day baseline, >=2 post-repair sessions,
13
+ // 0.01 waste-rate epsilon) so local and cloud verdicts agree.
14
+ const DEFAULTS = {
15
+ sessionLimit: 10,
16
+ minWastedTokens: 15000,
17
+ minWasteRate: 0.05,
18
+ cooldownMs: 6 * 60 * 60 * 1000,
19
+ minSessionsToJudge: 2,
20
+ baselineDays: 14,
21
+ rateEpsilon: 0.01,
22
+ historyLimit: 20,
23
+ };
24
+
25
+ function nowIso() {
26
+ return new Date().toISOString();
27
+ }
28
+
29
+ function statePath(root) {
30
+ return path.join(root, ".prismo", "repair-state.json");
31
+ }
32
+
33
+ function readState(root) {
34
+ try {
35
+ const raw = fs.readFileSync(statePath(root), "utf8");
36
+ const parsed = JSON.parse(raw);
37
+ return parsed && typeof parsed === "object" ? { causes: {}, history: [], ...parsed } : { causes: {}, history: [] };
38
+ } catch {
39
+ return { causes: {}, history: [] };
40
+ }
41
+ }
42
+
43
+ function writeState(root, state) {
44
+ const filePath = statePath(root);
45
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
46
+ fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
47
+ }
48
+
49
+ function sessionStamp(session) {
50
+ const value = session.updatedAt || session.startedAt || null;
51
+ const parsed = value ? Date.parse(value) : NaN;
52
+ return Number.isFinite(parsed) ? parsed : null;
53
+ }
54
+
55
+ function analyzeSessions(root, options = {}) {
56
+ let sessions = [];
57
+ try {
58
+ const summary = getUsageSummary({ tool: "all", cwd: root, limit: options.sessionLimit || DEFAULTS.sessionLimit });
59
+ sessions = summary.sessions || [];
60
+ } catch {
61
+ sessions = [];
62
+ }
63
+ return sessions
64
+ .map((session) => {
65
+ const stamp = sessionStamp(session);
66
+ if (stamp === null) return null;
67
+ const waste = estimateWaste(session);
68
+ return {
69
+ stamp,
70
+ tokens: Number(waste.tokens || 0),
71
+ wastedTokens: Number(waste.wastedTokens || 0),
72
+ topCause: waste.topCause,
73
+ };
74
+ })
75
+ .filter(Boolean);
76
+ }
77
+
78
+ function scoreCauses(analyzed) {
79
+ const totals = new Map();
80
+ let observedTokens = 0;
81
+ for (const session of analyzed) {
82
+ observedTokens += session.tokens;
83
+ if (!session.topCause || session.topCause === "low-signal") continue;
84
+ const entry = totals.get(session.topCause) || { cause: session.topCause, wastedTokens: 0, sessions: 0 };
85
+ entry.wastedTokens += session.wastedTokens;
86
+ entry.sessions += 1;
87
+ totals.set(session.topCause, entry);
88
+ }
89
+ return Array.from(totals.values())
90
+ .map((entry) => ({
91
+ ...entry,
92
+ wasteRate: observedTokens > 0 ? Math.round((entry.wastedTokens / observedTokens) * 10000) / 10000 : 0,
93
+ }))
94
+ .sort((a, b) => b.wastedTokens - a.wastedTokens);
95
+ }
96
+
97
+ // Local mirror of the backend's repair verification: compare the cause's
98
+ // waste rate in sessions before vs. after the last repair.
99
+ function judgeRepair(cause, repairedAtMs, analyzed, config) {
100
+ const baselineStart = repairedAtMs - config.baselineDays * 24 * 60 * 60 * 1000;
101
+ const before = analyzed.filter((s) => s.stamp <= repairedAtMs && s.stamp >= baselineStart);
102
+ const after = analyzed.filter((s) => s.stamp > repairedAtMs);
103
+
104
+ const causeWasted = (group) => group.reduce((sum, s) => sum + (s.topCause === cause ? s.wastedTokens : 0), 0);
105
+ const observed = (group) => group.reduce((sum, s) => sum + s.tokens, 0);
106
+
107
+ const observedAfter = observed(after);
108
+ const observedBefore = observed(before);
109
+ const verdict = {
110
+ cause,
111
+ sessionsBefore: before.length,
112
+ sessionsAfter: after.length,
113
+ wasteRateBefore: observedBefore > 0 ? Math.round((causeWasted(before) / observedBefore) * 10000) / 10000 : null,
114
+ wasteRateAfter: observedAfter > 0 ? Math.round((causeWasted(after) / observedAfter) * 10000) / 10000 : null,
115
+ status: "measuring",
116
+ judgedAt: nowIso(),
117
+ };
118
+ if (after.length < config.minSessionsToJudge || observedAfter <= 0) return verdict;
119
+ if (!before.length || observedBefore <= 0) {
120
+ verdict.status = "no-baseline";
121
+ return verdict;
122
+ }
123
+ const delta = verdict.wasteRateBefore - verdict.wasteRateAfter;
124
+ if (delta > config.rateEpsilon) verdict.status = "improved";
125
+ else if (delta < -config.rateEpsilon) verdict.status = "regressed";
126
+ else verdict.status = "no-change";
127
+ return verdict;
128
+ }
129
+
130
+ // Decide what (if anything) to repair next. Pure read: does not execute
131
+ // or modify state, so observe/suggest modes can call it safely.
132
+ function plan(root, options = {}) {
133
+ const config = { ...DEFAULTS, ...options };
134
+ const analyzed = analyzeSessions(root, config);
135
+ const causes = scoreCauses(analyzed);
136
+ const state = readState(root);
137
+ const now = Date.now();
138
+ const skipped = [];
139
+ let decision = null;
140
+
141
+ for (const scored of causes) {
142
+ if (scored.wastedTokens < config.minWastedTokens || scored.wasteRate < config.minWasteRate) {
143
+ skipped.push({ cause: scored.cause, reason: "below-threshold" });
144
+ continue;
145
+ }
146
+ if (!repairExecutors.forCause(scored.cause)) {
147
+ skipped.push({ cause: scored.cause, reason: "no-executor" });
148
+ continue;
149
+ }
150
+ const entry = state.causes[scored.cause] || null;
151
+ let verdict = null;
152
+ let tier = "mild";
153
+ if (entry && entry.lastRepairAt) {
154
+ const repairedAtMs = Date.parse(entry.lastRepairAt);
155
+ if (Number.isFinite(repairedAtMs)) {
156
+ if (now - repairedAtMs < config.cooldownMs) {
157
+ skipped.push({ cause: scored.cause, reason: "cooldown" });
158
+ continue;
159
+ }
160
+ verdict = judgeRepair(scored.cause, repairedAtMs, analyzed, config);
161
+ if (verdict.status === "measuring") {
162
+ skipped.push({ cause: scored.cause, reason: "measuring", verdict });
163
+ continue;
164
+ }
165
+ if (verdict.status === "no-change" || verdict.status === "regressed") {
166
+ if (entry.lastTier === "aggressive") {
167
+ // Both tiers tried without improvement; stop auto-repairing
168
+ // this cause until a human looks at it or sessions change.
169
+ skipped.push({ cause: scored.cause, reason: "needs-review", verdict });
170
+ continue;
171
+ }
172
+ tier = "aggressive";
173
+ }
174
+ }
175
+ }
176
+ if (!decision) {
177
+ decision = {
178
+ cause: scored.cause,
179
+ tier,
180
+ wastedTokens: scored.wastedTokens,
181
+ wasteRate: scored.wasteRate,
182
+ previousVerdict: verdict ? verdict.status : null,
183
+ reason: verdict && tier === "aggressive"
184
+ ? `Last ${entry.lastTier} repair came back ${verdict.status}; escalating to aggressive.`
185
+ : `${scored.cause} is the top waste cause (${scored.wastedTokens.toLocaleString()} tokens, ${(scored.wasteRate * 100).toFixed(1)}% of observed).`,
186
+ };
187
+ } else {
188
+ skipped.push({ cause: scored.cause, reason: "lower-priority" });
189
+ }
190
+ }
191
+
192
+ return {
193
+ generatedAt: nowIso(),
194
+ sessionsAnalyzed: analyzed.length,
195
+ causes,
196
+ decision,
197
+ skipped,
198
+ };
199
+ }
200
+
201
+ async function runPlannerOnce(rootDir = process.cwd(), options = {}) {
202
+ const root = path.resolve(rootDir);
203
+ const config = { ...DEFAULTS, ...options };
204
+ const planResult = plan(root, config);
205
+ const report = {
206
+ schemaVersion: 1,
207
+ command: "repair-planner",
208
+ ...planResult,
209
+ executed: false,
210
+ outcome: null,
211
+ };
212
+ if (!planResult.decision || options.execute === false) return report;
213
+
214
+ const { cause, tier } = planResult.decision;
215
+ const executor = repairExecutors.forCause(cause);
216
+ const action = {
217
+ id: null,
218
+ actionType: "repair",
219
+ command: `${NPX_COMMAND} repair ${cause}`,
220
+ label: `Auto-repair ${cause} (${tier})`,
221
+ targetCause: cause,
222
+ };
223
+ let outcome;
224
+ try {
225
+ outcome = await executor(action, root, { options: { ...options, tier } });
226
+ } catch (error) {
227
+ outcome = {
228
+ status: "failed",
229
+ statusMessage: `Repair executor failed: ${error && error.message ? error.message : String(error)}`,
230
+ result: null,
231
+ };
232
+ }
233
+
234
+ const state = readState(root);
235
+ const previous = state.causes[cause] || { attempts: 0 };
236
+ state.causes[cause] = {
237
+ ...previous,
238
+ lastRepairAt: nowIso(),
239
+ lastTier: tier,
240
+ lastStatus: outcome.status,
241
+ attempts: Number(previous.attempts || 0) + 1,
242
+ lastVerdict: planResult.decision.previousVerdict || previous.lastVerdict || null,
243
+ };
244
+ state.history = [
245
+ { at: nowIso(), cause, tier, status: outcome.status, wastedTokens: planResult.decision.wastedTokens },
246
+ ...(state.history || []),
247
+ ].slice(0, config.historyLimit);
248
+ writeState(root, state);
249
+
250
+ report.executed = outcome.status === "completed";
251
+ report.outcome = {
252
+ status: outcome.status,
253
+ statusMessage: outcome.statusMessage,
254
+ tier,
255
+ generatedFiles: (outcome.result && outcome.result.generatedFiles) || [],
256
+ };
257
+ return report;
258
+ }
259
+
260
+ function renderPlannerTerminal(report) {
261
+ const lines = [];
262
+ lines.push("");
263
+ lines.push("PrismoDev Repair Planner");
264
+ lines.push("");
265
+ lines.push(`Sessions analyzed: ${report.sessionsAnalyzed}`);
266
+ if (report.causes.length) {
267
+ lines.push("");
268
+ lines.push("Waste causes:");
269
+ report.causes.forEach((entry) => {
270
+ lines.push(`- ${entry.cause}: ${entry.wastedTokens.toLocaleString()} tokens (${(entry.wasteRate * 100).toFixed(1)}% of observed, ${entry.sessions} session(s))`);
271
+ });
272
+ } else {
273
+ lines.push("No attributable waste causes in recent sessions.");
274
+ }
275
+ if (report.decision) {
276
+ lines.push("");
277
+ lines.push(`Decision: repair ${report.decision.cause} (${report.decision.tier} tier)`);
278
+ lines.push(`Why: ${report.decision.reason}`);
279
+ } else {
280
+ lines.push("");
281
+ lines.push("Decision: nothing to repair right now.");
282
+ }
283
+ if (report.skipped.length) {
284
+ lines.push("");
285
+ lines.push("Held back:");
286
+ report.skipped.forEach((item) => lines.push(`- ${item.cause}: ${item.reason}`));
287
+ }
288
+ if (report.outcome) {
289
+ lines.push("");
290
+ lines.push(`Repair: ${report.outcome.status}`);
291
+ if (report.outcome.statusMessage) lines.push(report.outcome.statusMessage);
292
+ } else if (report.decision && !report.executed) {
293
+ lines.push("");
294
+ lines.push(`Not executed (planning only). Run: ${NPX_COMMAND} repair ${report.decision.cause}`);
295
+ }
296
+ return lines.join("\n");
297
+ }
298
+
299
+ return {
300
+ DEFAULTS,
301
+ judgeRepair,
302
+ plan,
303
+ readState,
304
+ renderPlannerTerminal,
305
+ runPlannerOnce,
306
+ };
307
+ };
@@ -272,6 +272,7 @@ const {
272
272
 
273
273
  const {
274
274
  buildSyncPayload,
275
+ estimateWaste,
275
276
  loadConfig,
276
277
  renderConnectTerminal,
277
278
  renderDisconnectTerminal,
@@ -313,6 +314,37 @@ const {
313
314
  loadConfig,
314
315
  });
315
316
 
317
+ const repairExecutors = require("./prismo-dev/repair-executors")({
318
+ fs,
319
+ path,
320
+ NPX_COMMAND,
321
+ runDoctor,
322
+ runOptimize,
323
+ runGuard,
324
+ runShield,
325
+ runFirewall,
326
+ getUsageSummary,
327
+ appendIgnoreSuggestions,
328
+ });
329
+ const {
330
+ REPAIR_CAUSES,
331
+ renderRepairTerminal,
332
+ runRepair,
333
+ } = repairExecutors;
334
+
335
+ const repairPlanner = require("./prismo-dev/repair-planner")({
336
+ fs,
337
+ path,
338
+ NPX_COMMAND,
339
+ getUsageSummary,
340
+ estimateWaste,
341
+ repairExecutors,
342
+ });
343
+ const {
344
+ renderPlannerTerminal,
345
+ runPlannerOnce,
346
+ } = repairPlanner;
347
+
316
348
  const {
317
349
  renderAgentTerminal,
318
350
  runAgent,
@@ -331,6 +363,8 @@ const {
331
363
  runShield,
332
364
  runOptimize,
333
365
  openUrl,
366
+ repairExecutors,
367
+ repairPlanner,
334
368
  });
335
369
 
336
370
  const {
@@ -452,6 +486,11 @@ const { runCli } = require("./prismo-dev/cli")({
452
486
  runSync,
453
487
  renderGuardTerminal,
454
488
  runGuard,
489
+ REPAIR_CAUSES,
490
+ renderRepairTerminal,
491
+ runRepair,
492
+ renderPlannerTerminal,
493
+ runPlannerOnce,
455
494
  renderAgentTerminal,
456
495
  runAgent,
457
496
  renderConnectorTerminal,
@@ -552,6 +591,11 @@ module.exports = {
552
591
  runConnect,
553
592
  runDisconnect,
554
593
  runGuard,
594
+ runRepair,
595
+ renderRepairTerminal,
596
+ REPAIR_CAUSES,
597
+ runPlannerOnce,
598
+ renderPlannerTerminal,
555
599
  runConnectorInstall,
556
600
  runConnectorStart,
557
601
  runConnectorStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getprismo",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Local AI coding workflow scanner for Codex, Claude Code, Cursor, and token-waste diagnostics.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/shanirsh/prismodev#readme",