@magnusekdahl/parallix 1.3.3 → 1.3.4

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 (47) hide show
  1. package/docs/agents.md +1 -1
  2. package/docs/use-cases.md +1 -1
  3. package/lib/agents/agents.js +20 -3
  4. package/lib/agents/agents.ts +14 -3
  5. package/lib/agents/mistral.js +128 -14
  6. package/lib/agents/mistral.ts +145 -5
  7. package/lib/commands/active.js +69 -39
  8. package/lib/commands/active.ts +97 -60
  9. package/lib/commands/config.ts +3 -3
  10. package/lib/commands/coverage-gate.ts +1 -1
  11. package/lib/commands/draft.js +1 -1
  12. package/lib/commands/draft.ts +1 -1
  13. package/lib/commands/handoff.js +13 -8
  14. package/lib/commands/handoff.ts +24 -18
  15. package/lib/commands/rebase.js +1 -1
  16. package/lib/commands/rebase.ts +2 -2
  17. package/lib/commands/repair-handoff.js +141 -20
  18. package/lib/commands/repair-handoff.ts +185 -45
  19. package/lib/commands/resolve-conflict.js +1 -1
  20. package/lib/commands/resolve-conflict.ts +2 -2
  21. package/lib/commands/stats-backfill.ts +10 -10
  22. package/lib/commands/stats.js +38 -11
  23. package/lib/commands/stats.ts +40 -96
  24. package/lib/core/fmt.ts +2 -2
  25. package/lib/core/git.ts +2 -2
  26. package/lib/core/gitignore.ts +2 -2
  27. package/lib/core/mission-utils.js +2 -2
  28. package/lib/core/mission-utils.ts +2 -2
  29. package/lib/core/persistent-data-migration.ts +2 -2
  30. package/lib/core/spawn-tee.ts +1 -1
  31. package/lib/core/state-map.ts +2 -2
  32. package/lib/core/storage.ts +1 -1
  33. package/lib/core/verification.ts +1 -1
  34. package/lib/review/rebase.ts +12 -12
  35. package/lib/review/review-artifacts.ts +35 -35
  36. package/lib/review/review-commands.ts +40 -40
  37. package/lib/review/review-events.ts +12 -12
  38. package/lib/review/review-loop.js +236 -7
  39. package/lib/review/review-loop.ts +338 -22
  40. package/lib/review/review-polling.ts +6 -6
  41. package/lib/review/review-prompts.js +8 -4
  42. package/lib/review/review-prompts.ts +12 -8
  43. package/lib/review/review-state.js +1 -1
  44. package/lib/review/review-state.ts +2 -2
  45. package/package.json +3 -2
  46. package/prompts/review-verbose.md +1 -1
  47. package/prompts/review.md +1 -1
package/docs/agents.md CHANGED
@@ -26,7 +26,7 @@ Opencode (custom agent family) may encounter issues with concurrent tool calls o
26
26
  |---------|----------------------------------------------------------------------|
27
27
  | codex | `codex exec --sandbox danger-full-access --cd <worktree> <prompt>` with a worktree-local `HOME` under `.workflow/codex-home`; resume uses `codex exec resume <session-id-or---last> <prompt>`; the launcher also seeds `.workflow/codex-home/.codex/config.toml` with the repo-standard trusted posture and copies `.codex/auth.json` so headless review commands can start and keep localhost Forgejo access |
28
28
  | claude | `claude --dangerously-skip-permissions --output-format stream-json --verbose --include-partial-messages -p <prompt>` (cwd=worktree) — uses `--output-format stream-json --verbose --include-partial-messages` to stream real-time JSONL events (tool calls, assistant text chunks) to the operator's terminal via the spawn-tee mechanism. `--include-partial-messages` is required: without it, the assistant event contains the full response at once and no intermediate progress is emitted. Session-id extraction parses the `result` event from stream-json output, falling back to the `claude --resume <id>` regex on plain text. |
29
- | mistral | `vibe --prompt <prompt> --trust --output text` (cwd=worktree) — **Note: NOT resume-capable in current Vibe version**; session management uses internal state in `~/.vibe/logs/session/` but does not emit a parseable resume hint to stdout/stderr. |
29
+ | mistral | `vibe --prompt <prompt> --trust --yolo --output text` (cwd=worktree) — `--yolo` approves tool calls non-interactively (mirrors `--dangerously-skip-permissions` for claude/opencode and codex's `trust_level = "trusted"`); `--trust` only bypasses the working-directory trust prompt and does not itself skip tool-call approval. **Note: NOT resume-capable in current Vibe version**; session management uses internal state in `~/.vibe/logs/session/` but does not emit a parseable resume hint to stdout/stderr. |
30
30
  | custom | `opencode run --pure --dangerously-skip-permissions <prompt>` (cwd=worktree); resume uses `-s <session>` when a session id is known or `--continue` when only the family marker is known |
31
31
 
32
32
  ## Launch output watchdog
package/docs/use-cases.md CHANGED
@@ -53,7 +53,7 @@ Each use case carries the four required parts: **(P) persona/buyer**, **(B) befo
53
53
  ### UC-6 — See which agent family actually pays off, across every repo one runtime drives
54
54
 
55
55
  - **(P)** Operator/buyer deciding which paid agent subscriptions to keep or cut.
56
- - **(B)** *Before:* no durable, cross-repo record of how each agent performs, so the keep/cut decision is a hunch. *After:* a single parallix-owned `stats.csv` accumulates per-agent telemetry (`classification, implementer, pr_fix_rounds`, plus an extended 21-column schema) across every repository one runtime drives, keyed so the same mission in different repos stays distinct.
56
+ - **(B)** *Before:* no durable, cross-repo record of how each agent performs, so the keep/cut decision is a hunch. *After:* a single parallix-owned `stats.csv` accumulates per-agent telemetry (`classification, implementer, pr_fix_rounds`, plus an extended 22-column schema including a `closed` flag) across every repository one runtime drives, keyed so the same mission in different repos stays distinct.
57
57
  - **(E)** `lib/commands/stats.js:14` (legacy 5-col schema), `:21-30` (extended schema). Tested: `test/stats.test.js` — `upsertStatsRow writes the workflow stats schema and updates existing missions idempotently`, `task-1314: upsertStatsRow keys on (repo, mission, stage) so same mission in different repos stays distinct`. The kind of agent-comparison this enables is demonstrated in `../visualBoard/docs/missions/2026/task-1023/RETROSPECTIVE_P5.md:198-243` (per-family PRs, reviews/PR, durations).
58
58
  - **(C)** **Partial.** Schema and CSV upsert are tested, but the value is bounded: the richest per-agent comparison in the evidence came from Forgejo PR data, not `stats.csv`, and two of four families record honest zeros for token usage (`opencode`/local custom and `mistral`/vibe telemetry are zeroed by design, per `README.md:230-231` describing `opencode-telemetry.js`/`mistral-telemetry.js`). So cross-agent *cost/value* comparison is complete only for `codex` and `claude` today.
59
59
 
@@ -115,11 +115,14 @@ const NON_BLOCKING_LAUNCH_ERROR_PATTERNS = Object.freeze([
115
115
  /\bmodel\s+(?:identifier|id)\s+(?:is\s+)?invalid\b/i,
116
116
  /\b(?:model\s+not\s+found|no\s+such\s+model)\b/i,
117
117
  /\bunknown\s+option\b/i,
118
+ /\bunsupported\s+(flag|option)\b/i,
118
119
  /\bauth(?:entication)?\b/i,
119
120
  /\bunauthorized\b/i,
120
121
  /\bforbidden\b/i,
121
122
  /\bapi\s+key\b/i,
122
- /\bread-only file system\b/i
123
+ /\bread-only file system\b/i,
124
+ /\b(home|bootstrap)\s+(error|failed|cannot|denied|not\s+found)\b/i,
125
+ /\bpermission\s+denied\b/i
123
126
  ]);
124
127
  function workflowLauncherStatus(agent) {
125
128
  const resolver = RESOLVERS[agent];
@@ -162,6 +165,10 @@ function commandInPath(name) {
162
165
  });
163
166
  return result.status === 0 && result.stdout.trim().length > 0;
164
167
  }
168
+ // Deterministic config/setup errors (invalid model IDs, auth failures,
169
+ // unsupported CLI flags, home/bootstrap failures) must not poison the
170
+ // persistent blocklist — only transient failures (runtime crashes, network
171
+ // errors) deserve a block. Custom agents are never blocked.
165
172
  function shouldPersistLaunchFailureBlock(agent, result) {
166
173
  if (!result || agent === 'custom') {
167
174
  return false;
@@ -543,7 +550,7 @@ function defaultIsAgentBlockedNow(agent) {
543
550
  const config = readAgentConfig(CONFIG_PATH, {});
544
551
  return isAgentBlocked(agent, config);
545
552
  }
546
- catch (err) {
553
+ catch (_err) {
547
554
  // If the config is malformed, surface that through the launcher path
548
555
  // (assertAgentSupported / launch) instead of silently rerouting. Treat as
549
556
  // not-blocked here so the existing error path runs.
@@ -823,10 +830,20 @@ async function startAgent(step, opts = { prompt: '' }) {
823
830
  // setup/config errors (invalid model id, auth failure, read-only HOME, etc.)
824
831
  // should fall through to the next family without poisoning agents.local.json.
825
832
  if (shouldPersistLaunchFailureBlock(chosen || '', result)) {
833
+ let blockReason = 'transient crash';
834
+ if (result?.signal) {
835
+ blockReason = `signal ${result.signal}`;
836
+ }
837
+ else if (result?.error?.code) {
838
+ blockReason = result.error.code;
839
+ }
840
+ else if (result?.status !== null && result?.status !== 0) {
841
+ blockReason = `exit ${result.status}`;
842
+ }
826
843
  const blockUntil = (0, limit_hit_js_1.formatBlockUntil)(new Date(Date.now() + limit_hit_js_1.DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000));
827
844
  try {
828
845
  const blockResult = updateAgentBlockFn(chosen || '', blockUntil);
829
- log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${limit_hit_js_1.DEFAULT_FALLBACK_HOURS}h block)`));
846
+ log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${limit_hit_js_1.DEFAULT_FALLBACK_HOURS}h block, ${blockReason})`));
830
847
  }
831
848
  catch (err) {
832
849
  log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${err.message}`));
@@ -115,11 +115,14 @@ const NON_BLOCKING_LAUNCH_ERROR_PATTERNS = Object.freeze([
115
115
  /\bmodel\s+(?:identifier|id)\s+(?:is\s+)?invalid\b/i,
116
116
  /\b(?:model\s+not\s+found|no\s+such\s+model)\b/i,
117
117
  /\bunknown\s+option\b/i,
118
+ /\bunsupported\s+(flag|option)\b/i,
118
119
  /\bauth(?:entication)?\b/i,
119
120
  /\bunauthorized\b/i,
120
121
  /\bforbidden\b/i,
121
122
  /\bapi\s+key\b/i,
122
- /\bread-only file system\b/i
123
+ /\bread-only file system\b/i,
124
+ /\b(home|bootstrap)\s+(error|failed|cannot|denied|not\s+found)\b/i,
125
+ /\bpermission\s+denied\b/i
123
126
  ]);
124
127
 
125
128
  function workflowLauncherStatus(agent: string): LauncherStatus {
@@ -168,6 +171,10 @@ function commandInPath(name: string) {
168
171
  return result.status === 0 && result.stdout.trim().length > 0;
169
172
  }
170
173
 
174
+ // Deterministic config/setup errors (invalid model IDs, auth failures,
175
+ // unsupported CLI flags, home/bootstrap failures) must not poison the
176
+ // persistent blocklist — only transient failures (runtime crashes, network
177
+ // errors) deserve a block. Custom agents are never blocked.
171
178
  function shouldPersistLaunchFailureBlock(agent: string, result: LaunchResultLike | null | undefined) {
172
179
  if (!result || agent === 'custom') {return false;}
173
180
  const combined = [
@@ -592,7 +599,7 @@ function defaultIsAgentBlockedNow(agent: string) {
592
599
  try {
593
600
  const config = readAgentConfig(CONFIG_PATH, {});
594
601
  return isAgentBlocked(agent, config);
595
- } catch (err) {
602
+ } catch (_err) {
596
603
  // If the config is malformed, surface that through the launcher path
597
604
  // (assertAgentSupported / launch) instead of silently rerouting. Treat as
598
605
  // not-blocked here so the existing error path runs.
@@ -907,10 +914,14 @@ async function startAgent(step: string, opts: StartAgentOptions = { prompt: '' }
907
914
  // setup/config errors (invalid model id, auth failure, read-only HOME, etc.)
908
915
  // should fall through to the next family without poisoning agents.local.json.
909
916
  if (shouldPersistLaunchFailureBlock(chosen || '', result)) {
917
+ let blockReason = 'transient crash';
918
+ if (result?.signal) { blockReason = `signal ${result.signal}`; }
919
+ else if (result?.error?.code) { blockReason = result.error.code; }
920
+ else if (result?.status !== null && result?.status !== 0) { blockReason = `exit ${result.status}`; }
910
921
  const blockUntil = formatBlockUntil(new Date(Date.now() + DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000));
911
922
  try {
912
923
  const blockResult = updateAgentBlockFn(chosen || '', blockUntil);
913
- log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${DEFAULT_FALLBACK_HOURS}h block)`));
924
+ log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${DEFAULT_FALLBACK_HOURS}h block, ${blockReason})`));
914
925
  } catch (err) {
915
926
  log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${(err as any).message}`));
916
927
  }
@@ -1,22 +1,128 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getMistralProviderModel = void 0;
3
7
  exports.buildMistralInvocation = buildMistralInvocation;
4
8
  exports.extractMistralSessionId = extractMistralSessionId;
9
+ exports.processResult = processResult;
5
10
  exports.resolveMistralCommand = resolveMistralCommand;
6
11
  exports.startMistralAgent = startMistralAgent;
7
12
  const spawn_tee_js_1 = require("../core/spawn-tee.js");
8
- // Mistral Vibe in programmatic mode (-p/--prompt) does NOT output a resume
9
- // hint to stdout/stderr like other agents do. Session IDs are stored in
10
- // ~/.vibe/logs/session/session_<timestamp>_<short_id>/meta.json with UUID format,
11
- // but there is no reliable stdout pattern to extract. Therefore, Mistral is
12
- // NOT marked as RESUME_CAPABLE in agents.js. If future Vibe versions add
13
- // a resume hint, update this regex and add 'mistral' to RESUME_CAPABLE.
14
- // Current session ID format in meta.json: UUID like "a3dd3d4d-f97d-d57d-4942-a1f694e3a922"
15
- // Directory naming uses first 8 chars: session_20260521_162703_a3dd3d4d
16
- // No stdout marker detected in testing, so we leave this as null.
17
- // Telemetry: mistral/vibe does not expose token-usage data. See mistral-telemetry.ts
18
- // for the honest-zero stub. Stats hooks in active.js and review-loop.js call
19
- // recordStageStats which defaults to '0' for tokens when telemetry is null.
13
+ const mistral_telemetry_js_1 = require("./mistral-telemetry.js");
14
+ Object.defineProperty(exports, "getMistralProviderModel", { enumerable: true, get: function () { return mistral_telemetry_js_1.getMistralProviderModel; } });
15
+ const node_fs_1 = __importDefault(require("node:fs"));
16
+ const node_path_1 = __importDefault(require("node:path"));
17
+ /**
18
+ * Maximum acceptable age (in minutes) for a mistral session's start_time
19
+ * relative to the invocation start. Sessions older than this window are
20
+ * rejected as potentially misattributed across concurrent missions.
21
+ */
22
+ const MAX_SESSION_AGE_MINUTES = 120;
23
+ function processResult(result, basePath, invocationStart) {
24
+ if (!result || typeof result !== 'object') {
25
+ return { sessionId: null, telemetry: null };
26
+ }
27
+ const scanDir = basePath || mistral_telemetry_js_1.DEFAULT_MISTRAL_LOG_DIR;
28
+ // Determine the invocation window for session correlation.
29
+ // When invocationStart is provided, only consider sessions whose
30
+ // start_time falls within MAX_SESSION_AGE_MINUTES of the invocation.
31
+ // This prevents cross-mission telemetry misattribution when multiple
32
+ // mistral phases run concurrently against a shared session directory.
33
+ let invokeTime = NaN;
34
+ let invokeWindow = null;
35
+ if (invocationStart) {
36
+ invokeTime = Date.parse(invocationStart);
37
+ if (!Number.isNaN(invokeTime)) {
38
+ const deltaMs = MAX_SESSION_AGE_MINUTES * 60000;
39
+ invokeWindow = { start: invokeTime - deltaMs, end: invokeTime + deltaMs };
40
+ }
41
+ }
42
+ // Scan session directories chronologically (sorted by basename).
43
+ // For each session, check if its start_time falls within the invocation
44
+ // window, then pick the session closest to the invocation start time.
45
+ // This replaces the previous approach of calling extractMistralTelemetry
46
+ // which always returned the globally newest session regardless of which
47
+ // invocation it belonged to.
48
+ let bestTelemetry = null;
49
+ let bestDistance = Infinity;
50
+ try {
51
+ const entries = node_fs_1.default.readdirSync(scanDir);
52
+ const dirs = entries.filter((d) => d.startsWith('session_')).sort();
53
+ for (const dir of dirs) {
54
+ const metaPath = node_path_1.default.join(scanDir, dir, 'meta.json');
55
+ if (!node_fs_1.default.existsSync(metaPath)) {
56
+ continue;
57
+ }
58
+ let content;
59
+ try {
60
+ content = node_fs_1.default.readFileSync(metaPath, 'utf8');
61
+ }
62
+ catch (_) {
63
+ continue;
64
+ }
65
+ let meta;
66
+ try {
67
+ meta = JSON.parse(content);
68
+ }
69
+ catch (_) {
70
+ continue;
71
+ }
72
+ const startTime = meta.start_time;
73
+ if (typeof startTime !== 'string') {
74
+ continue;
75
+ }
76
+ const sessionTime = Date.parse(startTime);
77
+ if (Number.isNaN(sessionTime)) {
78
+ continue;
79
+ }
80
+ // Check invocation window if applicable
81
+ if (invokeWindow && (sessionTime < invokeWindow.start || sessionTime > invokeWindow.end)) {
82
+ continue;
83
+ }
84
+ const telemetry = (0, mistral_telemetry_js_1.parseMistralMeta)(meta);
85
+ if (!telemetry) {
86
+ continue;
87
+ }
88
+ // When no invocationStart is provided, pick the first valid session.
89
+ // When invocationStart is provided, pick the session closest in time.
90
+ if (Number.isNaN(invokeTime)) {
91
+ bestTelemetry = telemetry;
92
+ break;
93
+ }
94
+ const distance = Math.abs(sessionTime - invokeTime);
95
+ if (distance < bestDistance) {
96
+ bestTelemetry = telemetry;
97
+ bestDistance = distance;
98
+ }
99
+ }
100
+ }
101
+ catch (_) {
102
+ // Directory unreadable — fall through to null telemetry
103
+ }
104
+ if (!bestTelemetry) {
105
+ return { ...result, sessionId: result.sessionId || null, telemetry: null };
106
+ }
107
+ const allToolCalls = (bestTelemetry.toolCallsAgreed || 0) +
108
+ (bestTelemetry.toolCallsRejected || 0) +
109
+ (bestTelemetry.toolCallsFailed || 0) +
110
+ (bestTelemetry.toolCallsSucceeded || 0);
111
+ const pm = (0, mistral_telemetry_js_1.getMistralProviderModel)();
112
+ const model = bestTelemetry.contextTokens > 0 || bestTelemetry.inputTokens > 0 ? 'mistral' : pm.model;
113
+ result.telemetry = {
114
+ provider: pm.provider,
115
+ model,
116
+ inputTokens: bestTelemetry.inputTokens,
117
+ outputTokens: bestTelemetry.outputTokens,
118
+ cachedTokens: bestTelemetry.contextTokens,
119
+ totalTokens: bestTelemetry.totalTokens,
120
+ toolCalls: allToolCalls,
121
+ usagePercent: null,
122
+ cost_usd: bestTelemetry.sessionCost,
123
+ };
124
+ return { ...result, sessionId: result.sessionId || null };
125
+ }
20
126
  function extractMistralSessionId(stdout) {
21
127
  void stdout;
22
128
  // Vibe does not currently emit a resume hint in programmatic mode.
@@ -29,7 +135,14 @@ function resolveMistralCommand() {
29
135
  function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model = null }) {
30
136
  void resume;
31
137
  void sessionId;
32
- const args = ['--prompt', prompt, '--trust', '--output', 'text'];
138
+ // --trust only bypasses the working-directory trust prompt; tool-call
139
+ // approval is a separate gate that vibe --help documents as controlled by
140
+ // --auto-approve/--yolo. Without it, any prompt that needs a tool call
141
+ // blocks on interactive approval outside a TTY and fails with a generic
142
+ // error, which then gets misread as a real launch failure and persisted
143
+ // to the blocklist (claude/opencode/codex all pass their own equivalent
144
+ // non-interactive bypass already).
145
+ const args = ['--prompt', prompt, '--trust', '--yolo', '--output', 'text'];
33
146
  // Vibe programmatic mode does not support --resume flag in the same way
34
147
  // as other agents. The --resume flag exists but requires interactive selection
35
148
  // or a session picker. Since we cannot reliably pass a session ID via
@@ -50,11 +163,12 @@ function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, mode
50
163
  }
51
164
  function startMistralAgent({ prompt, worktree, env, resume = false, sessionId = null, model = null, teeOptions = {} }) {
52
165
  const invocation = buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model });
166
+ const invocationStart = new Date().toISOString();
53
167
  const resultPromise = (0, spawn_tee_js_1.spawnAndTee)(invocation.command, invocation.args, { ...invocation.options, ...teeOptions }).then((result) => {
54
168
  if (result && result.stdout) {
55
169
  result.sessionId = extractMistralSessionId(result.stdout);
56
170
  }
57
- return result;
171
+ return processResult(result, undefined, invocationStart);
58
172
  });
59
173
  return { invocation, resultPromise };
60
174
  }
@@ -1,4 +1,14 @@
1
1
  import { spawnAndTee } from '../core/spawn-tee.js';
2
+ import { parseMistralMeta, getMistralProviderModel, DEFAULT_MISTRAL_LOG_DIR } from './mistral-telemetry.js';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ /**
7
+ * Maximum acceptable age (in minutes) for a mistral session's start_time
8
+ * relative to the invocation start. Sessions older than this window are
9
+ * rejected as potentially misattributed across concurrent missions.
10
+ */
11
+ const MAX_SESSION_AGE_MINUTES = 120;
2
12
 
3
13
  interface MistralInvocationOptions {
4
14
  prompt: string;
@@ -22,10 +32,130 @@ interface StartMistralAgentOptions extends MistralInvocationOptions {
22
32
  // Current session ID format in meta.json: UUID like "a3dd3d4d-f97d-d57d-4942-a1f694e3a922"
23
33
  // Directory naming uses first 8 chars: session_20260521_162703_a3dd3d4d
24
34
  // No stdout marker detected in testing, so we leave this as null.
25
- // Telemetry: mistral/vibe does not expose token-usage data. See mistral-telemetry.ts
26
- // for the honest-zero stub. Stats hooks in active.js and review-loop.js call
27
- // recordStageStats which defaults to '0' for tokens when telemetry is null.
35
+ // Telemetry: mistral/vibe writes structured token-usage data to meta.json files
36
+ // in ~/.vibe/logs/session/. This module's processResult function scans session
37
+ // directories and correlates by start_time window to prevent cross-mission
38
+ // telemetry misattribution. The mapped telemetry is consumed by
39
+ // telemetryToStatsFields in lib/commands/stats.ts.
40
+
41
+
42
+ interface ProcessedResult {
43
+ sessionId: string | null;
44
+ telemetry: {
45
+ provider: string;
46
+ model: string;
47
+ inputTokens: number;
48
+ outputTokens: number;
49
+ cachedTokens: number;
50
+ totalTokens: number;
51
+ toolCalls: number;
52
+ usagePercent: null;
53
+ cost_usd: number;
54
+ } | null;
55
+ [key: string]: unknown;
56
+ }
57
+
58
+ function processResult(result: any, basePath?: string, invocationStart?: string): ProcessedResult {
59
+ if (!result || typeof result !== 'object') {
60
+ return { sessionId: null, telemetry: null };
61
+ }
62
+
63
+ const scanDir = basePath || DEFAULT_MISTRAL_LOG_DIR;
64
+
65
+ // Determine the invocation window for session correlation.
66
+ // When invocationStart is provided, only consider sessions whose
67
+ // start_time falls within MAX_SESSION_AGE_MINUTES of the invocation.
68
+ // This prevents cross-mission telemetry misattribution when multiple
69
+ // mistral phases run concurrently against a shared session directory.
70
+ let invokeTime = NaN;
71
+ let invokeWindow: { start: number; end: number } | null = null;
72
+ if (invocationStart) {
73
+ invokeTime = Date.parse(invocationStart);
74
+ if (!Number.isNaN(invokeTime)) {
75
+ const deltaMs = MAX_SESSION_AGE_MINUTES * 60000;
76
+ invokeWindow = { start: invokeTime - deltaMs, end: invokeTime + deltaMs };
77
+ }
78
+ }
79
+
80
+ // Scan session directories chronologically (sorted by basename).
81
+ // For each session, check if its start_time falls within the invocation
82
+ // window, then pick the session closest to the invocation start time.
83
+ // This replaces the previous approach of calling extractMistralTelemetry
84
+ // which always returned the globally newest session regardless of which
85
+ // invocation it belonged to.
86
+ let bestTelemetry: ReturnType<typeof parseMistralMeta> | null = null;
87
+ let bestDistance = Infinity;
88
+
89
+ try {
90
+ const entries = fs.readdirSync(scanDir);
91
+ const dirs = entries.filter((d: string) => d.startsWith('session_')).sort();
92
+
93
+ for (const dir of dirs) {
94
+ const metaPath = path.join(scanDir, dir, 'meta.json');
95
+ if (!fs.existsSync(metaPath)) { continue; }
96
+
97
+ let content: string;
98
+ try { content = fs.readFileSync(metaPath, 'utf8'); } catch (_) { continue; }
28
99
 
100
+ let meta: Record<string, unknown>;
101
+ try { meta = JSON.parse(content); } catch (_) { continue; }
102
+
103
+ const startTime = meta.start_time;
104
+ if (typeof startTime !== 'string') { continue; }
105
+ const sessionTime = Date.parse(startTime);
106
+ if (Number.isNaN(sessionTime)) { continue; }
107
+
108
+ // Check invocation window if applicable
109
+ if (invokeWindow && (sessionTime < invokeWindow.start || sessionTime > invokeWindow.end)) {
110
+ continue;
111
+ }
112
+
113
+ const telemetry = parseMistralMeta(meta);
114
+ if (!telemetry) { continue; }
115
+
116
+ // When no invocationStart is provided, pick the first valid session.
117
+ // When invocationStart is provided, pick the session closest in time.
118
+ if (Number.isNaN(invokeTime)) {
119
+ bestTelemetry = telemetry;
120
+ break;
121
+ }
122
+ const distance = Math.abs(sessionTime - invokeTime);
123
+ if (distance < bestDistance) {
124
+ bestTelemetry = telemetry;
125
+ bestDistance = distance;
126
+ }
127
+ }
128
+ } catch (_) {
129
+ // Directory unreadable — fall through to null telemetry
130
+ }
131
+
132
+ if (!bestTelemetry) {
133
+ return { ...result, sessionId: result.sessionId || null, telemetry: null } as ProcessedResult;
134
+ }
135
+
136
+ const allToolCalls =
137
+ (bestTelemetry.toolCallsAgreed || 0) +
138
+ (bestTelemetry.toolCallsRejected || 0) +
139
+ (bestTelemetry.toolCallsFailed || 0) +
140
+ (bestTelemetry.toolCallsSucceeded || 0);
141
+
142
+ const pm = getMistralProviderModel();
143
+ const model = bestTelemetry.contextTokens > 0 || bestTelemetry.inputTokens > 0 ? 'mistral' : pm.model;
144
+
145
+ result.telemetry = {
146
+ provider: pm.provider,
147
+ model,
148
+ inputTokens: bestTelemetry.inputTokens,
149
+ outputTokens: bestTelemetry.outputTokens,
150
+ cachedTokens: bestTelemetry.contextTokens,
151
+ totalTokens: bestTelemetry.totalTokens,
152
+ toolCalls: allToolCalls,
153
+ usagePercent: null,
154
+ cost_usd: bestTelemetry.sessionCost,
155
+ };
156
+
157
+ return { ...result, sessionId: result.sessionId || null } as ProcessedResult;
158
+ }
29
159
 
30
160
  function extractMistralSessionId(stdout: string) {
31
161
  void stdout;
@@ -41,7 +171,14 @@ function resolveMistralCommand() {
41
171
  function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model = null }: MistralInvocationOptions) {
42
172
  void resume;
43
173
  void sessionId;
44
- const args = ['--prompt', prompt, '--trust', '--output', 'text'];
174
+ // --trust only bypasses the working-directory trust prompt; tool-call
175
+ // approval is a separate gate that vibe --help documents as controlled by
176
+ // --auto-approve/--yolo. Without it, any prompt that needs a tool call
177
+ // blocks on interactive approval outside a TTY and fails with a generic
178
+ // error, which then gets misread as a real launch failure and persisted
179
+ // to the blocklist (claude/opencode/codex all pass their own equivalent
180
+ // non-interactive bypass already).
181
+ const args = ['--prompt', prompt, '--trust', '--yolo', '--output', 'text'];
45
182
 
46
183
  // Vibe programmatic mode does not support --resume flag in the same way
47
184
  // as other agents. The --resume flag exists but requires interactive selection
@@ -66,11 +203,12 @@ function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, mode
66
203
 
67
204
  function startMistralAgent({ prompt, worktree, env, resume = false, sessionId = null, model = null, teeOptions = {} }: StartMistralAgentOptions) {
68
205
  const invocation = buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model });
206
+ const invocationStart = new Date().toISOString();
69
207
  const resultPromise = spawnAndTee(invocation.command, invocation.args, { ...invocation.options, ...teeOptions } as any).then((result: any) => {
70
208
  if (result && result.stdout) {
71
209
  result.sessionId = extractMistralSessionId(result.stdout);
72
210
  }
73
- return result;
211
+ return processResult(result, undefined, invocationStart);
74
212
  });
75
213
 
76
214
  return { invocation, resultPromise };
@@ -79,6 +217,8 @@ function startMistralAgent({ prompt, worktree, env, resume = false, sessionId =
79
217
  export {
80
218
  buildMistralInvocation,
81
219
  extractMistralSessionId,
220
+ getMistralProviderModel,
221
+ processResult,
82
222
  resolveMistralCommand,
83
223
  startMistralAgent
84
224
  };
@@ -297,14 +297,14 @@ function applyExecuteFallback(opts) {
297
297
  * @returns {Promise<{relaunched: boolean, error?: string}>} Result of relaunch attempt
298
298
  */
299
299
  /**
300
- * @param {string} slug
301
- * @param {string} worktree
302
- * @param {string} errorMsg
303
- * @param {string} agent
304
- * @param {{isRelaunchableErrorFn?: Function, buildRelaunchPromptFn?: Function, workflowLauncherStatusFn?: Function, startAgentFn?: Function, log?: Function, error?: Function}} [options]
305
- */
300
+ * @param {string} slug
301
+ * @param {string} worktree
302
+ * @param {string} errorMsg
303
+ * @param {string} agent
304
+ * @param {{isRelaunchableErrorFn?: Function, buildRelaunchPromptFn?: Function, workflowLauncherStatusFn?: Function, startAgentFn?: Function, log?: Function, error?: Function, gateOutput?: {stdout: string, stderr: string}}} [options]
305
+ */
306
306
  async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, options = {}) {
307
- const { isRelaunchableErrorFn = repairHandoff.isRelaunchableError, buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt, workflowLauncherStatusFn = agents.workflowLauncherStatus, startAgentFn = agents.startAgent, log = fmt.log.plain, error = fmt.log.plainError } = options;
307
+ const { isRelaunchableErrorFn = repairHandoff.isRelaunchableError, buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt, workflowLauncherStatusFn = agents.workflowLauncherStatus, startAgentFn = agents.startAgent, log = fmt.log.plain, error = fmt.log.plainError, gateOutput } = options;
308
308
  // Check if this is a relaunchable error
309
309
  if (!isRelaunchableErrorFn(errorMsg)) {
310
310
  log(`Error is not relaunchable: ${errorMsg}`);
@@ -316,8 +316,8 @@ async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, options = {
316
316
  error(`Agent ${fmt.agent(agent)} is not available for relaunch: ${status.detail || status.reason || 'unknown'}`);
317
317
  return { relaunched: false, error: `Agent ${agent} launcher is not available` };
318
318
  }
319
- // Build the relaunch prompt
320
- const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree);
319
+ // Build the relaunch prompt, passing captured gate output if available (task-1387)
320
+ const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree, gateOutput);
321
321
  log(`Attempting to relaunch ${fmt.agent(agent)} to fix repairable handoff error...`);
322
322
  // startAgent handles resume flags internally for resume-capable agents (codex, claude, gemini, custom)
323
323
  try {
@@ -394,7 +394,7 @@ function validateCheckpointsBeforeHandoff(slug, worktree, options = {}) {
394
394
  * @param {{taskFile?: string | null, validateCheckpointsBeforeHandoffFn?: Function, performHandoff?: Function, startReviewLoop?: Function, repairHandoffFn?: {isRelaunchableError: Function, buildRelaunchPrompt: Function}, attemptAgentRelaunchFn?: Function, log?: Function, error?: Function}} [options]
395
395
  */
396
396
  async function runHandoffAndReview(slug, worktree, agent, options = {}) {
397
- const { taskFile = null, validateCheckpointsBeforeHandoffFn = validateCheckpointsBeforeHandoff, performHandoff: _performHandoff = (/** @type{string} */ s, /** @type{object} */ o) => handoff.performHandoff(s, o), startReviewLoop: _startReviewLoop = (/** @type{string} */ s, /** @type{object} */ o) => review.startReviewLoop(s, o), repairHandoffFn = /** @type{(s: string, w: string, e: string, o: object) => Promise<{repaired: boolean, blocker?: string}>} */ (repairHandoff), attemptAgentRelaunchFn = attemptAgentRelaunch, log = fmt.log.plain, error = fmt.log.plainError } = options;
397
+ const { taskFile = null, validateCheckpointsBeforeHandoffFn = validateCheckpointsBeforeHandoff, performHandoff: _performHandoff = (/** @type{string} */ s, /** @type{object} */ o) => handoff.performHandoff(s, o), startReviewLoop: _startReviewLoop = (/** @type{string} */ s, /** @type{object} */ o) => review.startReviewLoop(s, o), repairHandoffFn = /** @type{(s: string, w: string, e: string, o: object) => Promise<{repaired: boolean, blocker?: string}>} */ (repairHandoff.default), attemptAgentRelaunchFn = attemptAgentRelaunch, log = fmt.log.plain, error = fmt.log.plainError } = options;
398
398
  // Pre-handoff checkpoint enforcement: validate checkpoints exist before calling performHandoff()
399
399
  // This catches missing checkpoints immediately after the execute agent exits,
400
400
  // before the repair flow runs, and provides an explicit instruction to create them.
@@ -407,38 +407,68 @@ async function runHandoffAndReview(slug, worktree, agent, options = {}) {
407
407
  }
408
408
  let handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree });
409
409
  if (!handoffResult.ok) {
410
- // Attempt single repair for routine hygiene issues (dirty artifacts, rebase needed)
411
- log(`\nAutomated handoff failed: ${handoffResult.error}`);
412
- log(`Attempting post-execute repair...`);
413
- const { repaired, blocker } = await /** @type{Function} */ (repairHandoffFn)(slug, worktree, /** @type{string} */ (handoffResult.error), { taskFile, log, error });
414
- if (repaired) {
415
- log(`Repair successful. Retrying automated handoff...`);
416
- handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
417
- }
418
- else if (blocker) {
419
- // If repair failed but provided a specific blocker (e.g. rebase failure),
420
- // report that blocker as the final error instead of the original handoff error.
421
- handoffResult.error = blocker;
410
+ // Check for genuine gate failure (task-1387): automatic relaunch with captured output
411
+ const isGenuineGateFailure = handoffResult.gateOutput ||
412
+ (handoffResult.error && (/verification gate failed/i.test(handoffResult.error) ||
413
+ (/\bdeclared gate\b/i.test(handoffResult.error) && /\bfailed\b/i.test(handoffResult.error))));
414
+ if (isGenuineGateFailure) {
415
+ // Automatic relaunch with captured gate output, bounded to max 2 attempts
416
+ let relaunchCount = 0;
417
+ const maxRelaunches = 2;
418
+ while (relaunchCount < maxRelaunches) {
419
+ relaunchCount++;
420
+ log(`\nGenuine gate failure detected. Relaunch attempt ${relaunchCount}/${maxRelaunches}...`);
421
+ const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(slug, worktree, /** @type{string} */ (handoffResult.error), agent, { log, error, gateOutput: handoffResult.gateOutput });
422
+ if (relaunched) {
423
+ handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
424
+ if (handoffResult.ok) {
425
+ break; // Success — proceed to review loop
426
+ }
427
+ // Handoff still failed; continue loop for another relaunch attempt
428
+ }
429
+ else {
430
+ log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
431
+ break; // Relaunch itself failed; stop
432
+ }
433
+ }
434
+ if (!handoffResult.ok && relaunchCount >= maxRelaunches) {
435
+ handoffResult.error = `Gate failure persisting after ${maxRelaunches} relaunch attempts. Manual intervention required.`;
436
+ }
422
437
  }
423
- else if (!repaired && repairHandoff.isRelaunchableError(handoffResult.error)) {
424
- // Attempt agent relaunch for repairable content errors (missing goal-check table)
425
- log(`Content error detected. Attempting agent relaunch to fix...`);
426
- const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(slug, worktree, /** @type{string} */ (handoffResult.error), agent, { log, error });
427
- if (relaunched) {
428
- // Agent was relaunched successfully; re-invoke performHandoff to verify
429
- // the handoff-to-review transition actually completed, matching the
430
- // contract of the repair-success path above.
431
- log(`Agent relaunched. It will fix the checkpoint and retry handoff.`);
438
+ else {
439
+ // Original logic: attempt single repair for routine hygiene issues (dirty artifacts, rebase needed)
440
+ log(`\nAutomated handoff failed: ${handoffResult.error}`);
441
+ log(`Attempting post-execute repair...`);
442
+ const { repaired, blocker } = await /** @type{Function} */ (repairHandoffFn)(slug, worktree, /** @type{string} */ (handoffResult.error), { taskFile, log, error });
443
+ if (repaired) {
444
+ log(`Repair successful. Retrying automated handoff...`);
432
445
  handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
433
- if (!handoffResult.ok) {
434
- handoffResult.error = `Post-relaunch handoff failed: ${handoffResult.error || 'unknown'}`;
435
- }
436
- // Fall through to gatekeeper pushback / review loop / failure handling below.
437
446
  }
438
- else {
439
- // Relaunch failed or was not possible
440
- log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
441
- // Fall through to manual handoff message
447
+ else if (blocker) {
448
+ // If repair failed but provided a specific blocker (e.g. rebase failure),
449
+ // report that blocker as the final error instead of the original handoff error.
450
+ handoffResult.error = blocker;
451
+ }
452
+ else if (!repaired && repairHandoff.isRelaunchableError(handoffResult.error)) {
453
+ // Attempt agent relaunch for repairable content errors (missing goal-check table)
454
+ log(`Content error detected. Attempting agent relaunch to fix...`);
455
+ const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(slug, worktree, /** @type{string} */ (handoffResult.error), agent, { log, error });
456
+ if (relaunched) {
457
+ // Agent was relaunched successfully; re-invoke performHandoff to verify
458
+ // the handoff-to-review transition actually completed, matching the
459
+ // contract of the repair-success path above.
460
+ log(`Agent relaunched. It will fix the checkpoint and retry handoff.`);
461
+ handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
462
+ if (!handoffResult.ok) {
463
+ handoffResult.error = `Post-relaunch handoff failed: ${handoffResult.error || 'unknown'}`;
464
+ }
465
+ // Fall through to gatekeeper pushback / review loop / failure handling below.
466
+ }
467
+ else {
468
+ // Relaunch failed or was not possible
469
+ log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
470
+ // Fall through to manual handoff message
471
+ }
442
472
  }
443
473
  }
444
474
  }